Compare commits

...

132 Commits

Author SHA1 Message Date
Micah
6688bcb488 Build aarch64 windows and linux builds for releases 2024-08-28 16:36:29 -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
Kampfkarren
f467fa4e59 7.4.0-rc.1 [release] 2023-10-03 21:07:06 -07:00
Micah
41fca4a2bb Set version to Rojo 7.4.0-rc1 (#796)
Due to the rewrite of the plugin's core sync loop and the change for the
Notify backend on MacOS, along with all the other changes in 7.4.0, it
makes sense for us to use a release candidate before actually cutting a
proper `7.4.0` release.
2023-10-04 00:04:21 -04:00
Micah
d38f955144 Update rbx_dom dependencies (#795)
In preparation for a new release, rbx_dom needs to be updated.
2023-10-03 16:32:22 -07:00
Micah
010e50a25d Add check to ensure plugin version matches cargo version (#794)
This modifies Rojo's build script to throw a fit if we're building a
plugin with a semver incompatible version. In the process, it moves the
version of the plugin to a file named `Version.txt` that's parsed at
runtime. This should be minimally invasive but it's technically worse
for performance than the hardcoded table and string we had before.

This feels better than a CI check or just manually verifying because it
makes it physically impossible for us to forget since Rojo won't build
with it being wrong.
2023-10-03 10:29:47 -04:00
Micah
eab7c607cd Don't include package author in CLI help (#793)
LPG is at the moment listed by name and email in the `help` for Rojo. We
should probably remove that before cutting a new release.
2023-10-03 10:22:47 -04:00
Kenneth Loeffler
3cafbf7f1a Use PollWatcher in memofs StdBackend on macOS (#783) 2023-09-26 15:41:33 -07:00
Kenneth Loeffler
d7277b5a5b Clean up changelog in preparation for Rojo 7.4.0 (#788)
This PR aims to clean up the changelog in preparation for 7.4.0. We
should focus on making changes clear to users with examples, removing
any entries that they won't care about, and consolidating tightly
related changes

---------

Co-authored-by: boatbomber <zack@boatbomber.com>
Co-authored-by: Micah <48431591+nezuo@users.noreply.github.com>
2023-09-25 16:13:01 -07:00
Sasial
bb8dd1402d Add RunContext support for script outputs (#765)
Resolves #667

This PR:

- Introduces a new field in the project file: `scriptType` which has the
default value of `Class` (in parity with previous versions), but can
also be `RunContext`.
- This is then passed to `InstanceContext` from the `Project` struct.
- This then changes the RunContext in the lua `snapshot_middleware`

---------

Co-authored-by: Micah <dekkonot@rocketmail.com>
2023-09-23 13:28:09 -07:00
Barış
539cd0d418 Create .git-blame-ignore-revs & Add Stylua pr (#787)
adds stylua pr to ignore so it doesn't show on git blame history
2023-09-19 05:56:39 +00:00
boatbomber
0f8e1625d5 Stylua formatting (#785)
Uses Stylua to format all existing Lua files, and adds a CI check in
`lint` to pin this improvement. Excludes formatting dependencies, of
course.
2023-09-18 15:39:46 -07:00
boatbomber
840e9bedb2 Use GUIDs for ephemeral PluginWidgets (#784)
Because Roact will destroy and recreate a plugin widget if it unmounts
and remounts, Studio will complain about making a new widget with the
same ID as the old one.

The simplest solution is to just use GUIDs so we never have to worry
about this again. The ID is used internally for storing the widget's
dock state and other internal details, so we don't want *all* our
widgets to use GUIDs, only the ephemeral popup ones.
2023-09-18 21:44:14 +00:00
Micah
e11ad476fc Use conflicts_with for build flags (#779) 2023-09-11 13:32:43 -07:00
boatbomber
c43726bc75 Confirmation behaviors (#774) 2023-08-20 12:37:40 -07:00
Kenneth Loeffler
c9ab933a23 Update rbx_dom_lua to rojo-rbx/rbx-dom@7b03132 (#775)
Fixes a problem where the MaterialColors Lua encoder would return nothing
2023-08-16 16:47:04 -07:00
Micah
066a0b1668 Allow Terrain to be included in projects without a classname (#771)
Services, `StarterPlayerScripts`, and `StarterCharacterScripts` are
currently special-cased to allow them to be specified in project files
without a classname. This does the same to `Terrain` since it's a
singleton in the same style as those.
2023-08-15 05:41:49 -07:00
Micah
aa68fe412e Add ambiguous value support for MaterialColors (#770)
The last release of rbx_dom had support for `Terrain.MaterialColors`.
This allows it to be specified directly instead of only via the
fully-qualified syntax.
2023-08-14 15:41:15 -07:00
Micah
d748ea7e40 Update rbx_dom dependencies (#768) 2023-08-09 21:54:47 -07:00
boatbomber
a7a282078f Update Highlighter submodule from 0.8.1 to 0.8.2 (#767)
Highlighter had an edge case with changed events racing, which has been
resolved in the 0.8.2 patch.
2023-08-09 06:40:35 +00:00
boatbomber
2fad3b588a Fix theme (#763) 2023-08-03 00:47:03 +00:00
utrain
4cb5d4a9c5 Update Theme to use Color3.fromHex (#761) 2023-08-01 09:36:29 -07:00
Micah
5b22ef192e Don't override initial state for source diff view (#760) 2023-08-01 09:33:06 -07:00
boatbomber
34024d8524 Fix PatchTree performance issues (#755)
When building the tree, I've implemented a few improvements:

- We no longer traverse the full ancestry for every leaf node- we exit
early when we find a node that already exists
- We no longer search the entire tree to see if a node id exists before
creating one with that id, we just check if is in the map
2023-07-31 22:26:16 -07:00
boatbomber
ecc31dea15 View rich diffs for Source property changes (#748) 2023-07-26 23:50:29 -07:00
utrain
d0e48d9bdc Add some logging/error-handling for opener errors! (#745)
After seeing an issue with the opener not opening, it was strange to see
that there was no logging or error handling! This PR aims to solve that.
2023-07-26 15:10:02 -07:00
Micah
f6fc5599c0 Add plugin template (#738)
Adds a new plugin template to the `init` command: `rojo init --kind
plugin`.
2023-07-23 17:12:48 -07:00
Micah
89b6666436 Support ambiguous syntax for Font properties (#731) 2023-07-20 21:30:18 -07:00
Micah
94d45a2262 Remove references to LPG's patreon (#744)
Given that LPG is not maintaining Rojo or its related projects, it does
not make sense for us to suggest his patreon. This PR removes them.
2023-07-20 07:57:02 -07:00
Micah
dc17a185ca Add plugin build flag (#735)
Resolves #715.
2023-07-17 19:15:40 -07:00
boatbomber
4915477823 Expose reconciler hooks on servesession (#741) 2023-07-17 04:03:06 +00:00
boatbomber
8662d2227c Improve Rojo sync reliability (#739)
Uses non-recursive main loop and avoids hanging promises
2023-07-16 14:59:30 -07:00
dependabot[bot]
dd01a9bef3 Bump openssl from 0.10.51 to 0.10.55 (#737) 2023-07-14 14:04:11 -07:00
Shae
6e320b1fd5 Add support for TOML files (#633)
TOML maps well to Lua, is easier to read and write than JSON, and is
commonly used by Roblox tools.

Use cases:
* Put game, plugin, or library config in a toml file
* Sync in toml files generated by tools
* Sync in config files for tools so that the game can double-check that
the config file has been followed. (e.g. check that packages match
versions specified in wally.toml)
2023-07-14 20:36:50 +00:00
boatbomber
6e40993199 Rework patch visualizer with many fixes and improvements (#726)
- Reconciler now has precommit and postcommit hooks for patch applying
  - This is used to compute a patch tree snapshot precommit and update the
tree metadata postcommit
- PatchVisualizer can now display Removes that happened during sync
  - It was previously missing because the removed objects no longer
existed so it couldn't get any info on them (This is resolved because
the info is gotten in precommit, before the instance was removed)
- PatchVisualizer now shows Old and New values instead of just Incoming
during sync
  - (Still displays Current and Incoming during confirmation)
  - This is much more useful, since you now see what the changes were and
not just which things were changed
- PatchVisualizer displays clarifying message when initial sync has no
changes instead of just showing a blank box
- Objects in the tree UI no longer get stuck expanded when the next
patch has the same instance but different info on it
- Objects in the tree UI correctly become selectable after their
instance is added and unclickable when removed during sync
2023-07-13 20:09:19 -07:00
boatbomber
9d48af2b50 Better settings control (#725)
- Lock some settings during sync
- Make experimental features more clearly labelled
2023-07-12 13:00:07 -07:00
boatbomber
28d48a76e3 Fix tooltips crashing on thread cancellation edge cases (#727) 2023-07-12 10:01:21 -07:00
utrain
80eb14f9da Removed InstanceSnapshot snapshot_id's redudant Ref. (#730)
Ref now is an optional inside, so it's redundant to have an option
wrapping an option. The only snapshots that were changed were any that
had a Ref within (from none to zeroed). Some also had some newlines
added in the end.
2023-07-12 10:00:09 -07:00
boatbomber
623fa06d52 Improve tooltip behavior (#723) 2023-07-08 21:41:47 -07:00
boatbomber
7154113c13 Add buttons on connected page (#722) 2023-07-08 21:23:25 -07:00
boatbomber
0a932ff880 Show failed to apply in visualizer (#717)
Objects with failed changes will highlight, and the specific failed
changes in their list will highlight as well.
2023-07-08 19:31:26 -07:00
boatbomber
7ef4a1ff12 Select Instances from diff tree (#709) 2023-07-08 18:45:25 -07:00
Barış
ccc52b69d2 (fix) Move changelog job to it's own workflow (#720)
- moved the job to its own workflow file
- changed the label to `skip changelog`
2023-07-09 01:30:59 +00:00
Barış
8139fdc738 (feat) Add Changelog Check Action (#719) 2023-07-09 00:44:28 +00:00
boatbomber
a4fd53d516 Update changelog to include #713 2023-07-08 15:54:19 -07:00
boatbomber
27357110b5 Handle strings and instances in patch removes for visualizer (#713)
When an object is deleted in a patch, it is either represented with an
ID or an Instance. On initial sync, removals are instances since the map
does not contain those instances. Later removals of managed objects use
an ID. The patch visualizer only handled instances, so this fixes that.

Closes #710.
2023-07-08 14:37:42 -07:00
boatbomber
fde78738b6 Improve sync info (#692)
- Fixed an edge case where disconnecting and then reconnecting would
retain outdated info
- Fixed an issue where new patches wouldn't immediately update the
change info text
- Removed extraneous changes count info, as it was not useful and could
be checked in the visualizer anyway
- Added warning info for when some changes fail to apply
- Updates timestamp of last sync even if patch was empty, to have a more
accurate signal on Rojo's uptime
2023-07-05 21:38:11 +00:00
boatbomber
ce530e795a Fix Rojo breaking when users undo/redo in Studio (#708) 2023-07-05 14:09:11 -07:00
boatbomber
658d211779 Sync reminder notification & notification actions (#689)
Implements and closes #652.

---------

Co-authored-by: Chris Chang <51393127+chriscerie@users.noreply.github.com>
Co-authored-by: Micah <git@dekkonot.com>
2023-07-04 20:09:41 +00:00
boatbomber
66c1cd0d93 Add protection against syncing non place projects (#691)
Closes #113.

Does what it says on the tin. When you try to sync a non-place project,
it gives a clear message instead of crashing.
2023-06-30 13:44:42 -07:00
boatbomber
55ac231cec Skip confirming patches that only contain a datamodel name change (#688)
Closes #672.

Skips the user confirmation if the patch contains only a datamodel name
change.

I decided to build generic PatchSet utility functions in case we need to
use similar logic in the future, in addition to maintaining clear
division of duties. The app code shouldn't be too dependent upon patch
internal structure when we can avoid it.

---------

Co-authored-by: Kenneth Loeffler <kenloef@gmail.com>
2023-06-30 11:21:36 -07:00
Kenneth Loeffler
67674d53a2 Fix remaining clippy lints (#687)
The eighth (and final) in a series of PRs that aim to get CI passing
2023-06-30 11:06:43 -07:00
Kenneth Loeffler
8646b2dfce Fix snapshot middleware clippy lints (#686)
The seventh in a series of PRs that aim to get CI passing
2023-06-30 11:06:12 -07:00
Kenneth Loeffler
a2f68c2e3c Fix snapshot clippy lints (#685)
The sixth in a series of PRs that aim to get CI passing
2023-06-30 11:05:55 -07:00
Kenneth Loeffler
5b1a090c5e Fix serve session clippy lints (#684)
The fifth in a series of PRs that aim to get CI passing
2023-06-30 11:05:30 -07:00
Kenneth Loeffler
e9efa238b0 Fix project clippy lints (#683)
The fourth in a series of PRs that aim to get CI passing
2023-06-30 11:04:56 -07:00
Kenneth Loeffler
0dabd8a1f6 Fix memofs clippy lints (#682)
The third in a series of PRs that aim to get CI passing
2023-06-30 11:04:04 -07:00
Kenneth Loeffler
b7a1f82f56 Fix cli clippy lints (#681)
The second in a series of PRs that aim to get CI passing
2023-06-30 11:03:37 -07:00
Kenneth Loeffler
2507e096b7 Fix change processor clippy lints (#680)
Bear with me here...

The first in a series of PRs that aim to get CI passing
2023-06-30 11:03:09 -07:00
boatbomber
b303b0a99c Add PRs 674 and 675 to changelog (#693)
Forgot to update changelog in those PRs, this remedies that
2023-06-30 11:02:14 -07:00
Filip Tibell
342fb57d14 Fix compilation target in release workflow (#701)
Fixes macOS aarch64 builds not actually outputting x86 binaries by
specifying the `--target` flag during compilation in the release
workflow. These are the same changes as [this PR in
Aftman](https://github.com/LPGhatguy/aftman/pull/34), which had the same
issue, and uses the same workflow.
2023-06-30 10:53:19 -07:00
Boegie19
a9ca77e27f Change gitmodules from ssh to https (#696)
Why if you want to use ssh you need todo more setup aka add a ssh key
into github while with https you don't have to do the extra work aka
makes rojo easier to contribute to.
2023-06-16 11:41:30 -07:00
boatbomber
6542304340 Fix the diff visualizer of connected sessions (#674)
Clicking on the "X changes X ago" message opens up a handy diff
visualizer to see what those changes were. However, it had quite a few
issues that needed fixing.

- Disconnecting a session with it expanded caused an error as it tried
to read the serveSession that no longer exists during the page fade
transition. (#671)
- Resolved by converting to stateful component and holding the
serveSession during the lifetime to ensure it can render the last known
changes during the fade transition
- Leaving it open while new changes are synced did not update the
visualizer
- The patch data was piggybacking on an existing binding, which meant
that new patches did not trigger rerender.
    - Resolved by converting to state
    - Also made some improvements to that old binding
- Moved from app to connected page for better organization and
separation of duties
      - No more useless updates causing rerenders with no real change
- Scroll window child component wouldn't actually display the updated
visuals
    - Resolved by making major improvements to VirtualScroller
      - Made more robust against edge case states
      - Made smarter about knowing when it needs to refresh

As you can see in this slow motion GIF, it works now.

![slowedDemo](https://github.com/rojo-rbx/rojo/assets/40185666/c9fc8489-72a9-47be-ae45-9c420e1535d4)
2023-06-03 22:46:16 -07:00
boatbomber
6b0f7f94b6 Fix disconnected session activity (#675)
When a session is disconnected, the apiContext long-polling for messages
continues until resolved/rejected. This means that even after a session
is disconnected, a message can be received and handled.

This leads to bad behavior, as the session was already cleaned up and
the message cannot be handled correctly. The instance map was cleaned up
upon disconnect, so it will warn about unapplied changes to unknown
instances. (Like #512)

It's very easy to repro:
Connect a session, disconnect it, then save a change.


https://github.com/rojo-rbx/rojo/assets/40185666/846a7269-7043-4727-9f9c-b3ac55a18a3a

-----------

This PR fixes that neatly by tracking all active requests in a map, and
cancelling their promises when we disconnect.
2023-06-01 08:18:04 -07:00
Kenneth Loeffler
d87c76a23e Switch plugin packages back to git submodules (#678)
Alright, so I hate to be the one to do this, but #584 broke crates.io
publishing and also caused librojo to be unusable. I see that there was
some discussion on Discord shortly after the problem was realized, but
there was no action taken.

I think keeping librojo and publishing working far, far outweigh any
convenience added by Wally.

I've kept the same `Packages` naming convention to keep the diff
minimal.
2023-05-26 10:26:21 -07:00
Filip Tibell
305423b856 Sourcemap performance improvements (#668)
This PR brings two performance improvements to the `rojo sourcemap`
command:

- Use `rayon` with a small threadpool to parallelize sourcemap
generation while still keeping startup cost very low
- Remove conversions to owned strings and use lifetimes tied to the dom
instead, which mostly improves performance with the
`--include-non-scripts` flag enabled

From my personal testing on an M1 mac this decreases the sourcemap
generation time of our games by 2x or more, from ~20ms to ~8ms on one
project and ~30ms to ~15ms on another. Generation is pretty fast to
begin with but since sourcemaps are heavily used in interactive tools
(like luau-lsp) a difference of a couple frames can be great for ux.
2023-05-06 01:01:15 -07:00
Miizzuu
4b62190aff Fix wrong version of Rojo displaying in studio. (#669) 2023-05-06 01:00:45 -07:00
308 changed files with 39909 additions and 9007 deletions

View File

@@ -24,3 +24,6 @@ insert_final_newline = true
[*.lua] [*.lua]
indent_style = tab indent_style = tab
[*.luau]
indent_style = tab

2
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,2 @@
# stylua formatting
0f8e1625d572a5fe0f7b5c08653ff92cc837d346

1
.gitattributes vendored Normal file
View File

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

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
patreon: lpghatguy

23
.github/workflows/changelog.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Changelog Check
on:
pull_request:
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
branches:
- master
jobs:
build:
name: Check Actions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Changelog check
uses: Zomzog/changelog-checker@v1.3.0
with:
fileName: CHANGELOG.md
noChangelogLabel: skip changelog
checkNotification: Simple
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -12,66 +12,88 @@ on:
jobs: jobs:
build: build:
name: Build and Test name: Build and Test
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
rust_version: [stable, 1.69.0] os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with:
submodules: true
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ matrix.rust_version }} - name: Rust cache
override: true uses: Swatinem/rust-cache@v2
profile: minimal
- name: Setup Aftman - name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0 uses: ok-nick/setup-aftman@v0.3.0
with: with:
version: 'v0.2.7' version: 'v0.2.7'
- name: Install packages
run: |
cd plugin
wally install
cd ..
- name: Build - name: Build
run: cargo build --locked --verbose run: cargo build --locked --verbose
- name: Test - name: Test
run: cargo test --locked --verbose run: cargo test --locked --verbose
lint: msrv:
name: Rustfmt and Clippy name: Check MSRV
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with:
submodules: true
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@1.70.0
with:
toolchain: stable - name: Rust cache
override: true uses: Swatinem/rust-cache@v2
components: rustfmt, clippy
- name: Setup Aftman - name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0 uses: ok-nick/setup-aftman@v0.3.0
with: with:
version: 'v0.2.7' version: 'v0.2.7'
- name: Install packages - name: Build
run: | run: cargo build --locked --verbose
cd plugin
wally install lint:
cd .. name: Rustfmt, Clippy, Stylua, & Selene
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0
with:
version: 'v0.2.7'
- name: Stylua
run: stylua --check plugin/src
- name: Selene
run: selene plugin/src
- name: Rustfmt - name: Rustfmt
run: cargo fmt -- --check run: cargo fmt -- --check
- name: Clippy - name: Clippy
run: cargo clippy run: cargo clippy

View File

@@ -8,26 +8,22 @@ jobs:
create-release: create-release:
name: Create Release name: Create Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4
- name: Create Release - name: Create Release
id: create_release
uses: actions/create-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: run: |
tag_name: ${{ github.ref }} gh release create ${{ github.ref_name }} --draft --verify-tag --title ${{ github.ref_name }}
release_name: ${{ github.ref }}
draft: true
prerelease: false
build-plugin: build-plugin:
needs: ["create-release"] needs: ["create-release"]
name: Build Roblox Studio Plugin name: Build Roblox Studio Plugin
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with:
submodules: true
- name: Setup Aftman - name: Setup Aftman
uses: ok-nick/setup-aftman@v0.1.0 uses: ok-nick/setup-aftman@v0.1.0
@@ -36,27 +32,17 @@ jobs:
trust-check: false trust-check: false
version: 'v0.2.6' version: 'v0.2.6'
- name: Install packages
run: |
cd plugin
wally install
cd ..
- name: Build Plugin - name: Build Plugin
run: rojo build plugin --output Rojo.rbxm run: rojo build plugin --output Rojo.rbxm
- name: Upload Plugin to Release - name: Upload Plugin to Release
uses: actions/upload-release-asset@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: run: |
upload_url: ${{ needs.create-release.outputs.upload_url }} gh release upload ${{ github.ref_name }} Rojo.rbxm
asset_path: Rojo.rbxm
asset_name: Rojo.rbxm
asset_content_type: application/octet-stream
- name: Upload Plugin to Artifacts - name: Upload Plugin to Artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Rojo.rbxm name: Rojo.rbxm
path: Rojo.rbxm path: Rojo.rbxm
@@ -73,11 +59,21 @@ jobs:
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
label: linux-x86_64 label: linux-x86_64
- host: linux
os: ubuntu-20.04
target: aarch64-unknown-linux-gnu
label: linux-aarch64
- host: windows - host: windows
os: windows-latest os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
label: windows-x86_64 label: windows-x86_64
- host: windows
os: windows-latest
target: aarch64-pc-windows-msvc
label: windows-aarch64
- host: macos - host: macos
os: macos-latest os: macos-latest
target: x86_64-apple-darwin target: x86_64-apple-darwin
@@ -93,22 +89,14 @@ jobs:
env: env:
BIN: rojo BIN: rojo
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with:
- name: Get Version from Tag submodules: true
shell: bash
# https://github.community/t/how-to-get-just-the-tag-name/16241/7#M1027
run: |
echo "PROJECT_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
echo "Version is: ${{ env.PROJECT_VERSION }}"
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: stable targets: ${{ matrix.target }}
target: ${{ matrix.target }}
override: true
profile: minimal
- name: Setup Aftman - name: Setup Aftman
uses: ok-nick/setup-aftman@v0.1.0 uses: ok-nick/setup-aftman@v0.1.0
@@ -117,51 +105,41 @@ jobs:
trust-check: false trust-check: false
version: 'v0.2.6' version: 'v0.2.6'
- name: Install packages
run: |
cd plugin
wally install
cd ..
shell: bash
- name: Build Release - name: Build Release
run: cargo build --release --locked --verbose run: cargo build --release --locked --verbose --target ${{ matrix.target }}
env: env:
# Build into a known directory so we can find our build artifact more # Build into a known directory so we can find our build artifact more
# easily. # easily.
CARGO_TARGET_DIR: output CARGO_TARGET_DIR: output
# On platforms that use OpenSSL, ensure it is statically linked to - name: Generate Artifact Name
# make binaries more portable.
OPENSSL_STATIC: 1
- name: Create Release Archive
shell: bash shell: bash
env:
TAG_NAME: ${{ github.ref_name }}
run: |
echo "ARTIFACT_NAME=$BIN-${TAG_NAME#v}-${{ matrix.label }}.zip" >> "$GITHUB_ENV"
- name: Create Archive and Upload to Release
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
mkdir staging mkdir staging
if [ "${{ matrix.host }}" = "windows" ]; then if [ "${{ matrix.host }}" = "windows" ]; then
cp "output/release/$BIN.exe" staging/ cp "output/${{ matrix.target }}/release/$BIN.exe" staging/
cd staging cd staging
7z a ../release.zip * 7z a ../$ARTIFACT_NAME *
else else
cp "output/release/$BIN" staging/ cp "output/${{ matrix.target }}/release/$BIN" staging/
cd staging cd staging
zip ../release.zip * zip ../$ARTIFACT_NAME *
fi fi
- name: Upload Archive to Release gh release upload ${{ github.ref_name }} ../$ARTIFACT_NAME
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: release.zip
asset_name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip
asset_content_type: application/octet-stream
- name: Upload Archive to Artifacts - name: Upload Archive to Artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip path: ${{ env.ARTIFACT_NAME }}
path: release.zip name: ${{ env.ARTIFACT_NAME }}

3
.gitignore vendored
View File

@@ -13,9 +13,6 @@
# Test places for the Roblox Studio Plugin # Test places for the Roblox Studio Plugin
/plugin/*.rbxlx /plugin/*.rbxlx
# Packages for the Roblox Studio Plugin
/plugin/*Packages
# Roblox Studio holds 'lock' files on places # Roblox Studio holds 'lock' files on places
*.rbxl.lock *.rbxl.lock
*.rbxlx.lock *.rbxlx.lock

18
.gitmodules vendored Normal file
View File

@@ -0,0 +1,18 @@
[submodule "plugin/Packages/Roact"]
path = plugin/Packages/Roact
url = https://github.com/roblox/roact.git
[submodule "plugin/Packages/Flipper"]
path = plugin/Packages/Flipper
url = https://github.com/reselim/flipper.git
[submodule "plugin/Packages/Promise"]
path = plugin/Packages/Promise
url = https://github.com/evaera/roblox-lua-promise.git
[submodule "plugin/Packages/t"]
path = plugin/Packages/t
url = https://github.com/osyrisrblx/t.git
[submodule "plugin/Packages/TestEZ"]
path = plugin/Packages/TestEZ
url = https://github.com/roblox/testez.git
[submodule "plugin/Packages/Highlighter"]
path = plugin/Packages/Highlighter
url = https://github.com/boatbomber/highlighter.git

View File

@@ -1,6 +1,313 @@
# Rojo Changelog # Rojo Changelog
## Unreleased Changes ## Unreleased Changes
* Projects may now manually link `Ref` properties together using `Attributes`. ([#843])
This has two parts: using `id` or `$id` in JSON files or a `Rojo_Target` attribute, an Instance
is given an ID. Then, that ID may be used elsewhere in the project to point to an Instance
using an attribute named `Rojo_Target_PROP_NAME`, where `PROP_NAME` is the name of a property.
As an example, here is a `model.json` for an ObjectValue that refers to itself:
```json
{
"id": "arbitrary string",
"attributes": {
"Rojo_Target_Value": "arbitrary string"
}
}
```
This is a very rough implementation and the usage will become more ergonomic
over time.
* Updated Undo/Redo history to be more robust ([#915])
* Added popout diff visualizer for table properties like Attributes and Tags ([#834])
* Updated Theme to use Studio colors ([#838])
* Improved patch visualizer UX ([#883])
* Added update notifications for newer compatible versions in the Studio plugin. ([#832])
* Added experimental setting for Auto Connect in playtests ([#840])
* Improved settings UI ([#886])
* `Open Scripts Externally` option can now be changed while syncing ([#911])
* Projects may now specify rules for syncing files as if they had a different file extension. ([#813])
This is specified via a new field on project files, `syncRules`:
```json
{
"syncRules": [
{
"pattern": "*.foo",
"use": "text",
"exclude": "*.exclude.foo",
},
{
"pattern": "*.bar.baz",
"use": "json",
"suffix": ".bar.baz",
},
],
"name": "SyncRulesAreCool",
"tree": {
"$path": "src"
}
}
```
The `pattern` field is a glob used to match the sync rule to files. If present, the `suffix` field allows you to specify parts of a file's name get cut off by Rojo to name the Instance, including the file extension. If it isn't specified, Rojo will only cut off the first part of the file extension, up to the first dot.
Additionally, the `exclude` field allows files to be excluded from the sync rule if they match a pattern specified by it. If it's not present, all files that match `pattern` will be modified using the sync rule.
The `use` field corresponds to one of the potential file type that Rojo will currently include in a project. Files that match the provided pattern will be treated as if they had the file extension for that file type. A full list is below:
| `use` value | file extension |
|:---------------|:----------------|
| `serverScript` | `.server.lua` |
| `clientScript` | `.client.lua` |
| `moduleScript` | `.lua` |
| `json` | `.json` |
| `toml` | `.toml` |
| `csv` | `.csv` |
| `text` | `.txt` |
| `jsonModel` | `.model.json` |
| `rbxm` | `.rbxm` |
| `rbxmx` | `.rbxmx` |
| `project` | `.project.json` |
| `ignore` | None! |
**All** sync rules are reset between project files, so they must be specified in each one when nesting them. This is to ensure that nothing can break other projects by changing how files are synced!
[#813]: https://github.com/rojo-rbx/rojo/pull/813
[#832]: https://github.com/rojo-rbx/rojo/pull/832
[#834]: https://github.com/rojo-rbx/rojo/pull/834
[#838]: https://github.com/rojo-rbx/rojo/pull/838
[#840]: https://github.com/rojo-rbx/rojo/pull/840
[#843]: https://github.com/rojo-rbx/rojo/pull/843
[#883]: https://github.com/rojo-rbx/rojo/pull/883
[#886]: https://github.com/rojo-rbx/rojo/pull/886
[#911]: https://github.com/rojo-rbx/rojo/pull/911
[#915]: https://github.com/rojo-rbx/rojo/pull/915
## [7.4.3] - August 6th, 2024
* Fixed issue with building binary files introduced in 7.4.2
* Fixed `value of type nil cannot be converted to number` warning spam in output. [#955]
[#955]: https://github.com/rojo-rbx/rojo/pull/955
## [7.4.2] - July 23, 2024
* Added Never option to Confirmation ([#893])
* Fixed removing trailing newlines ([#903])
* Updated the internal property database, correcting an issue with `SurfaceAppearance.Color` that was reported [here][Surface_Appearance_Color_1] and [here][Surface_Appearance_Color_2] ([#948])
[#893]: https://github.com/rojo-rbx/rojo/pull/893
[#903]: https://github.com/rojo-rbx/rojo/pull/903
[#948]: https://github.com/rojo-rbx/rojo/pull/948
[Surface_Appearance_Color_1]: https://devforum.roblox.com/t/jailbreak-custom-character-turned-shiny-black-no-texture/3075563
[Surface_Appearance_Color_2]: https://devforum.roblox.com/t/surfaceappearance-not-displaying-correctly/3075588
## [7.4.1] - February 20, 2024
* Made the `name` field optional on project files ([#870])
Files named `default.project.json` inherit the name of the folder they're in and all other projects
are named as expect (e.g. `foo.project.json` becomes an Instance named `foo`)
There is no change in behavior if `name` is set.
* Fixed incorrect results when building model pivots ([#865])
* Fixed incorrect results when serving model pivots ([#868])
* Rojo now converts any line endings to LF, preventing spurious diffs when syncing Lua files on Windows ([#854])
* Fixed Rojo plugin failing to connect when project contains certain unreadable properties ([#848])
* Fixed various cases where patch visualizer would not display sync failures ([#845], [#844])
* Fixed http error handling so Rojo can be used in Github Codespaces ([#847])
[#848]: https://github.com/rojo-rbx/rojo/pull/848
[#845]: https://github.com/rojo-rbx/rojo/pull/845
[#844]: https://github.com/rojo-rbx/rojo/pull/844
[#847]: https://github.com/rojo-rbx/rojo/pull/847
[#854]: https://github.com/rojo-rbx/rojo/pull/854
[#865]: https://github.com/rojo-rbx/rojo/pull/865
[#868]: https://github.com/rojo-rbx/rojo/pull/868
[#870]: https://github.com/rojo-rbx/rojo/pull/870
## [7.4.0] - January 16, 2024
* Improved the visualization for array properties like Tags ([#829])
* Significantly improved performance of `rojo serve`, `rojo build --watch`, and `rojo sourcemap --watch` on macOS. ([#830])
* Changed *.lua files that init command generates to *.luau ([#831])
* Does not remind users to sync if the sync lock is claimed already ([#833])
[#829]: https://github.com/rojo-rbx/rojo/pull/829
[#830]: https://github.com/rojo-rbx/rojo/pull/830
[#831]: https://github.com/rojo-rbx/rojo/pull/831
[#833]: https://github.com/rojo-rbx/rojo/pull/833
## [7.4.0-rc3] - October 25, 2023
* Changed `sourcemap --watch` to only generate the sourcemap when it's necessary ([#800])
* Switched script source property getter and setter to `ScriptEditorService` methods ([#801])
This ensures that the script editor reflects any changes Rojo makes to a script while it is open in the script editor.
* Fixed issues when handling `SecurityCapabilities` values ([#803], [#807])
* Fixed Rojo plugin erroring out when attempting to sync attributes with invalid names ([#809])
[#800]: https://github.com/rojo-rbx/rojo/pull/800
[#801]: https://github.com/rojo-rbx/rojo/pull/801
[#803]: https://github.com/rojo-rbx/rojo/pull/803
[#807]: https://github.com/rojo-rbx/rojo/pull/807
[#809]: https://github.com/rojo-rbx/rojo/pull/809
## [7.4.0-rc2] - October 3, 2023
* Fixed bug with parsing version for plugin validation ([#797])
[#797]: https://github.com/rojo-rbx/rojo/pull/797
## [7.4.0-rc1] - October 3, 2023
### Additions
#### Project format
* Added support for `.toml` files to `$path` ([#633])
* Added support for `Font` and `CFrame` attributes ([rbx-dom#299], [rbx-dom#296])
* Added the `emitLegacyScripts` field to the project format ([#765]). The behavior is outlined below:
| `emitLegacyScripts` Value | Action Taken by Rojo |
|---------------------------|------------------------------------------------------------------------------------------------------------------|
| false | Rojo emits Scripts with the appropriate `RunContext` for `*.client.lua` and `*.server.lua` files in the project. |
| true (default) | Rojo emits LocalScripts and Scripts with legacy `RunContext` (same behavior as previously). |
It can be used like this:
```json
{
"emitLegacyScripts": false,
"name": "MyCoolRunContextProject",
"tree": {
"$path": "src"
}
}
```
* Added `Terrain` classname inference, similar to services ([#771])
`Terrain` may now be defined in projects without using `$className`:
```json
"Workspace": {
"Terrain": {
"$path": "path/to/terrain.rbxm"
}
}
```
* Added support for `Terrain.MaterialColors` ([#770])
`Terrain.MaterialColors` is now represented in projects in a human readable format:
```json
"Workspace": {
"Terrain": {
"$path": "path/to/terrain.rbxm"
"$properties": {
"MaterialColors": {
"Grass": [10, 20, 30],
"Asphalt": [40, 50, 60],
"LeafyGrass": [255, 155, 55]
}
}
}
}
```
* Added better support for `Font` properties ([#731])
`FontFace` properties may now be defined using implicit property syntax:
```json
"TextBox": {
"$className": "TextBox",
"$properties": {
"FontFace": {
"family": "rbxasset://fonts/families/RobotoMono.json",
"weight": "Thin",
"style": "Normal"
}
}
}
```
#### Patch visualizer and notifications
* Added a setting to control patch confirmation behavior ([#774])
This is a new setting for controlling when the Rojo plugin prompts for confirmation before syncing. It has four options:
* Initial (default): prompts only once for a project in a given Studio session
* Always: always prompts for confirmation
* Large Changes: only prompts when there are more than X changed instances. The number of instances is configurable - an additional setting for the number of instances becomes available when this option is chosen
* Unlisted PlaceId: only prompts if the place ID is not present in servePlaceIds
* Added the ability to select Instances in patch visualizer ([#709])
Double-clicking an instance in the patch visualizer sets Roblox Studio's selection to the instance.
* Added a sync reminder notification. ([#689])
Rojo detects if you have previously synced to a place, and displays a notification reminding you to sync again:
![Rojo reminds you to sync a place that you've synced previously](https://user-images.githubusercontent.com/40185666/242397435-ccdfddf2-a63f-420c-bc18-a6e3d6455bba.png)
* Added rich Source diffs in patch visualizer ([#748])
A "View Diff" button for script sources is now present in the patch visualizer. Clicking it displays a side-by-side diff of the script changes:
![The patch visualizer contains a "view diff" button](https://user-images.githubusercontent.com/40185666/256065992-3f03558f-84b0-45a1-80eb-901f348cf067.png)
![The "View Diff" button opens a widget that displays a diff](https://user-images.githubusercontent.com/40185666/256066084-1d9d8fe8-7dad-4ee7-a542-b4aee35a5644.png)
* Patch visualizer now indicates what changes failed to apply. ([#717])
A clickable warning label is displayed when the Rojo plugin is unable to apply changes. Clicking the label displays precise information about which changes failed:
![Patch visualizer displays a clickable warning label when changes fail to apply](https://user-images.githubusercontent.com/40185666/252063660-f08399ef-1e16-4f1c-bed8-552821f98cef.png)
#### Miscellaneous
* Added `plugin` flag to the `build` command that outputs to the local plugins folder ([#735])
This is a flag that builds a Rojo project into Roblox Studio's plugins directory. This allows you to build a Rojo project and load it into Studio as a plugin without having to type the full path to the plugins directory. It can be used like this: `rojo build <PATH-TO-PROJECT> --plugin <FILE-NAME>`
* Added new plugin template to the `init` command ([#738])
This is a new template geared towards plugins. It is similar to the model template, but creates a `Script` instead of a `ModuleScript` in the `src` directory. It can be used like this: `rojo init --kind plugin`
* Added protection against syncing non-place projects as a place. ([#691])
* Add buttons for navigation on the Connected page ([#722])
### Fixes
* Significantly improved performance of `rojo sourcemap` ([#668])
* Fixed the diff visualizer of connected sessions. ([#674])
* Fixed disconnected session activity. ([#675])
* Skip confirming patches that contain only a datamodel name change. ([#688])
* Fix Rojo breaking when users undo/redo in Studio ([#708])
* Improve tooltip behavior ([#723])
* Better settings controls ([#725])
* Rework patch visualizer with many fixes and improvements ([#713], [#726], [#755])
[#668]: https://github.com/rojo-rbx/rojo/pull/668
[#674]: https://github.com/rojo-rbx/rojo/pull/674
[#675]: https://github.com/rojo-rbx/rojo/pull/675
[#688]: https://github.com/rojo-rbx/rojo/pull/688
[#689]: https://github.com/rojo-rbx/rojo/pull/689
[#691]: https://github.com/rojo-rbx/rojo/pull/691
[#709]: https://github.com/rojo-rbx/rojo/pull/709
[#708]: https://github.com/rojo-rbx/rojo/pull/708
[#713]: https://github.com/rojo-rbx/rojo/pull/713
[#717]: https://github.com/rojo-rbx/rojo/pull/717
[#722]: https://github.com/rojo-rbx/rojo/pull/722
[#723]: https://github.com/rojo-rbx/rojo/pull/723
[#725]: https://github.com/rojo-rbx/rojo/pull/725
[#726]: https://github.com/rojo-rbx/rojo/pull/726
[#633]: https://github.com/rojo-rbx/rojo/pull/633
[#735]: https://github.com/rojo-rbx/rojo/pull/735
[#731]: https://github.com/rojo-rbx/rojo/pull/731
[#738]: https://github.com/rojo-rbx/rojo/pull/738
[#748]: https://github.com/rojo-rbx/rojo/pull/748
[#755]: https://github.com/rojo-rbx/rojo/pull/755
[#765]: https://github.com/rojo-rbx/rojo/pull/765
[#770]: https://github.com/rojo-rbx/rojo/pull/770
[#771]: https://github.com/rojo-rbx/rojo/pull/771
[#774]: https://github.com/rojo-rbx/rojo/pull/774
[rbx-dom#299]: https://github.com/rojo-rbx/rbx-dom/pull/299
[rbx-dom#296]: https://github.com/rojo-rbx/rbx-dom/pull/296
## [7.3.0] - April 22, 2023 ## [7.3.0] - April 22, 2023
* Added `$attributes` to project format. ([#574]) * Added `$attributes` to project format. ([#574])

1355
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "rojo" name = "rojo"
version = "7.3.0" version = "7.4.0"
rust-version = "1.68.2" rust-version = "1.70.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers" description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0" license = "MPL-2.0"
@@ -12,9 +12,7 @@ readme = "README.md"
edition = "2021" edition = "2021"
build = "build.rs" build = "build.rs"
exclude = [ exclude = ["/test-projects/**"]
"/test-projects/**",
]
[profile.dev] [profile.dev]
panic = "abort" panic = "abort"
@@ -28,7 +26,9 @@ default = []
# Enable this feature to live-reload assets from the web UI. # Enable this feature to live-reload assets from the web UI.
dev_live_assets = [] dev_live_assets = []
profile-with-tracy = ["profiling/profile-with-tracy", "tracy-client"] # Run Rojo with this feature to open a Tracy session.
# Currently uses protocol v63, last supported in Tracy 0.9.1.
profile-with-tracy = ["profiling/profile-with-tracy"]
[workspace] [workspace]
members = ["crates/*"] members = ["crates/*"]
@@ -42,7 +42,7 @@ name = "build"
harness = false harness = false
[dependencies] [dependencies]
memofs = { version = "0.2.0", path = "crates/memofs" } memofs = { version = "0.3.0", path = "crates/memofs" }
# These dependencies can be uncommented when working on rbx-dom simultaneously # These dependencies can be uncommented when working on rbx-dom simultaneously
# rbx_binary = { path = "../rbx-dom/rbx_binary" } # rbx_binary = { path = "../rbx-dom/rbx_binary" }
@@ -51,60 +51,66 @@ memofs = { version = "0.2.0", path = "crates/memofs" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" } # rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" } # rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "0.7.0" rbx_binary = "0.7.7"
rbx_dom_weak = "2.4.0" rbx_dom_weak = "2.9.0"
rbx_reflection = "4.2.0" rbx_reflection = "4.7.0"
rbx_reflection_database = "0.2.6" rbx_reflection_database = "0.2.12"
rbx_xml = "0.13.0" rbx_xml = "0.13.5"
anyhow = "1.0.44" anyhow = "1.0.80"
backtrace = "0.3.61" backtrace = "0.3.69"
bincode = "1.3.3" bincode = "1.3.3"
crossbeam-channel = "0.5.1" crossbeam-channel = "0.5.12"
csv = "1.1.6" csv = "1.3.0"
env_logger = "0.9.0" env_logger = "0.9.3"
fs-err = "2.6.0" fs-err = "2.11.0"
futures = "0.3.17" futures = "0.3.30"
globset = "0.4.8" globset = "0.4.14"
humantime = "2.1.0" humantime = "2.1.0"
hyper = { version = "0.14.13", features = ["server", "tcp", "http1"] } hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] }
jod-thread = "0.1.2" jod-thread = "0.1.2"
log = "0.4.14" log = "0.4.21"
maplit = "1.0.2" maplit = "1.0.2"
notify = "4.0.17" num_cpus = "1.16.0"
opener = "0.5.0" opener = "0.5.2"
reqwest = { version = "0.11.10", features = ["blocking", "json", "native-tls-vendored"] } rayon = "1.9.0"
reqwest = { version = "0.11.24", default-features = false, features = [
"blocking",
"json",
"rustls-tls",
] }
ritz = "0.1.0" ritz = "0.1.0"
roblox_install = "1.0.0" roblox_install = "1.0.0"
serde = { version = "1.0.130", features = ["derive", "rc"] } serde = { version = "1.0.197", features = ["derive", "rc"] }
serde_json = "1.0.68" serde_json = "1.0.114"
termcolor = "1.1.2" toml = "0.5.11"
thiserror = "1.0.30" termcolor = "1.4.1"
tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] } thiserror = "1.0.57"
uuid = { version = "1.0.0", features = ["v4", "serde"] } tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
clap = { version = "3.1.18", features = ["derive"] } uuid = { version = "1.7.0", features = ["v4", "serde"] }
profiling = "1.0.6" clap = { version = "3.2.25", features = ["derive"] }
tracy-client = { version = "0.13.2", optional = true } profiling = "1.0.15"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.10.1" winreg = "0.10.1"
[build-dependencies] [build-dependencies]
memofs = { version = "0.2.0", path = "crates/memofs" } memofs = { version = "0.3.0", path = "crates/memofs" }
embed-resource = "1.6.4" embed-resource = "1.8.0"
anyhow = "1.0.44" anyhow = "1.0.80"
bincode = "1.3.3" bincode = "1.3.3"
fs-err = "2.6.0" fs-err = "2.11.0"
maplit = "1.0.2" maplit = "1.0.2"
semver = "1.0.22"
[dev-dependencies] [dev-dependencies]
rojo-insta-ext = { path = "crates/rojo-insta-ext" } rojo-insta-ext = { path = "crates/rojo-insta-ext" }
criterion = "0.3.5" criterion = "0.3.6"
insta = { version = "1.8.0", features = ["redactions", "yaml"] } insta = { version = "1.36.1", features = ["redactions", "yaml"] }
paste = "1.0.5" paste = "1.0.14"
pretty_assertions = "1.2.1" pretty_assertions = "1.4.0"
serde_yaml = "0.8.21" serde_yaml = "0.8.26"
tempfile = "3.2.0" tempfile = "3.10.1"
walkdir = "2.3.2" walkdir = "2.5.0"

View File

@@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<a href="https://rojo.space"><img src="assets/logo-512.png" alt="Rojo" height="217" /></a> <a href="https://rojo.space"><img src="assets/brand_images/logo-512.png" alt="Rojo" height="217" /></a>
</div> </div>
<div>&nbsp;</div> <div>&nbsp;</div>
@@ -8,7 +8,6 @@
<a href="https://github.com/rojo-rbx/rojo/actions"><img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" /></a> <a href="https://github.com/rojo-rbx/rojo/actions"><img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" /></a>
<a href="https://crates.io/crates/rojo"><img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /></a> <a href="https://crates.io/crates/rojo"><img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /></a>
<a href="https://rojo.space/docs"><img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" /></a> <a href="https://rojo.space/docs"><img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" /></a>
<a href="https://www.patreon.com/lpghatguy"><img src="https://img.shields.io/badge/sponsor-patreon-red" alt="Patreon" /></a>
</div> </div>
<hr /> <hr />
@@ -41,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome! Pull requests are welcome!
Rojo supports Rust 1.58.1 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has. Rojo supports Rust 1.70.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

View File

@@ -1,5 +1,5 @@
[tools] [tools]
wally = "UpliftGames/wally@0.3.1" rojo = "rojo-rbx/rojo@7.3.0"
rojo = "rojo-rbx/rojo@7.2.1" selene = "Kampfkarren/selene@0.26.1"
selene = "Kampfkarren/selene@0.20.0" stylua = "JohnnyMorganz/stylua@0.18.2"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" 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

@@ -2,7 +2,7 @@
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}. Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
## Getting Started ## Getting Started
To build this library or plugin, use: To build this library, use:
```bash ```bash
rojo build -o "{project_name}.rbxmx" rojo build -o "{project_name}.rbxmx"

View File

@@ -0,0 +1,17 @@
# {project_name}
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
## Getting Started
To build this plugin to your local plugins folder, use:
```bash
rojo build -p "{project_name}.rbxm"
```
You can include the `watch` flag to re-build it on save:
```bash
rojo build -p "{project_name}.rbxm" --watch
```
For more help, check out [the Rojo documentation](https://rojo.space/docs).

View File

@@ -0,0 +1,6 @@
{
"name": "{project_name}",
"tree": {
"$path": "src"
}
}

View File

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

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

@@ -31,11 +31,12 @@ fn bench_build_place(c: &mut Criterion, name: &str, path: &str) {
fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) { fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let input = input_path.as_ref().to_path_buf(); let input = input_path.as_ref().to_path_buf();
let output = dir.path().join("output.rbxlx"); let output = Some(dir.path().join("output.rbxlx"));
let options = BuildCommand { let options = BuildCommand {
project: input, project: input,
watch: false, watch: false,
plugin: None,
output, output,
}; };

View File

@@ -7,6 +7,7 @@ use fs_err as fs;
use fs_err::File; use fs_err::File;
use maplit::hashmap; use maplit::hashmap;
use memofs::VfsSnapshot; use memofs::VfsSnapshot;
use semver::Version;
fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> { fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
println!("cargo:rerun-if-changed={}", path.display()); println!("cargo:rerun-if-changed={}", path.display());
@@ -43,6 +44,15 @@ fn main() -> Result<(), anyhow::Error> {
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap(); let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
let plugin_root = PathBuf::from(root_dir).join("plugin"); let plugin_root = PathBuf::from(root_dir).join("plugin");
let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
let plugin_version =
Version::parse(fs::read_to_string(plugin_root.join("Version.txt"))?.trim())?;
assert_eq!(
our_version, plugin_version,
"plugin version does not match Cargo version"
);
let snapshot = VfsSnapshot::dir(hashmap! { let snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?, "default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?, "fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
@@ -51,10 +61,11 @@ fn main() -> Result<(), anyhow::Error> {
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?, "rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?, "src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
"Packages" => snapshot_from_fs_path(&plugin_root.join("Packages"))?, "Packages" => snapshot_from_fs_path(&plugin_root.join("Packages"))?,
"Version.txt" => snapshot_from_fs_path(&plugin_root.join("Version.txt"))?,
}); });
let out_path = Path::new(&out_dir).join("plugin.bincode"); let out_path = Path::new(&out_dir).join("plugin.bincode");
let out_file = File::create(&out_path)?; let out_file = File::create(out_path)?;
bincode::serialize_into(out_file, &snapshot)?; bincode::serialize_into(out_file, &snapshot)?;

View File

@@ -2,6 +2,13 @@
## Unreleased Changes ## Unreleased Changes
## 0.3.0 (2024-03-15)
* Changed `StdBackend` file watching component to use minimal recursive watches. [#830]
* Added `Vfs::read_to_string` and `Vfs::read_to_string_lf_normalized` [#854]
[#830]: https://github.com/rojo-rbx/rojo/pull/830
[#854]: https://github.com/rojo-rbx/rojo/pull/854
## 0.2.0 (2021-08-23) ## 0.2.0 (2021-08-23)
* Updated to `crossbeam-channel` 0.5.1. * Updated to `crossbeam-channel` 0.5.1.

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "memofs" name = "memofs"
description = "Virtual filesystem with configurable backends." description = "Virtual filesystem with configurable backends."
version = "0.2.0" version = "0.3.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018" edition = "2018"
readme = "README.md" readme = "README.md"
@@ -11,7 +11,7 @@ homepage = "https://github.com/rojo-rbx/rojo/tree/master/memofs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
crossbeam-channel = "0.5.1" crossbeam-channel = "0.5.12"
fs-err = "2.3.0" fs-err = "2.11.0"
notify = "4.0.15" notify = "4.0.17"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }

View File

@@ -50,6 +50,12 @@ impl InMemoryFs {
} }
} }
impl Default for InMemoryFs {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)] #[derive(Debug)]
struct InMemoryFsInner { struct InMemoryFsInner {
entries: HashMap<PathBuf, Entry>, entries: HashMap<PathBuf, Entry>,

View File

@@ -22,9 +22,9 @@ mod noop_backend;
mod snapshot; mod snapshot;
mod std_backend; mod std_backend;
use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
use std::{io, str};
pub use in_memory_fs::InMemoryFs; pub use in_memory_fs::InMemoryFs;
pub use noop_backend::NoopBackend; pub use noop_backend::NoopBackend;
@@ -155,6 +155,24 @@ impl VfsInner {
Ok(Arc::new(contents)) Ok(Arc::new(contents))
} }
fn read_to_string<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
let contents = self.backend.read(path)?;
if self.watch_enabled {
self.backend.watch(path)?;
}
let contents_str = str::from_utf8(&contents).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("File was not valid UTF-8: {}", path.display()),
)
})?;
Ok(Arc::new(contents_str.into()))
}
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> { fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
let path = path.as_ref(); let path = path.as_ref();
let contents = contents.as_ref(); let contents = contents.as_ref();
@@ -194,11 +212,8 @@ impl VfsInner {
} }
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> { fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
match event { if let VfsEvent::Remove(path) = event {
VfsEvent::Remove(path) => { let _ = self.backend.unwatch(path);
let _ = self.backend.unwatch(&path);
}
_ => {}
} }
Ok(()) Ok(())
@@ -261,6 +276,33 @@ impl Vfs {
self.inner.lock().unwrap().read(path) self.inner.lock().unwrap().read(path)
} }
/// Read a file from the VFS (or from the underlying backend if it isn't
/// resident) into a string.
///
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string].
///
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
#[inline]
pub fn read_to_string<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
self.inner.lock().unwrap().read_to_string(path)
}
/// Read a file from the VFS (or the underlying backend if it isn't
/// resident) into a string, and normalize its line endings to LF.
///
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string], but also performs
/// line ending normalization.
///
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
#[inline]
pub fn read_to_string_lf_normalized<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
let contents = self.inner.lock().unwrap().read_to_string(path)?;
Ok(contents.replace("\r\n", "\n").into())
}
/// Write a file to the VFS and the underlying backend. /// Write a file to the VFS and the underlying backend.
/// ///
/// Roughly equivalent to [`std::fs::write`][std::fs::write]. /// Roughly equivalent to [`std::fs::write`][std::fs::write].
@@ -431,3 +473,23 @@ impl VfsLock<'_> {
self.inner.commit_event(event) self.inner.commit_event(event)
} }
} }
#[cfg(test)]
mod test {
use crate::{InMemoryFs, Vfs, VfsSnapshot};
/// https://github.com/rojo-rbx/rojo/issues/899
#[test]
fn read_to_string_lf_normalized_keeps_trailing_newline() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("test", VfsSnapshot::file("bar\r\nfoo\r\n\r\n"))
.unwrap();
let vfs = Vfs::new(imfs);
assert_eq!(
vfs.read_to_string_lf_normalized("test").unwrap().as_str(),
"bar\nfoo\n\n"
);
}
}

View File

@@ -74,3 +74,9 @@ impl VfsBackend for NoopBackend {
)) ))
} }
} }
impl Default for NoopBackend {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,8 +1,8 @@
use std::io; use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::mpsc; use std::sync::mpsc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use std::{collections::HashSet, io};
use crossbeam_channel::Receiver; use crossbeam_channel::Receiver;
use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher}; use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
@@ -13,6 +13,7 @@ use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
pub struct StdBackend { pub struct StdBackend {
watcher: RecommendedWatcher, watcher: RecommendedWatcher,
watcher_receiver: Receiver<VfsEvent>, watcher_receiver: Receiver<VfsEvent>,
watches: HashSet<PathBuf>,
} }
impl StdBackend { impl StdBackend {
@@ -48,6 +49,7 @@ impl StdBackend {
Self { Self {
watcher, watcher,
watcher_receiver: rx, watcher_receiver: rx,
watches: HashSet::new(),
} }
} }
} }
@@ -97,14 +99,30 @@ impl VfsBackend for StdBackend {
} }
fn watch(&mut self, path: &Path) -> io::Result<()> { fn watch(&mut self, path: &Path) -> io::Result<()> {
self.watcher if self.watches.contains(path)
.watch(path, RecursiveMode::NonRecursive) || path
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner)) .ancestors()
.any(|ancestor| self.watches.contains(ancestor))
{
Ok(())
} else {
self.watches.insert(path.to_path_buf());
self.watcher
.watch(path, RecursiveMode::Recursive)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
}
} }
fn unwatch(&mut self, path: &Path) -> io::Result<()> { fn unwatch(&mut self, path: &Path) -> io::Result<()> {
self.watches.remove(path);
self.watcher self.watcher
.unwatch(path) .unwatch(path)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner)) .map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
} }
} }
impl Default for StdBackend {
fn default() -> Self {
Self::new()
}
}

View File

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

1
plugin/Packages/Roact Submodule

Submodule plugin/Packages/Roact added at 956891b70f

1
plugin/Packages/t Submodule

Submodule plugin/Packages/t added at 1f9754254b

1
plugin/Version.txt Normal file
View File

@@ -0,0 +1 @@
7.4.0

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,8 @@ local function serializeFloat(value)
return value return value
end end
local ALL_AXES = {"X", "Y", "Z"} local ALL_AXES = { "X", "Y", "Z" }
local ALL_FACES = {"Right", "Top", "Back", "Left", "Bottom", "Front"} local ALL_FACES = { "Right", "Top", "Back", "Left", "Bottom", "Front" }
local EncodedValue = {} local EncodedValue = {}
@@ -37,7 +37,10 @@ types = {
if ok then if ok then
output[key] = result output[key] = result
else else
local warning = ("Could not decode attribute value of type %q: %s"):format(typeof(value), tostring(result)) local warning = ("Could not decode attribute value of type %q: %s"):format(
typeof(value),
tostring(result)
)
warn(warning) warn(warning)
end end
end end
@@ -53,7 +56,10 @@ types = {
if ok then if ok then
output[key] = result output[key] = result
else else
local warning = ("Could not encode attribute value of type %q: %s"):format(typeof(value), tostring(result)) local warning = ("Could not encode attribute value of type %q: %s"):format(
typeof(value),
tostring(result)
)
warn(warning) warn(warning)
end end
end end
@@ -111,6 +117,7 @@ types = {
local pos = pod.position local pos = pod.position
local orient = pod.orientation local orient = pod.orientation
--stylua: ignore
return CFrame.new( return CFrame.new(
pos[1], pos[2], pos[3], pos[1], pos[2], pos[3],
orient[1][1], orient[1][2], orient[1][3], orient[1][1], orient[1][2], orient[1][3],
@@ -120,17 +127,14 @@ types = {
end, end,
toPod = function(roblox) toPod = function(roblox)
local x, y, z, local x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 = roblox:GetComponents()
r00, r01, r02,
r10, r11, r12,
r20, r21, r22 = roblox:GetComponents()
return { return {
position = {x, y, z}, position = { x, y, z },
orientation = { orientation = {
{r00, r01, r02}, { r00, r01, r02 },
{r10, r11, r12}, { r10, r11, r12 },
{r20, r21, r22}, { r20, r21, r22 },
}, },
} }
end, end,
@@ -140,7 +144,7 @@ types = {
fromPod = unpackDecoder(Color3.new), fromPod = unpackDecoder(Color3.new),
toPod = function(roblox) toPod = function(roblox)
return {roblox.r, roblox.g, roblox.b} return { roblox.r, roblox.g, roblox.b }
end, end,
}, },
@@ -161,10 +165,7 @@ types = {
local keypoints = {} local keypoints = {}
for index, keypoint in ipairs(pod.keypoints) do for index, keypoint in ipairs(pod.keypoints) do
keypoints[index] = ColorSequenceKeypoint.new( keypoints[index] = ColorSequenceKeypoint.new(keypoint.time, types.Color3.fromPod(keypoint.color))
keypoint.time,
types.Color3.fromPod(keypoint.color)
)
end end
return ColorSequence.new(keypoints) return ColorSequence.new(keypoints)
@@ -265,11 +266,32 @@ types = {
toPod = identity, toPod = identity,
}, },
MaterialColors = {
fromPod = function(pod: { [string]: { number } })
local real = {}
for name, color in pod do
real[Enum.Material[name]] = Color3.fromRGB(color[1], color[2], color[3])
end
return real
end,
toPod = function(roblox: { [Enum.Material]: Color3 })
local pod = {}
for material, color in roblox do
pod[material.Name] = {
math.round(math.clamp(color.R, 0, 1) * 255),
math.round(math.clamp(color.G, 0, 1) * 255),
math.round(math.clamp(color.B, 0, 1) * 255),
}
end
return pod
end,
},
NumberRange = { NumberRange = {
fromPod = unpackDecoder(NumberRange.new), fromPod = unpackDecoder(NumberRange.new),
toPod = function(roblox) toPod = function(roblox)
return {roblox.Min, roblox.Max} return { roblox.Min, roblox.Max }
end, end,
}, },
@@ -278,11 +300,7 @@ types = {
local keypoints = {} local keypoints = {}
for index, keypoint in ipairs(pod.keypoints) do for index, keypoint in ipairs(pod.keypoints) do
keypoints[index] = NumberSequenceKeypoint.new( keypoints[index] = NumberSequenceKeypoint.new(keypoint.time, keypoint.value, keypoint.envelope)
keypoint.time,
keypoint.value,
keypoint.envelope
)
end end
return NumberSequence.new(keypoints) return NumberSequence.new(keypoints)
@@ -337,10 +355,7 @@ types = {
Ray = { Ray = {
fromPod = function(pod) fromPod = function(pod)
return Ray.new( return Ray.new(types.Vector3.fromPod(pod.origin), types.Vector3.fromPod(pod.direction))
types.Vector3.fromPod(pod.origin),
types.Vector3.fromPod(pod.direction)
)
end, end,
toPod = function(roblox) toPod = function(roblox)
@@ -353,10 +368,7 @@ types = {
Rect = { Rect = {
fromPod = function(pod) fromPod = function(pod)
return Rect.new( return Rect.new(types.Vector2.fromPod(pod[1]), types.Vector2.fromPod(pod[2]))
types.Vector2.fromPod(pod[1]),
types.Vector2.fromPod(pod[2])
)
end, end,
toPod = function(roblox) toPod = function(roblox)
@@ -368,31 +380,28 @@ types = {
}, },
Ref = { Ref = {
fromPod = function(_pod) fromPod = function(_)
error("Ref cannot be decoded on its own") error("Ref cannot be decoded on its own")
end, end,
toPod = function(_roblox) toPod = function(_)
error("Ref can not be encoded on its own") error("Ref can not be encoded on its own")
end, end,
}, },
Region3 = { Region3 = {
fromPod = function(pod) fromPod = function(_)
error("Region3 is not implemented") error("Region3 is not implemented")
end, end,
toPod = function(roblox) toPod = function(_)
error("Region3 is not implemented") error("Region3 is not implemented")
end, end,
}, },
Region3int16 = { Region3int16 = {
fromPod = function(pod) fromPod = function(pod)
return Region3int16.new( return Region3int16.new(types.Vector3int16.fromPod(pod[1]), types.Vector3int16.fromPod(pod[2]))
types.Vector3int16.fromPod(pod[1]),
types.Vector3int16.fromPod(pod[2])
)
end, end,
toPod = function(roblox) toPod = function(roblox)
@@ -404,11 +413,11 @@ types = {
}, },
SharedString = { SharedString = {
fromPod = function(pod) fromPod = function(_pod)
error("SharedString is not supported") error("SharedString is not supported")
end, end,
toPod = function(roblox) toPod = function(_roblox)
error("SharedString is not supported") error("SharedString is not supported")
end, end,
}, },
@@ -422,16 +431,13 @@ types = {
fromPod = unpackDecoder(UDim.new), fromPod = unpackDecoder(UDim.new),
toPod = function(roblox) toPod = function(roblox)
return {roblox.Scale, roblox.Offset} return { roblox.Scale, roblox.Offset }
end, end,
}, },
UDim2 = { UDim2 = {
fromPod = function(pod) fromPod = function(pod)
return UDim2.new( return UDim2.new(types.UDim.fromPod(pod[1]), types.UDim.fromPod(pod[2]))
types.UDim.fromPod(pod[1]),
types.UDim.fromPod(pod[2])
)
end, end,
toPod = function(roblox) toPod = function(roblox)
@@ -462,7 +468,7 @@ types = {
fromPod = unpackDecoder(Vector2int16.new), fromPod = unpackDecoder(Vector2int16.new),
toPod = function(roblox) toPod = function(roblox)
return {roblox.X, roblox.Y} return { roblox.X, roblox.Y }
end, end,
}, },
@@ -482,14 +488,37 @@ types = {
fromPod = unpackDecoder(Vector3int16.new), fromPod = unpackDecoder(Vector3int16.new),
toPod = function(roblox) toPod = function(roblox)
return {roblox.X, roblox.Y, roblox.Z} return { roblox.X, roblox.Y, roblox.Z }
end, end,
}, },
} }
types.OptionalCFrame = {
fromPod = function(pod)
if pod == nil then
return nil
else
return types.CFrame.fromPod(pod)
end
end,
toPod = function(roblox)
if roblox == nil then
return nil
else
return types.CFrame.toPod(roblox)
end
end,
}
function EncodedValue.decode(encodedValue) function EncodedValue.decode(encodedValue)
local ty, value = next(encodedValue) local ty, value = next(encodedValue)
if ty == nil then
-- If the encoded pair is empty, assume it is an unoccupied optional value
return true, nil
end
local typeImpl = types[ty] local typeImpl = types[ty]
if typeImpl == nil then if typeImpl == nil then
return false, "Couldn't decode value " .. tostring(ty) return false, "Couldn't decode value " .. tostring(ty)

View File

@@ -1,72 +0,0 @@
return function()
local HttpService = game:GetService("HttpService")
local EncodedValue = require(script.Parent.EncodedValue)
local allValues = require(script.Parent.allValues)
local function deepEq(a, b)
if typeof(a) ~= typeof(b) then
return false
end
local ty = typeof(a)
if ty == "table" then
local visited = {}
for key, valueA in pairs(a) do
visited[key] = true
if not deepEq(valueA, b[key]) then
return false
end
end
for key, valueB in pairs(b) do
if visited[key] then
continue
end
if not deepEq(valueB, a[key]) then
return false
end
end
return true
else
return a == b
end
end
local extraAssertions = {
CFrame = function(value)
expect(value).to.equal(CFrame.new(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12))
end,
}
for testName, testEntry in pairs(allValues) do
it("round trip " .. testName, function()
local ok, decoded = EncodedValue.decode(testEntry.value)
assert(ok, decoded)
if extraAssertions[testName] ~= nil then
extraAssertions[testName](decoded)
end
local ok, encoded = EncodedValue.encode(decoded, testEntry.ty)
assert(ok, encoded)
if not deepEq(encoded, testEntry.value) then
local expected = HttpService:JSONEncode(testEntry.value)
local actual = HttpService:JSONEncode(encoded)
local message = string.format(
"Round-trip results did not match.\nExpected:\n%s\nActual:\n%s",
expected, actual
)
error(message)
end
end)
end
end

View File

@@ -230,6 +230,118 @@
}, },
"ty": "Int64" "ty": "Int64"
}, },
"MaterialColors": {
"value": {
"MaterialColors": {
"Grass": [
106,
127,
63
],
"Slate": [
63,
127,
107
],
"Concrete": [
127,
102,
63
],
"Brick": [
138,
86,
62
],
"Sand": [
143,
126,
95
],
"WoodPlanks": [
139,
109,
79
],
"Rock": [
102,
108,
111
],
"Glacier": [
101,
176,
234
],
"Snow": [
195,
199,
218
],
"Sandstone": [
137,
90,
71
],
"Mud": [
58,
46,
36
],
"Basalt": [
30,
30,
37
],
"Ground": [
102,
92,
59
],
"CrackedLava": [
232,
156,
74
],
"Asphalt": [
115,
123,
107
],
"Cobblestone": [
132,
123,
90
],
"Ice": [
129,
194,
224
],
"LeafyGrass": [
115,
132,
74
],
"Salt": [
198,
189,
181
],
"Limestone": [
206,
173,
148
],
"Pavement": [
148,
148,
140
]
}
},
"ty": "MaterialColors"
},
"NumberRange": { "NumberRange": {
"value": { "value": {
"NumberRange": [ "NumberRange": [
@@ -258,6 +370,41 @@
}, },
"ty": "NumberSequence" "ty": "NumberSequence"
}, },
"OptionalCFrame-None": {
"value": {
"OptionalCFrame": null
},
"ty": "OptionalCFrame"
},
"OptionalCFrame-Some": {
"value": {
"OptionalCFrame": {
"position": [
0.0,
0.0,
0.0
],
"orientation": [
[
1.0,
0.0,
0.0
],
[
0.0,
1.0,
0.0
],
[
0.0,
0.0,
1.0
]
]
}
},
"ty": "OptionalCFrame"
},
"PhysicalProperties-Custom": { "PhysicalProperties-Custom": {
"value": { "value": {
"PhysicalProperties": { "PhysicalProperties": {

View File

@@ -1,29 +0,0 @@
return function()
local base64 = require(script.Parent.base64)
it("should encode and decode", function()
local function try(str, expected)
local encoded = base64.encode(str)
expect(encoded).to.equal(expected)
expect(base64.decode(encoded)).to.equal(str)
end
try("Man", "TWFu")
try("Ma", "TWE=")
try("M", "TQ==")
try("ManM", "TWFuTQ==")
try(
[[Man is distinguished, not only by his reason, but by this ]]..
[[singular passion from other animals, which is a lust of the ]]..
[[mind, that by a perseverance of delight in the continued and ]]..
[[indefatigable generation of knowledge, exceeds the short ]]..
[[vehemence of any carnal pleasure.]],
[[TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sI]]..
[[GJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYW]]..
[[xzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJ]]..
[[zZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRl]]..
[[ZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZ]]..
[[SBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=]]
)
end)
end

View File

@@ -1,4 +1,45 @@
local CollectionService = game:GetService("CollectionService") local CollectionService = game:GetService("CollectionService")
local ScriptEditorService = game:GetService("ScriptEditorService")
--- A list of `Enum.Material` values that are used for Terrain.MaterialColors
local TERRAIN_MATERIAL_COLORS = {
Enum.Material.Grass,
Enum.Material.Slate,
Enum.Material.Concrete,
Enum.Material.Brick,
Enum.Material.Sand,
Enum.Material.WoodPlanks,
Enum.Material.Rock,
Enum.Material.Glacier,
Enum.Material.Snow,
Enum.Material.Sandstone,
Enum.Material.Mud,
Enum.Material.Basalt,
Enum.Material.Ground,
Enum.Material.CrackedLava,
Enum.Material.Asphalt,
Enum.Material.Cobblestone,
Enum.Material.Ice,
Enum.Material.LeafyGrass,
Enum.Material.Salt,
Enum.Material.Limestone,
Enum.Material.Pavement,
}
local function isAttributeNameValid(attributeName)
-- For SetAttribute to succeed, the attribute name must be less than or
-- equal to 100 characters...
return #attributeName <= 100
-- ...and must only contain alphanumeric characters, periods, hyphens,
-- underscores, or forward slashes.
and attributeName:match("[^%w%.%-_/]") == nil
end
local function isAttributeNameReserved(attributeName)
-- For SetAttribute to succeed, attribute names must not use the RBX
-- prefix, which is reserved by Roblox.
return attributeName:sub(1, 3) == "RBX"
end
-- Defines how to read and write properties that aren't directly scriptable. -- Defines how to read and write properties that aren't directly scriptable.
-- --
@@ -11,18 +52,40 @@ return {
end, end,
write = function(instance, _, value) write = function(instance, _, value)
local existing = instance:GetAttributes() local existing = instance:GetAttributes()
local didAllWritesSucceed = true
for key, attr in pairs(value) do for attributeName, attributeValue in pairs(value) do
instance:SetAttribute(key, attr) 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 end
for key in pairs(existing) do for existingAttributeName in pairs(existing) do
if value[key] == nil then if isAttributeNameReserved(existingAttributeName) then
instance:SetAttribute(key, nil) continue
end
if not isAttributeNameValid(existingAttributeName) then
didAllWritesSucceed = false
continue
end
if value[existingAttributeName] == nil then
instance:SetAttribute(existingAttributeName, nil)
end end
end end
return true return didAllWritesSucceed
end, end,
}, },
Tags = { Tags = {
@@ -52,10 +115,10 @@ return {
}, },
LocalizationTable = { LocalizationTable = {
Contents = { Contents = {
read = function(instance, key) read = function(instance, _)
return true, instance:GetContents() return true, instance:GetContents()
end, end,
write = function(instance, key, value) write = function(instance, _, value)
instance:SetContents(value) instance:SetContents(value)
return true return true
end, end,
@@ -70,5 +133,68 @@ return {
return true, instance:ScaleTo(value) return true, instance:ScaleTo(value)
end, end,
}, },
WorldPivotData = {
read = function(instance)
return true, instance.WorldPivot
end,
write = function(instance, _, value)
if value == nil then
return true, nil
else
instance.WorldPivot = value
return true
end
end,
},
},
Terrain = {
MaterialColors = {
read = function(instance: Terrain)
-- There's no way to get a list of every color, so we have to
-- make one.
local colors = {}
for _, material in TERRAIN_MATERIAL_COLORS do
colors[material] = instance:GetMaterialColor(material)
end
return true, colors
end,
write = function(instance: Terrain, _, value: { [Enum.Material]: Color3 })
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,
},
}, },
} }

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,8 @@ local function findCanonicalPropertyDescriptor(className, propertyName)
return PropertyDescriptor.fromRaw( return PropertyDescriptor.fromRaw(
currentClass.Properties[aliasData.AliasFor], currentClass.Properties[aliasData.AliasFor],
currentClassName, currentClassName,
aliasData.AliasFor) aliasData.AliasFor
)
end end
return nil return nil

View File

@@ -1,7 +0,0 @@
return function()
local RbxDom = require(script.Parent)
it("should load", function()
expect(RbxDom).to.be.ok()
end)
end

View File

@@ -11,13 +11,6 @@ local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse) local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
--[[
Returns a promise that will never resolve nor reject.
]]
local function hangingPromise()
return Promise.new(function() end)
end
local function rejectFailedRequests(response) local function rejectFailedRequests(response)
if response.code >= 400 then if response.code >= 400 then
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body) local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body)
@@ -31,15 +24,17 @@ end
local function rejectWrongProtocolVersion(infoResponseBody) local function rejectWrongProtocolVersion(infoResponseBody)
if infoResponseBody.protocolVersion ~= Config.protocolVersion then if infoResponseBody.protocolVersion ~= Config.protocolVersion then
local message = ( local message = (
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." .. "Found a Rojo dev server, but it's using a different protocol version, and is incompatible."
"\nMake sure you have matching versions of both the Rojo plugin and server!" .. .. "\nMake sure you have matching versions of both the Rojo plugin and server!"
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." .. .. "\n\nYour client is version %s, with protocol version %s. It expects server version %s."
"\nYour server is version %s, with protocol version %s." .. .. "\nYour server is version %s, with protocol version %s."
"\n\nGo to https://github.com/rojo-rbx/rojo for more details." .. "\n\nGo to https://github.com/rojo-rbx/rojo for more details."
):format( ):format(
Version.display(Config.version), Config.protocolVersion, Version.display(Config.version),
Config.protocolVersion,
Config.expectedServerVersionString, Config.expectedServerVersionString,
infoResponseBody.serverVersion, infoResponseBody.protocolVersion infoResponseBody.serverVersion,
infoResponseBody.protocolVersion
) )
return Promise.reject(message) return Promise.reject(message)
@@ -66,14 +61,11 @@ local function rejectWrongPlaceId(infoResponseBody)
end end
local message = ( local message = (
"Found a Rojo server, but its project is set to only be used with a specific list of places." .. "Found a Rojo server, but its project is set to only be used with a specific list of places."
"\nYour place ID is %s, but needs to be one of these:" .. .. "\nYour place ID is %s, but needs to be one of these:"
"\n%s" .. .. "\n%s"
"\n\nTo change this list, edit 'servePlaceIds' in your .project.json file." .. "\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
):format( ):format(tostring(game.PlaceId), table.concat(idList, "\n"))
tostring(game.PlaceId),
table.concat(idList, "\n")
)
return Promise.reject(message) return Promise.reject(message)
end end
@@ -93,6 +85,7 @@ function ApiContext.new(baseUrl)
__sessionId = nil, __sessionId = nil,
__messageCursor = -1, __messageCursor = -1,
__connected = true, __connected = true,
__activeRequests = {},
} }
return setmetatable(self, ApiContext) return setmetatable(self, ApiContext)
@@ -113,6 +106,11 @@ end
function ApiContext:disconnect() function ApiContext:disconnect()
self.__connected = false self.__connected = false
for request in self.__activeRequests do
Log.trace("Cancelling request {}", request)
request:cancel()
end
self.__activeRequests = {}
end end
function ApiContext:setMessageCursor(index) function ApiContext:setMessageCursor(index)
@@ -142,18 +140,15 @@ end
function ApiContext:read(ids) function ApiContext:read(ids)
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ","))
return Http.get(url) return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
:andThen(rejectFailedRequests) if body.sessionId ~= self.__sessionId then
:andThen(Http.Response.json) return Promise.reject("Server changed ID")
:andThen(function(body) end
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiRead(body)) assert(validateApiRead(body))
return body return body
end) end)
end end
function ApiContext:write(patch) function ApiContext:write(patch)
@@ -190,63 +185,58 @@ function ApiContext:write(patch)
body = Http.jsonEncode(body) body = Http.jsonEncode(body)
return Http.post(url, body) return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody)
:andThen(rejectFailedRequests) Log.info("Write response: {:?}", responseBody)
:andThen(Http.Response.json)
:andThen(function(body)
Log.info("Write response: {:?}", body)
return body return responseBody
end) end)
end end
function ApiContext:retrieveMessages() function ApiContext:retrieveMessages()
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor) local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
local function sendRequest() local function sendRequest()
return Http.get(url) local request = Http.get(url):catch(function(err)
:catch(function(err) if err.type == Http.Error.Kind.Timeout and self.__connected then
if err.type == Http.Error.Kind.Timeout then return sendRequest()
if self.__connected then
return sendRequest()
else
return hangingPromise()
end
end
return Promise.reject(err)
end)
end
return sendRequest()
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end end
assert(validateApiSubscribe(body)) return Promise.reject(err)
self:setMessageCursor(body.messageCursor)
return body.messages
end) end)
Log.trace("Tracking request {}", request)
self.__activeRequests[request] = true
return request:finally(function(...)
Log.trace("Cleaning up request {}", request)
self.__activeRequests[request] = nil
return ...
end)
end
return sendRequest():andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiSubscribe(body))
self:setMessageCursor(body.messageCursor)
return body.messages
end)
end end
function ApiContext:open(id) function ApiContext:open(id)
local url = ("%s/api/open/%s"):format(self.__baseUrl, id) local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
return Http.post(url, "") return Http.post(url, ""):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
:andThen(rejectFailedRequests) if body.sessionId ~= self.__sessionId then
:andThen(Http.Response.json) return Promise.reject("Server changed ID")
:andThen(function(body) end
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
return nil return nil
end) end)
end end
return ApiContext return ApiContext

View File

@@ -23,12 +23,10 @@ end
function Checkbox:didUpdate(lastProps) function Checkbox:didUpdate(lastProps)
if lastProps.active ~= self.props.active then if lastProps.active ~= self.props.active then
self.motor:setGoal( self.motor:setGoal(Flipper.Spring.new(self.props.active and 1 or 0, {
Flipper.Spring.new(self.props.active and 1 or 0, { frequency = 6,
frequency = 6, dampingRatio = 1.1,
dampingRatio = 1.1, }))
})
)
end end
end end
@@ -51,10 +49,16 @@ function Checkbox:render()
ZIndex = self.props.zIndex, ZIndex = self.props.zIndex,
BackgroundTransparency = 1, BackgroundTransparency = 1,
[Roact.Event.Activated] = self.props.onClick, [Roact.Event.Activated] = function()
if self.props.locked then
return
end
self.props.onClick()
end,
}, { }, {
StateTip = e(Tooltip.Trigger, { StateTip = e(Tooltip.Trigger, {
text = if self.props.active then "Enabled" else "Disabled", text = (if self.props.locked then "[LOCKED] " else "")
.. (if self.props.active then "Enabled" else "Disabled"),
}), }),
Active = e(SlicedImage, { Active = e(SlicedImage, {
@@ -65,7 +69,7 @@ function Checkbox:render()
zIndex = 2, zIndex = 2,
}, { }, {
Icon = e("ImageLabel", { Icon = e("ImageLabel", {
Image = Assets.Images.Checkbox.Active, Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Active,
ImageColor3 = theme.Active.IconColor, ImageColor3 = theme.Active.IconColor,
ImageTransparency = activeTransparency, ImageTransparency = activeTransparency,
@@ -84,7 +88,9 @@ function Checkbox:render()
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
}, { }, {
Icon = e("ImageLabel", { Icon = e("ImageLabel", {
Image = Assets.Images.Checkbox.Inactive, Image = if self.props.locked
then Assets.Images.Checkbox.Locked
else Assets.Images.Checkbox.Inactive,
ImageColor3 = theme.Inactive.IconColor, ImageColor3 = theme.Inactive.IconColor,
ImageTransparency = self.props.transparency, ImageTransparency = self.props.transparency,

View File

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

View File

@@ -0,0 +1,61 @@
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

@@ -29,13 +29,17 @@ function Dropdown:init()
}) })
end end
function Dropdown:didUpdate() function Dropdown:didUpdate(prevProps)
self.openMotor:setGoal( if self.props.locked and not prevProps.locked then
Flipper.Spring.new(self.state.open and 1 or 0, { self:setState({
frequency = 6, open = false,
dampingRatio = 1.1,
}) })
) end
self.openMotor:setGoal(Flipper.Spring.new(self.state.open and 1 or 0, {
frequency = 6,
dampingRatio = 1.1,
}))
end end
function Dropdown:render() function Dropdown:render()
@@ -46,10 +50,7 @@ function Dropdown:render()
local width = -1 local width = -1
for i, option in self.props.options do for i, option in self.props.options do
local text = tostring(option or "") local text = tostring(option or "")
local textSize = TextService:GetTextSize( local textSize = TextService:GetTextSize(text, 15, Enum.Font.GothamMedium, Vector2.new(math.huge, 20))
text, 15, Enum.Font.GothamMedium,
Vector2.new(math.huge, 20)
)
if textSize.X > width then if textSize.X > width then
width = textSize.X width = textSize.X
end end
@@ -68,6 +69,9 @@ function Dropdown:render()
Font = Enum.Font.GothamMedium, Font = Enum.Font.GothamMedium,
[Roact.Event.Activated] = function() [Roact.Event.Activated] = function()
if self.props.locked then
return
end
self:setState({ self:setState({
open = false, open = false,
}) })
@@ -81,7 +85,7 @@ function Dropdown:render()
end end
return e("ImageButton", { return e("ImageButton", {
Size = UDim2.new(0, width+50, 0, 28), Size = UDim2.new(0, width + 50, 0, 28),
Position = self.props.position, Position = self.props.position,
AnchorPoint = self.props.anchorPoint, AnchorPoint = self.props.anchorPoint,
LayoutOrder = self.props.layoutOrder, LayoutOrder = self.props.layoutOrder,
@@ -89,6 +93,9 @@ function Dropdown:render()
BackgroundTransparency = 1, BackgroundTransparency = 1,
[Roact.Event.Activated] = function() [Roact.Event.Activated] = function()
if self.props.locked then
return
end
self:setState({ self:setState({
open = not self.state.open, open = not self.state.open,
}) })
@@ -101,10 +108,8 @@ function Dropdown:render()
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
}, { }, {
DropArrow = e("ImageLabel", { DropArrow = e("ImageLabel", {
Image = Assets.Images.Dropdown.Arrow, Image = if self.props.locked then Assets.Images.Dropdown.Locked else Assets.Images.Dropdown.Arrow,
ImageColor3 = self.openBinding:map(function(a) ImageColor3 = theme.IconColor,
return theme.Closed.IconColor:Lerp(theme.Open.IconColor, a)
end),
ImageTransparency = self.props.transparency, ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 18, 0, 18), Size = UDim2.new(0, 18, 0, 18),
@@ -128,40 +133,42 @@ function Dropdown:render()
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
}), }),
}), }),
Options = if self.state.open then e(SlicedImage, { Options = if self.state.open
slice = Assets.Slices.RoundedBackground, then e(SlicedImage, {
color = theme.BackgroundColor, slice = Assets.Slices.RoundedBackground,
position = UDim2.new(1, 0, 1, 3), color = theme.BackgroundColor,
size = self.openBinding:map(function(a) position = UDim2.new(1, 0, 1, 3),
return UDim2.new(1, 0, a*math.min(3, #self.props.options), 0) size = self.openBinding:map(function(a)
end), return UDim2.new(1, 0, a * math.min(3, #self.props.options), 0)
anchorPoint = Vector2.new(1, 0), end),
}, { anchorPoint = Vector2.new(1, 0),
Border = e(SlicedImage, {
slice = Assets.Slices.RoundedBorder,
color = theme.BorderColor,
transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0),
}),
ScrollingFrame = e(ScrollingFrame, {
size = UDim2.new(1, -4, 1, -4),
position = UDim2.new(0, 2, 0, 2),
transparency = self.props.transparency,
contentSize = self.contentSize,
}, { }, {
Layout = e("UIListLayout", { Border = e(SlicedImage, {
VerticalAlignment = Enum.VerticalAlignment.Top, slice = Assets.Slices.RoundedBorder,
FillDirection = Enum.FillDirection.Vertical, color = theme.BorderColor,
SortOrder = Enum.SortOrder.LayoutOrder, transparency = self.props.transparency,
Padding = UDim.new(0, 0), size = UDim2.new(1, 0, 1, 0),
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
}), }),
Roact.createFragment(optionButtons), ScrollingFrame = e(ScrollingFrame, {
}), size = UDim2.new(1, -4, 1, -4),
}) else nil, position = UDim2.new(0, 2, 0, 2),
transparency = self.props.transparency,
contentSize = self.contentSize,
}, {
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Top,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 0),
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
}),
Options = Roact.createFragment(optionButtons),
}),
})
else nil,
}) })
end) end)
end end

View File

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

View File

@@ -38,15 +38,11 @@ function IconButton:render()
[Roact.Event.Activated] = self.props.onClick, [Roact.Event.Activated] = self.props.onClick,
[Roact.Event.MouseEnter] = function() [Roact.Event.MouseEnter] = function()
self.motor:setGoal( self.motor:setGoal(Flipper.Spring.new(1, HOVER_SPRING_PROPS))
Flipper.Spring.new(1, HOVER_SPRING_PROPS)
)
end, end,
[Roact.Event.MouseLeave] = function() [Roact.Event.MouseLeave] = function()
self.motor:setGoal( self.motor:setGoal(Flipper.Spring.new(0, HOVER_SPRING_PROPS))
Flipper.Spring.new(0, HOVER_SPRING_PROPS)
)
end, end,
}, { }, {
Icon = e("ImageLabel", { Icon = e("ImageLabel", {

View File

@@ -4,12 +4,133 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local DisplayValue = require(script.Parent.DisplayValue) local DisplayValue = require(script.Parent.DisplayValue)
local EMPTY_TABLE = {}
local e = Roact.createElement local e = Roact.createElement
local function ViewDiffButton(props)
return Theme.with(function(theme)
return e("TextButton", {
Text = "",
Size = UDim2.new(0.7, 0, 1, -4),
LayoutOrder = 2,
BackgroundTransparency = 1,
[Roact.Event.Activated] = props.onClick,
}, {
e(BorderedContainer, {
size = UDim2.new(1, 0, 1, 0),
transparency = props.transparency:map(function(t)
return 0.5 + (0.5 * t)
end),
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 5),
}),
Label = e("TextLabel", {
Text = "View Diff",
BackgroundTransparency = 1,
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 = props.transparency,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
}),
})
end)
end
local function RowContent(props)
local values = props.values
local metadata = props.metadata
if props.showStringDiff and values[1] == "Source" then
-- Special case for .Source updates
return e(ViewDiffButton, {
transparency = props.transparency,
onClick = function()
if not props.showStringDiff then
return
end
props.showStringDiff(tostring(values[2]), tostring(values[3]))
end,
})
end
if props.showTableDiff and (type(values[2]) == "table" or type(values[3]) == "table") then
-- Special case for table properties (like Attributes/Tags)
return e(ViewDiffButton, {
transparency = props.transparency,
onClick = function()
if not props.showTableDiff then
return
end
props.showTableDiff(values[2], values[3])
end,
})
end
return Theme.with(function(theme)
return Roact.createFragment({
ColumnB = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
},
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
textColor = if metadata.isWarning
then theme.Diff.Warning
else theme.Settings.Setting.DescriptionColor,
})
),
ColumnC = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
},
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
textColor = if metadata.isWarning
then theme.Diff.Warning
else theme.Settings.Setting.DescriptionColor,
})
),
})
end)
end
local ChangeList = Roact.Component:extend("ChangeList") local ChangeList = Roact.Component:extend("ChangeList")
function ChangeList:init() function ChangeList:init()
@@ -26,16 +147,15 @@ function ChangeList:render()
return 0.93 + (0.07 * t) return 0.93 + (0.07 * t)
end) end)
local columnVisibility = props.columnVisibility
local rows = {} local rows = {}
local pad = { local pad = {
PaddingLeft = UDim.new(0, 5), PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5), PaddingRight = UDim.new(0, 5),
} }
local headerRow = changes[1]
local headers = e("Frame", { local headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 30), Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = rowTransparency, BackgroundTransparency = rowTransparency,
BackgroundColor3 = theme.Diff.Row, BackgroundColor3 = theme.Diff.Row,
LayoutOrder = 0, LayoutOrder = 0,
@@ -47,39 +167,36 @@ function ChangeList:render()
HorizontalAlignment = Enum.HorizontalAlignment.Left, HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center,
}), }),
A = e("TextLabel", { ColumnA = e("TextLabel", {
Visible = columnVisibility[1], Text = tostring(headerRow[1]),
Text = tostring(changes[1][1]),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamBold, Font = Enum.Font.GothamBold,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0), Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1, LayoutOrder = 1,
}), }),
B = e("TextLabel", { ColumnB = e("TextLabel", {
Visible = columnVisibility[2], Text = tostring(headerRow[2]),
Text = tostring(changes[1][2]),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamBold, Font = Enum.Font.GothamBold,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.35, 0, 1, 0), Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2, LayoutOrder = 2,
}), }),
C = e("TextLabel", { ColumnC = e("TextLabel", {
Visible = columnVisibility[3], Text = tostring(headerRow[3]),
Text = tostring(changes[1][3]),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamBold, Font = Enum.Font.GothamBold,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
@@ -93,8 +210,11 @@ function ChangeList:render()
continue -- Skip headers, already handled above continue -- Skip headers, already handled above
end end
local metadata = values[4] or EMPTY_TABLE
local isWarning = metadata.isWarning
rows[row] = e("Frame", { rows[row] = e("Frame", {
Size = UDim2.new(1, 0, 0, 30), Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
BackgroundColor3 = theme.Diff.Row, BackgroundColor3 = theme.Diff.Row,
BorderSizePixel = 0, BorderSizePixel = 0,
@@ -107,45 +227,25 @@ function ChangeList:render()
HorizontalAlignment = Enum.HorizontalAlignment.Left, HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center,
}), }),
A = e("TextLabel", { ColumnA = e("TextLabel", {
Visible = columnVisibility[1], Text = (if isWarning then "" else "") .. tostring(values[1]),
Text = tostring(values[1]),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, Font = Enum.Font.GothamMedium,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = if isWarning then theme.Diff.Warning else theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0), Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1, LayoutOrder = 1,
}), }),
B = e( Content = e(RowContent, {
"Frame", values = values,
{ metadata = metadata,
Visible = columnVisibility[2], transparency = props.transparency,
BackgroundTransparency = 1, showStringDiff = props.showStringDiff,
Size = UDim2.new(0.35, 0, 1, 0), showTableDiff = props.showTableDiff,
LayoutOrder = 2, }),
},
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
})
),
C = e(
"Frame",
{
Visible = columnVisibility[3],
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
},
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
})
),
}) })
end end
@@ -169,8 +269,8 @@ function ChangeList:render()
}, { }, {
Headers = headers, Headers = headers,
Values = e(ScrollingFrame, { Values = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, -30), size = UDim2.new(1, 0, 1, -24),
position = UDim2.new(0, 0, 0, 30), position = UDim2.new(0, 0, 0, 24),
contentSize = self.contentSize, contentSize = self.contentSize,
transparency = props.transparency, transparency = props.transparency,
}, rows), }, rows),

View File

@@ -30,11 +30,11 @@ local function DisplayValue(props)
}), }),
}), }),
Label = e("TextLabel", { Label = e("TextLabel", {
Text = string.format("%d,%d,%d", props.value.R * 255, props.value.G * 255, props.value.B * 255), Text = string.format("%d, %d, %d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, Font = Enum.Font.GothamMedium,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
@@ -42,7 +42,6 @@ local function DisplayValue(props)
Position = UDim2.new(0, 25, 0, 0), Position = UDim2.new(0, 25, 0, 0),
}), }),
}) })
elseif t == "table" then elseif t == "table" then
-- Showing a memory address for tables is useless, so we want to show the best we can -- Showing a memory address for tables is useless, so we want to show the best we can
local textRepresentation = nil local textRepresentation = nil
@@ -54,18 +53,33 @@ local function DisplayValue(props)
elseif next(props.value) == nil then elseif next(props.value) == nil then
-- If it's empty, show empty braces -- If it's empty, show empty braces
textRepresentation = "{}" textRepresentation = "{}"
elseif next(props.value) == 1 then
-- We don't need to support mixed tables, so checking the first key is enough
-- to determine if it's a simple array
local out, i = table.create(#props.value), 0
for _, v in props.value do
i += 1
-- Wrap strings in quotes
if type(v) == "string" then
v = '"' .. v .. '"'
end
out[i] = tostring(v)
end
textRepresentation = "{ " .. table.concat(out, ", ") .. " }"
else else
-- If it has children, list them out -- Otherwise, show the table contents as a dictionary
local out, i = {}, 0 local out, i = {}, 0
for k, v in pairs(props.value) do for k, v in pairs(props.value) do
i += 1 i += 1
-- Wrap strings in quotes -- Wrap strings in quotes
if type(k) == "string" then if type(k) == "string" then
k = "\"" .. k .. "\"" k = '"' .. k .. '"'
end end
if type(v) == "string" then if type(v) == "string" then
v = "\"" .. v .. "\"" v = '"' .. v .. '"'
end end
out[i] = string.format("[%s] = %s", tostring(k), tostring(v)) out[i] = string.format("[%s] = %s", tostring(k), tostring(v))
@@ -78,7 +92,7 @@ local function DisplayValue(props)
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, Font = Enum.Font.GothamMedium,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
@@ -90,12 +104,17 @@ local function DisplayValue(props)
-- Or special text handling tostring for some? -- Or special text handling tostring for some?
-- Will add as needed, let's see what cases arise. -- Will add as needed, let's see what cases arise.
local textRepresentation = string.gsub(tostring(props.value), "%s", " ")
if t == "string" then
textRepresentation = '"' .. textRepresentation .. '"'
end
return e("TextLabel", { return e("TextLabel", {
Text = string.gsub(tostring(props.value), "%s", " "), Text = textRepresentation,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, Font = Enum.Font.GothamMedium,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,

View File

@@ -1,4 +1,4 @@
local StudioService = game:GetService("StudioService") local SelectionService = game:GetService("Selection")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
@@ -14,6 +14,8 @@ local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement local e = Roact.createElement
local ChangeList = require(script.Parent.ChangeList) local ChangeList = require(script.Parent.ChangeList)
local Tooltip = require(Plugin.App.Components.Tooltip)
local ClassIcon = require(Plugin.App.Components.ClassIcon)
local Expansion = Roact.Component:extend("Expansion") local Expansion = Roact.Component:extend("Expansion")
@@ -26,13 +28,14 @@ function Expansion:render()
return e("Frame", { return e("Frame", {
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(1, -props.indent, 1, -30), Size = UDim2.new(1, -props.indent, 1, -24),
Position = UDim2.new(0, props.indent, 0, 30), Position = UDim2.new(0, props.indent, 0, 24),
}, { }, {
ChangeList = e(ChangeList, { ChangeList = e(ChangeList, {
changes = props.changeList, changes = props.changeList,
transparency = props.transparency, transparency = props.transparency,
columnVisibility = props.columnVisibility, showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
}), }),
}) })
end end
@@ -40,13 +43,8 @@ end
local DomLabel = Roact.Component:extend("DomLabel") local DomLabel = Roact.Component:extend("DomLabel")
function DomLabel:init() function DomLabel:init()
self.maxElementHeight = 0
if self.props.changeList then
self.maxElementHeight = math.clamp(#self.props.changeList * 30, 30, 30 * 6)
end
local initHeight = self.props.elementHeight:getValue() local initHeight = self.props.elementHeight:getValue()
self.expanded = initHeight > 30 self.expanded = initHeight > 24
self.motor = Flipper.SingleMotor.new(initHeight) self.motor = Flipper.SingleMotor.new(initHeight)
self.binding = bindingUtil.fromMotor(self.motor) self.binding = bindingUtil.fromMotor(self.motor)
@@ -55,7 +53,7 @@ function DomLabel:init()
renderExpansion = self.expanded, renderExpansion = self.expanded,
}) })
self.motor:onStep(function(value) self.motor:onStep(function(value)
local renderExpansion = value > 30 local renderExpansion = value > 24
self.props.setElementHeight(value) self.props.setElementHeight(value)
if self.props.updateEvent then if self.props.updateEvent then
@@ -74,35 +72,77 @@ function DomLabel:init()
end) end)
end end
function DomLabel:didUpdate(prevProps)
if
prevProps.instance ~= self.props.instance
or prevProps.patchType ~= self.props.patchType
or prevProps.name ~= self.props.name
or prevProps.changeList ~= self.props.changeList
then
-- Close the expansion when the domlabel is changed to a different thing
self.expanded = false
self.motor:setGoal(Flipper.Spring.new(24, {
frequency = 5,
dampingRatio = 1,
}))
end
end
function DomLabel:render() function DomLabel:render()
local props = self.props local props = self.props
local depth = props.depth or 1
return Theme.with(function(theme) return Theme.with(function(theme)
local iconProps = StudioService:GetClassIcon(props.className) local color = if props.isWarning
local indent = (props.depth or 0) * 20 + 25 then theme.Diff.Warning
elseif props.patchType then theme.Diff[props.patchType]
else theme.TextColor
local indent = (depth - 1) * 12 + 15
-- Line guides help indent depth remain readable -- Line guides help indent depth remain readable
local lineGuides = {} local lineGuides = {}
for i = 1, props.depth or 0 do for i = 2, depth do
table.insert( if props.depthsComplete[i] then
lineGuides, continue
e("Frame", { end
Name = "Line_" .. i, if props.isFinalChild and i == depth then
Size = UDim2.new(0, 2, 1, 2), -- This line stops halfway down to merge with our connector for the right angle
Position = UDim2.new(0, (20 * i) + 15, 0, -1), lineGuides["Line_" .. i] = e("Frame", {
Size = UDim2.new(0, 2, 0, 15),
Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1),
BorderSizePixel = 0, BorderSizePixel = 0,
BackgroundTransparency = props.transparency, BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor, BackgroundColor3 = theme.BorderedContainer.BorderColor,
}) })
) else
-- All other lines go all the way
-- with the exception of the final element, which stops halfway down
lineGuides["Line_" .. i] = e("Frame", {
Size = UDim2.new(0, 2, 1, if props.isFinalElement then -9 else 2),
Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1),
BorderSizePixel = 0,
BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
})
end
end
if depth ~= 1 then
lineGuides["Connector"] = e("Frame", {
Size = UDim2.new(0, 8, 0, 2),
Position = UDim2.new(0, 2 + (12 * props.depth), 0, 12),
AnchorPoint = Vector2.xAxis,
BorderSizePixel = 0,
BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
})
end end
return e("Frame", { return e("Frame", {
Name = "Change",
ClipsDescendants = true, ClipsDescendants = true,
BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil, BackgroundTransparency = if props.elementIndex % 2 == 0 then 0.985 else 1,
BorderSizePixel = 0, BackgroundColor3 = theme.Diff.Row,
BackgroundTransparency = props.patchType and props.transparency or 1,
Size = self.binding:map(function(expand) Size = self.binding:map(function(expand)
return UDim2.new(1, 0, 0, expand) return UDim2.new(1, 0, 0, expand)
end), end),
@@ -111,66 +151,128 @@ function DomLabel:render()
PaddingLeft = UDim.new(0, 10), PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10), PaddingRight = UDim.new(0, 10),
}), }),
ExpandButton = if props.changeList Button = e("TextButton", {
then e("TextButton", { BackgroundTransparency = 1,
BackgroundTransparency = 1, Text = "",
Text = "", Size = UDim2.new(1, 0, 1, 0),
Size = UDim2.new(1, 0, 1, 0), [Roact.Event.Activated] = function(_rbx: Instance, _input: InputObject, clickCount: number)
[Roact.Event.Activated] = function() if clickCount == 1 then
self.expanded = not self.expanded -- Double click opens the instance in explorer
self.motor:setGoal(Flipper.Spring.new((self.expanded and self.maxElementHeight or 0) + 30, { self.lastDoubleClickTime = os.clock()
frequency = 5, if props.instance then
dampingRatio = 1, SelectionService:Set({ props.instance })
})) end
end, elseif clickCount == 0 then
}) -- Single click expands the changes
else nil, task.wait(0.25)
if os.clock() - (self.lastDoubleClickTime or 0) <= 0.25 then
-- This is a double click, so don't expand
return
end
if props.changeList then
self.expanded = not self.expanded
local goalHeight = 24
+ (if self.expanded then math.clamp(#props.changeList * 24, 24, 24 * 6) else 0)
self.motor:setGoal(Flipper.Spring.new(goalHeight, {
frequency = 5,
dampingRatio = 1,
}))
end
end
end,
}, {
StateTip = if (props.instance or props.changeList)
then e(Tooltip.Trigger, {
text = (if props.changeList
then "Click to " .. (if self.expanded then "hide" else "view") .. " changes"
else "") .. (if props.instance
then (if props.changeList then " & d" else "D") .. "ouble click to open in Explorer"
else ""),
})
else nil,
}),
Expansion = if props.changeList Expansion = if props.changeList
then e(Expansion, { then e(Expansion, {
rendered = self.state.renderExpansion, rendered = self.state.renderExpansion,
indent = indent, indent = indent,
transparency = props.transparency, transparency = props.transparency,
changeList = props.changeList, changeList = props.changeList,
columnVisibility = props.columnVisibility, showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
}) })
else nil, else nil,
DiffIcon = if props.patchType DiffIcon = if props.patchType
then e("ImageLabel", { then e("ImageLabel", {
Image = Assets.Images.Diff[props.patchType], Image = Assets.Images.Diff[props.patchType],
ImageColor3 = theme.AddressEntry.PlaceholderColor, ImageColor3 = color,
ImageTransparency = props.transparency, ImageTransparency = props.transparency,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(0, 20, 0, 20), Size = UDim2.new(0, 14, 0, 14),
Position = UDim2.new(0, 0, 0, 15), Position = UDim2.new(0, 0, 0, 12),
AnchorPoint = Vector2.new(0, 0.5), AnchorPoint = Vector2.new(0, 0.5),
}) })
else nil, else nil,
ClassIcon = e("ImageLabel", { ClassIcon = e(ClassIcon, {
Image = iconProps.Image, className = props.className,
ImageTransparency = props.transparency, color = color,
ImageRectOffset = iconProps.ImageRectOffset, transparency = props.transparency,
ImageRectSize = iconProps.ImageRectSize, size = UDim2.new(0, 16, 0, 16),
BackgroundTransparency = 1, position = UDim2.new(0, indent + 2, 0, 12),
Size = UDim2.new(0, 20, 0, 20), anchorPoint = Vector2.new(0, 0.5),
Position = UDim2.new(0, indent, 0, 15),
AnchorPoint = Vector2.new(0, 0.5),
}), }),
InstanceName = e("TextLabel", { InstanceName = e("TextLabel", {
Text = props.name .. (props.hint and string.format( Text = (if props.isWarning then "" else "") .. props.name,
' <font color="#%s">%s</font>',
theme.AddressEntry.PlaceholderColor:ToHex(),
props.hint
) or ""),
RichText = true, RichText = true,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, Font = if props.patchType then Enum.Font.GothamBold else Enum.Font.GothamMedium,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = color,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(1, -indent - 50, 0, 30), Size = UDim2.new(1, -indent - 50, 0, 24),
Position = UDim2.new(0, indent + 30, 0, 0), Position = UDim2.new(0, indent + 22, 0, 0),
}),
ChangeInfo = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(1, -indent - 80, 0, 24),
Position = UDim2.new(1, -2, 0, 0),
AnchorPoint = Vector2.new(1, 0),
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 4),
}),
Edits = if props.changeInfo and props.changeInfo.edits
then e("TextLabel", {
Text = props.changeInfo.edits .. if props.changeInfo.failed then "," else "",
BackgroundTransparency = 1,
Font = Enum.Font.Gotham,
TextSize = 14,
TextColor3 = theme.SubTextColor,
TextTransparency = props.transparency,
Size = UDim2.new(0, 0, 0, 16),
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,
Font = Enum.Font.Gotham,
TextSize = 14,
TextColor3 = theme.Diff.Warning,
TextTransparency = props.transparency,
Size = UDim2.new(0, 0, 0, 16),
AutomaticSize = Enum.AutomaticSize.X,
LayoutOrder = 6,
})
else nil,
}), }),
LineGuides = e("Folder", nil, lineGuides), LineGuides = e("Folder", nil, lineGuides),
}) })

View File

@@ -1,143 +1,18 @@
local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local PatchTree = require(Plugin.PatchTree)
local PatchSet = require(Plugin.PatchSet) local PatchSet = require(Plugin.PatchSet)
local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local Theme = require(Plugin.App.Theme)
local VirtualScroller = require(Plugin.App.Components.VirtualScroller) local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local e = Roact.createElement local e = Roact.createElement
local function alphabeticalNext(t, state)
-- Equivalent of the next function, but returns the keys in the alphabetic
-- order of node names. We use a temporary ordered key table that is stored in the
-- table being iterated.
local key = nil
if state == nil then
-- First iteration, generate the index
local orderedIndex, i = table.create(5), 0
for k in t do
i += 1
orderedIndex[i] = k
end
table.sort(orderedIndex, function(a, b)
local nodeA, nodeB = t[a], t[b]
return (nodeA.name or "") < (nodeB.name or "")
end)
t.__orderedIndex = orderedIndex
key = orderedIndex[1]
else
-- Fetch the next value
for i, orderedState in t.__orderedIndex do
if orderedState == state then
key = t.__orderedIndex[i + 1]
break
end
end
end
if key then
return key, t[key]
end
-- No more value to return, cleanup
t.__orderedIndex = nil
return
end
local function alphabeticalPairs(t)
-- Equivalent of the pairs() iterator, but sorted
return alphabeticalNext, t, nil
end
local function Tree()
local tree = {
idToNode = {},
ROOT = {
className = "DataModel",
name = "ROOT",
children = {},
},
}
-- Add ROOT to idToNode or it won't be found by getNode since that searches *within* ROOT
tree.idToNode["ROOT"] = tree.ROOT
function tree:getNode(id, target)
if self.idToNode[id] then
return self.idToNode[id]
end
for nodeId, node in target or tree.ROOT.children do
if nodeId == id then
self.idToNode[id] = node
return node
end
local descendant = self:getNode(id, node.children)
if descendant then
return descendant
end
end
return nil
end
function tree:addNode(parent, props)
parent = parent or "ROOT"
local node = self:getNode(props.id)
if node then
for k, v in props do
node[k] = v
end
return node
end
node = table.clone(props)
node.children = {}
local parentNode = self:getNode(parent)
if not parentNode then
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
return
end
parentNode.children[node.id] = node
self.idToNode[node.id] = node
return node
end
function tree:buildAncestryNodes(ancestry, patch, instanceMap)
-- Build nodes for ancestry by going up the tree
local previousId = "ROOT"
for _, ancestorId in ancestry do
local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId]
if not value then
Log.warn("Failed to find ancestor object for " .. ancestorId)
continue
end
self:addNode(previousId, {
id = ancestorId,
className = value.ClassName,
name = value.Name,
})
previousId = ancestorId
end
end
return tree
end
local DomLabel = require(script.DomLabel) local DomLabel = require(script.DomLabel)
local PatchVisualizer = Roact.Component:extend("PatchVisualizer") local PatchVisualizer = Roact.Component:extend("PatchVisualizer")
@@ -153,250 +28,125 @@ function PatchVisualizer:willUnmount()
end end
function PatchVisualizer:shouldUpdate(nextProps) function PatchVisualizer:shouldUpdate(nextProps)
if self.props.patchTree ~= nextProps.patchTree then
return true
end
local currentPatch, nextPatch = self.props.patch, nextProps.patch local currentPatch, nextPatch = self.props.patch, nextProps.patch
if currentPatch ~= nil or nextPatch ~= nil then
return not PatchSet.isEqual(currentPatch, nextPatch) return not PatchSet.isEqual(currentPatch, nextPatch)
end
function PatchVisualizer:buildTree(patch, instanceMap)
local tree = Tree()
for _, change in patch.updated do
local instance = instanceMap.fromIds[change.id]
if not instance then
continue
end
-- Gather ancestors from existing DOM
local ancestry = {}
local parentObject = instance.Parent
local parentId = instanceMap.fromInstances[parentObject]
while parentObject do
table.insert(ancestry, 1, parentId)
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject]
end
tree:buildAncestryNodes(ancestry, patch, instanceMap)
-- Gather detail text
local changeList, hint = nil, nil
if next(change.changedProperties) or change.changedName then
changeList = {}
local hintBuffer, i = {}, 0
local function addProp(prop: string, current: any?, incoming: any?)
i += 1
hintBuffer[i] = prop
changeList[i] = { prop, current, incoming }
end
-- Gather the changes
if change.changedName then
addProp("Name", instance.Name, change.changedName)
end
for prop, incoming in change.changedProperties do
local incomingSuccess, incomingValue = decodeValue(incoming, instanceMap)
local currentSuccess, currentValue = getProperty(instance, prop)
addProp(
prop,
if currentSuccess then currentValue else "[Error]",
if incomingSuccess then incomingValue else next(incoming)
)
end
-- Finalize detail values
-- Trim hint to top 3
table.sort(hintBuffer)
if #hintBuffer > 3 then
hintBuffer = {
hintBuffer[1],
hintBuffer[2],
hintBuffer[3],
i - 3 .. " more",
}
end
hint = table.concat(hintBuffer, ", ")
-- Sort changes and add header
table.sort(changeList, function(a, b)
return a[1] < b[1]
end)
table.insert(changeList, 1, { "Property", "Current", "Incoming" })
end
-- Add this node to tree
tree:addNode(instanceMap.fromInstances[instance.Parent], {
id = change.id,
patchType = "Edit",
className = instance.ClassName,
name = instance.Name,
hint = hint,
changeList = changeList,
})
end end
for _, instance in patch.removed do return false
-- Gather ancestors from existing DOM
-- (note that they may have no ID if they're being removed as unknown)
local ancestry = {}
local parentObject = instance.Parent
local parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
while parentObject do
instanceMap:insert(parentId, parentObject)
table.insert(ancestry, 1, parentId)
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
end
tree:buildAncestryNodes(ancestry, patch, instanceMap)
-- Add this node to tree
local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false)
instanceMap:insert(nodeId, instance)
tree:addNode(instanceMap.fromInstances[instance.Parent], {
id = nodeId,
patchType = "Remove",
className = instance.ClassName,
name = instance.Name,
})
end
for _, change in patch.added do
-- Gather ancestors from existing DOM or future additions
local ancestry = {}
local parentId = change.Parent
local parentData = patch.added[parentId]
local parentObject = instanceMap.fromIds[parentId]
while parentId do
table.insert(ancestry, 1, parentId)
parentId = nil
if parentData then
parentId = parentData.Parent
parentData = patch.added[parentId]
parentObject = instanceMap.fromIds[parentId]
elseif parentObject then
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject]
parentData = patch.added[parentId]
end
end
tree:buildAncestryNodes(ancestry, patch, instanceMap)
-- Gather detail text
local changeList, hint = nil, nil
if next(change.Properties) then
changeList = {}
local hintBuffer, i = {}, 0
for prop, incoming in change.Properties do
i += 1
hintBuffer[i] = prop
local success, incomingValue = decodeValue(incoming, instanceMap)
if success then
table.insert(changeList, { prop, "N/A", incomingValue })
else
table.insert(changeList, { prop, "N/A", next(incoming) })
end
end
-- Finalize detail values
-- Trim hint to top 3
table.sort(hintBuffer)
if #hintBuffer > 3 then
hintBuffer = {
hintBuffer[1],
hintBuffer[2],
hintBuffer[3],
i - 3 .. " more",
}
end
hint = table.concat(hintBuffer, ", ")
-- Sort changes and add header
table.sort(changeList, function(a, b)
return a[1] < b[1]
end)
table.insert(changeList, 1, { "Property", "Current", "Incoming" })
end
-- Add this node to tree
tree:addNode(change.Parent, {
id = change.Id,
patchType = "Add",
className = change.ClassName,
name = change.Name,
hint = hint,
changeList = changeList,
})
end
return tree
end end
function PatchVisualizer:render() function PatchVisualizer:render()
local patch = self.props.patch local patchTree = self.props.patchTree
local instanceMap = self.props.instanceMap if patchTree == nil and self.props.patch ~= nil then
patchTree = PatchTree.build(
local tree = self:buildTree(patch, instanceMap) self.props.patch,
self.props.instanceMap,
self.props.changeListHeaders or { "Property", "Current", "Incoming" }
)
if self.props.unappliedPatch then
patchTree =
PatchTree.updateMetadata(patchTree, self.props.patch, self.props.instanceMap, self.props.unappliedPatch)
end
end
-- Recusively draw tree -- Recusively draw tree
local scrollElements, elementHeights = {}, {} local scrollElements, elementHeights, elementIndex = {}, {}, 0
local function drawNode(node, depth)
local elementHeight, setElementHeight = Roact.createBinding(30) if patchTree then
table.insert(elementHeights, elementHeight) local elementTotal = patchTree:getCount()
table.insert( local depthsComplete = {}
scrollElements, local function drawNode(node, depth)
e(DomLabel, { elementIndex += 1
columnVisibility = self.props.columnVisibility,
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, updateEvent = self.updateEvent,
elementHeight = elementHeight, elementHeight = elementHeight,
setElementHeight = setElementHeight, 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, patchType = node.patchType,
className = node.className, className = node.className,
isWarning = node.isWarning,
instance = node.instance,
name = node.name, name = node.name,
hint = node.hint, changeInfo = node.changeInfo,
changeList = node.changeList, changeList = node.changeList,
depth = depth,
transparency = self.props.transparency,
}) })
)
for _, childNode in alphabeticalPairs(node.children) do if isFinalChild then
drawNode(childNode, depth + 1) depthsComplete[depth] = true
end
end end
end
for _, node in alphabeticalPairs(tree.ROOT.children) do patchTree:forEach(function(node, depth)
drawNode(node, 0) depthsComplete[depth] = false
for i = depth + 1, #depthsComplete do
depthsComplete[i] = nil
end
drawNode(node, depth)
end)
end end
return e(BorderedContainer, { return Theme.with(function(theme)
transparency = self.props.transparency, return e(BorderedContainer, {
size = self.props.size,
position = self.props.position,
layoutOrder = self.props.layoutOrder,
}, {
VirtualScroller = e(VirtualScroller, {
size = UDim2.new(1, 0, 1, 0),
transparency = self.props.transparency, transparency = self.props.transparency,
count = #scrollElements, size = self.props.size,
updateEvent = self.updateEvent.Event, position = self.props.position,
render = function(i) anchorPoint = self.props.anchorPoint,
return scrollElements[i] layoutOrder = self.props.layoutOrder,
end, }, {
getHeightBinding = function(i) CleanMerge = e("TextLabel", {
return elementHeights[i] Visible = #scrollElements == 0,
end, Text = "No changes to sync, project is up to date.",
}), Font = Enum.Font.GothamMedium,
}) TextSize = 15,
TextColor3 = theme.TextColor,
TextWrapped = true,
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
}),
VirtualScroller = e(VirtualScroller, {
size = UDim2.new(1, 0, 1, -2),
position = UDim2.new(0, 0, 0, 2),
transparency = self.props.transparency,
count = #scrollElements,
updateEvent = self.updateEvent.Event,
render = function(i)
return scrollElements[i]
end,
getHeightBinding = function(i)
return elementHeights[i]
end,
}),
})
end)
end end
return PatchVisualizer return PatchVisualizer

View File

@@ -10,6 +10,12 @@ local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement local e = Roact.createElement
local scrollDirToAutoSize = {
[Enum.ScrollingDirection.X] = Enum.AutomaticSize.X,
[Enum.ScrollingDirection.Y] = Enum.AutomaticSize.Y,
[Enum.ScrollingDirection.XY] = Enum.AutomaticSize.XY,
}
local function ScrollingFrame(props) local function ScrollingFrame(props)
return Theme.with(function(theme) return Theme.with(function(theme)
return e("ScrollingFrame", { return e("ScrollingFrame", {
@@ -23,19 +29,31 @@ local function ScrollingFrame(props)
BottomImage = Assets.Images.ScrollBar.Bottom, BottomImage = Assets.Images.ScrollBar.Bottom,
ElasticBehavior = Enum.ElasticBehavior.Always, ElasticBehavior = Enum.ElasticBehavior.Always,
ScrollingDirection = Enum.ScrollingDirection.Y, ScrollingDirection = props.scrollingDirection or Enum.ScrollingDirection.Y,
Size = props.size, Size = props.size,
Position = props.position, Position = props.position,
AnchorPoint = props.anchorPoint, AnchorPoint = props.anchorPoint,
CanvasSize = props.contentSize:map(function(value) CanvasSize = if props.contentSize
return UDim2.new(0, 0, 0, value.Y) then props.contentSize:map(function(value)
end), return UDim2.new(
0,
if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y)
then value.X
else 0,
0,
value.Y
)
end)
else UDim2.new(),
AutomaticCanvasSize = if props.contentSize == nil
then scrollDirToAutoSize[props.scrollingDirection or Enum.ScrollingDirection.XY]
else nil,
BorderSizePixel = 0, BorderSizePixel = 0,
BackgroundTransparency = 1, BackgroundTransparency = 1,
[Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize] [Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize],
}, props[Roact.Children]) }, props[Roact.Children])
end) end)
end end

View File

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

View File

@@ -0,0 +1,441 @@
--[[
Based on DiffMatchPatch by Neil Fraser.
https://github.com/google/diff-match-patch
]]
export type DiffAction = number
export type Diff = { actionType: DiffAction, value: string }
export type Diffs = { Diff }
local StringDiff = {
ActionTypes = table.freeze({
Equal = 0,
Delete = 1,
Insert = 2,
}),
}
function StringDiff.findDiffs(text1: string, text2: string): Diffs
-- Validate inputs
if type(text1) ~= "string" or type(text2) ~= "string" then
error(
string.format(
"Invalid inputs to StringDiff.findDiffs, expected strings and got (%s, %s)",
type(text1),
type(text2)
),
2
)
end
-- Shortcut if the texts are identical
if text1 == text2 then
return { { actionType = StringDiff.ActionTypes.Equal, value = text1 } }
end
-- Trim off any shared prefix and suffix
-- These are easy to detect and can be dealt with quickly without needing a complex diff
-- and later we simply add them as Equal to the start and end of the diff
local sharedPrefix, sharedSuffix
local prefixLength = StringDiff._sharedPrefix(text1, text2)
if prefixLength > 0 then
-- Store the prefix
sharedPrefix = string.sub(text1, 1, prefixLength)
-- Now trim it off
text1 = string.sub(text1, prefixLength + 1)
text2 = string.sub(text2, prefixLength + 1)
end
local suffixLength = StringDiff._sharedSuffix(text1, text2)
if suffixLength > 0 then
-- Store the suffix
sharedSuffix = string.sub(text1, -suffixLength)
-- Now trim it off
text1 = string.sub(text1, 1, -suffixLength - 1)
text2 = string.sub(text2, 1, -suffixLength - 1)
end
-- Compute the diff on the middle block where the changes lie
local diffs = StringDiff._computeDiff(text1, text2)
-- Restore the prefix and suffix
if sharedPrefix then
table.insert(diffs, 1, { actionType = StringDiff.ActionTypes.Equal, value = sharedPrefix })
end
if sharedSuffix then
table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = sharedSuffix })
end
-- Cleanup the diff
diffs = StringDiff._reorderAndMerge(diffs)
return diffs
end
function StringDiff._sharedPrefix(text1: string, text2: string): number
-- Uses a binary search to find the largest common prefix between the two strings
-- Performance analysis: http://neil.fraser.name/news/2007/10/09/
-- Shortcut common cases
if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, 1) ~= string.byte(text2, 1)) then
return 0
end
local pointerMin = 1
local pointerMax = math.min(#text1, #text2)
local pointerMid = pointerMax
local pointerStart = 1
while pointerMin < pointerMid do
if string.sub(text1, pointerStart, pointerMid) == string.sub(text2, pointerStart, pointerMid) then
pointerMin = pointerMid
pointerStart = pointerMin
else
pointerMax = pointerMid
end
pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2)
end
return pointerMid
end
function StringDiff._sharedSuffix(text1: string, text2: string): number
-- Uses a binary search to find the largest common suffix between the two strings
-- Performance analysis: http://neil.fraser.name/news/2007/10/09/
-- Shortcut common cases
if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, -1) ~= string.byte(text2, -1)) then
return 0
end
local pointerMin = 1
local pointerMax = math.min(#text1, #text2)
local pointerMid = pointerMax
local pointerEnd = 1
while pointerMin < pointerMid do
if string.sub(text1, -pointerMid, -pointerEnd) == string.sub(text2, -pointerMid, -pointerEnd) then
pointerMin = pointerMid
pointerEnd = pointerMin
else
pointerMax = pointerMid
end
pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2)
end
return pointerMid
end
function StringDiff._computeDiff(text1: string, text2: string): Diffs
-- Assumes that the prefix and suffix have already been trimmed off
-- and shortcut returns have been made so these texts must be different
local text1Length, text2Length = #text1, #text2
if text1Length == 0 then
-- It's simply inserting all of text2 into text1
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
end
if text2Length == 0 then
-- It's simply deleting all of text1
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
end
local longText = if text1Length > text2Length then text1 else text2
local shortText = if text1Length > text2Length then text2 else text1
local shortTextLength = #shortText
-- Shortcut if the shorter string exists entirely inside the longer one
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
if indexOf ~= nil then
local diffs = {
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
}
-- Swap insertions for deletions if diff is reversed
if text1Length > text2Length then
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
end
return diffs
end
if shortTextLength == 1 then
-- Single character string
-- After the previous shortcut, the character can't be an equality
return {
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
}
end
return StringDiff._bisect(text1, text2)
end
function StringDiff._bisect(text1: string, text2: string): Diffs
-- Find the 'middle snake' of a diff, split the problem in two
-- and return the recursively constructed diff
-- See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations
-- Cache the text lengths to prevent multiple calls
local text1Length = #text1
local text2Length = #text2
local _sub, _element
local maxD = math.ceil((text1Length + text2Length) / 2)
local vOffset = maxD
local vLength = 2 * maxD
local v1 = table.create(vLength)
local v2 = table.create(vLength)
-- Setting all elements to -1 is faster in Lua than mixing integers and nil
for x = 0, vLength - 1 do
v1[x] = -1
v2[x] = -1
end
v1[vOffset + 1] = 0
v2[vOffset + 1] = 0
local delta = text1Length - text2Length
-- If the total number of characters is odd, then
-- the front path will collide with the reverse path
local front = (delta % 2 ~= 0)
-- Offsets for start and end of k loop
-- Prevents mapping of space beyond the grid
local k1Start = 0
local k1End = 0
local k2Start = 0
local k2End = 0
for d = 0, maxD - 1 do
-- Walk the front path one step
for k1 = -d + k1Start, d - k1End, 2 do
local k1_offset = vOffset + k1
local x1
if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then
x1 = v1[k1_offset + 1]
else
x1 = v1[k1_offset - 1] + 1
end
local y1 = x1 - k1
while
(x1 <= text1Length)
and (y1 <= text2Length)
and (string.sub(text1, x1, x1) == string.sub(text2, y1, y1))
do
x1 = x1 + 1
y1 = y1 + 1
end
v1[k1_offset] = x1
if x1 > text1Length + 1 then
-- Ran off the right of the graph
k1End = k1End + 2
elseif y1 > text2Length + 1 then
-- Ran off the bottom of the graph
k1Start = k1Start + 2
elseif front then
local k2_offset = vOffset + delta - k1
if k2_offset >= 0 and k2_offset < vLength and v2[k2_offset] ~= -1 then
-- Mirror x2 onto top-left coordinate system
local x2 = text1Length - v2[k2_offset] + 1
if x1 > x2 then
-- Overlap detected
return StringDiff._bisectSplit(text1, text2, x1, y1)
end
end
end
end
-- Walk the reverse path one step
for k2 = -d + k2Start, d - k2End, 2 do
local k2_offset = vOffset + k2
local x2
if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then
x2 = v2[k2_offset + 1]
else
x2 = v2[k2_offset - 1] + 1
end
local y2 = x2 - k2
while
(x2 <= text1Length)
and (y2 <= text2Length)
and (string.sub(text1, -x2, -x2) == string.sub(text2, -y2, -y2))
do
x2 = x2 + 1
y2 = y2 + 1
end
v2[k2_offset] = x2
if x2 > text1Length + 1 then
-- Ran off the left of the graph
k2End = k2End + 2
elseif y2 > text2Length + 1 then
-- Ran off the top of the graph
k2Start = k2Start + 2
elseif not front then
local k1_offset = vOffset + delta - k2
if k1_offset >= 0 and k1_offset < vLength and v1[k1_offset] ~= -1 then
local x1 = v1[k1_offset]
local y1 = vOffset + x1 - k1_offset
-- Mirror x2 onto top-left coordinate system
x2 = text1Length - x2 + 1
if x1 > x2 then
-- Overlap detected
return StringDiff._bisectSplit(text1, text2, x1, y1)
end
end
end
end
end
-- Number of diffs equals number of characters, no commonality at all
return {
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
}
end
function StringDiff._bisectSplit(text1: string, text2: string, x: number, y: number): Diffs
-- Given the location of the 'middle snake',
-- split the diff in two parts and recurse
local text1a = string.sub(text1, 1, x - 1)
local text2a = string.sub(text2, 1, y - 1)
local text1b = string.sub(text1, x)
local text2b = string.sub(text2, y)
-- Compute both diffs serially
local diffs = StringDiff.findDiffs(text1a, text2a)
local diffsB = StringDiff.findDiffs(text1b, text2b)
-- Merge diffs
table.move(diffsB, 1, #diffsB, #diffs + 1, diffs)
return diffs
end
function StringDiff._reorderAndMerge(diffs: Diffs): Diffs
-- Reorder and merge like edit sections and merge equalities
-- Any edit section can move as long as it doesn't cross an equality
-- Add a dummy entry at the end
table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = "" })
local pointer = 1
local countDelete, countInsert = 0, 0
local textDelete, textInsert = "", ""
local commonLength
while diffs[pointer] do
local actionType = diffs[pointer].actionType
if actionType == StringDiff.ActionTypes.Insert then
countInsert = countInsert + 1
textInsert = textInsert .. diffs[pointer].value
pointer = pointer + 1
elseif actionType == StringDiff.ActionTypes.Delete then
countDelete = countDelete + 1
textDelete = textDelete .. diffs[pointer].value
pointer = pointer + 1
elseif actionType == StringDiff.ActionTypes.Equal then
-- Upon reaching an equality, check for prior redundancies
if countDelete + countInsert > 1 then
if (countDelete > 0) and (countInsert > 0) then
-- Factor out any common prefixies
commonLength = StringDiff._sharedPrefix(textInsert, textDelete)
if commonLength > 0 then
local back_pointer = pointer - countDelete - countInsert
if
(back_pointer > 1) and (diffs[back_pointer - 1].actionType == StringDiff.ActionTypes.Equal)
then
diffs[back_pointer - 1].value = diffs[back_pointer - 1].value
.. string.sub(textInsert, 1, commonLength)
else
table.insert(diffs, 1, {
actionType = StringDiff.ActionTypes.Equal,
value = string.sub(textInsert, 1, commonLength),
})
pointer = pointer + 1
end
textInsert = string.sub(textInsert, commonLength + 1)
textDelete = string.sub(textDelete, commonLength + 1)
end
-- Factor out any common suffixies
commonLength = StringDiff._sharedSuffix(textInsert, textDelete)
if commonLength ~= 0 then
diffs[pointer].value = string.sub(textInsert, -commonLength) .. diffs[pointer].value
textInsert = string.sub(textInsert, 1, -commonLength - 1)
textDelete = string.sub(textDelete, 1, -commonLength - 1)
end
end
-- Delete the offending records and add the merged ones
pointer = pointer - countDelete - countInsert
for _ = 1, countDelete + countInsert do
table.remove(diffs, pointer)
end
if #textDelete > 0 then
table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Delete, value = textDelete })
pointer = pointer + 1
end
if #textInsert > 0 then
table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Insert, value = textInsert })
pointer = pointer + 1
end
pointer = pointer + 1
elseif (pointer > 1) and (diffs[pointer - 1].actionType == StringDiff.ActionTypes.Equal) then
-- Merge this equality with the previous one
diffs[pointer - 1].value = diffs[pointer - 1].value .. diffs[pointer].value
table.remove(diffs, pointer)
else
pointer = pointer + 1
end
countInsert, countDelete = 0, 0
textDelete, textInsert = "", ""
end
end
if diffs[#diffs].value == "" then
-- Remove the dummy entry at the end
diffs[#diffs] = nil
end
-- Second pass: look for single edits surrounded on both sides by equalities
-- which can be shifted sideways to eliminate an equality
-- e.g: A<ins>BA</ins>C -> <ins>AB</ins>AC
local changes = false
pointer = 2
-- Intentionally ignore the first and last element (don't need checking)
while pointer < #diffs do
local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1]
if
(prevDiff.actionType == StringDiff.ActionTypes.Equal)
and (nextDiff.actionType == StringDiff.ActionTypes.Equal)
then
-- This is a single edit surrounded by equalities
local currentDiff = diffs[pointer]
local currentText = currentDiff.value
local prevText = prevDiff.value
local nextText = nextDiff.value
if #prevText == 0 then
table.remove(diffs, pointer - 1)
changes = true
elseif string.sub(currentText, -#prevText) == prevText then
-- Shift the edit over the previous equality
currentDiff.value = prevText .. string.sub(currentText, 1, -#prevText - 1)
nextDiff.value = prevText .. nextDiff.value
table.remove(diffs, pointer - 1)
changes = true
elseif string.sub(currentText, 1, #nextText) == nextText then
-- Shift the edit over the next equality
prevDiff.value = prevText .. nextText
currentDiff.value = string.sub(currentText, #nextText + 1) .. nextText
table.remove(diffs, pointer + 1)
changes = true
end
end
pointer = pointer + 1
end
-- If shifts were made, the diffs need reordering and another shift sweep
if changes then
return StringDiff._reorderAndMerge(diffs)
end
return diffs
end
return StringDiff

View File

@@ -0,0 +1,205 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Highlighter = require(Packages.Highlighter)
local StringDiff = require(script:FindFirstChild("StringDiff"))
local Timer = require(Plugin.Timer)
local Theme = require(Plugin.App.Theme)
local CodeLabel = require(Plugin.App.Components.CodeLabel)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local e = Roact.createElement
local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer")
function StringDiffVisualizer:init()
self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0))
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
-- Ensure that the script background is up to date with the current theme
self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function()
task.defer(function()
-- Defer to allow Highlighter to process the theme change first
self:updateScriptBackground()
end)
end)
self:calculateContentSize()
self:updateScriptBackground()
self:setState({
add = {},
remove = {},
})
end
function StringDiffVisualizer:willUnmount()
self.themeChangedConnection:Disconnect()
end
function StringDiffVisualizer:updateScriptBackground()
local backgroundColor = Highlighter.getTokenColor("background")
if backgroundColor ~= self.scriptBackground:getValue() then
self.setScriptBackground(backgroundColor)
end
end
function StringDiffVisualizer:didUpdate(previousProps)
if previousProps.oldString ~= self.props.oldString or previousProps.newString ~= self.props.newString then
self:calculateContentSize()
local add, remove = self:calculateDiffLines()
self:setState({
add = add,
remove = remove,
})
end
end
function StringDiffVisualizer:calculateContentSize()
local oldString, newString = self.props.oldString, self.props.newString
local oldStringBounds = TextService:GetTextSize(oldString, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
local newStringBounds = TextService:GetTextSize(newString, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
self.setContentSize(
Vector2.new(math.max(oldStringBounds.X, newStringBounds.X), math.max(oldStringBounds.Y, newStringBounds.Y))
)
end
function StringDiffVisualizer:calculateDiffLines()
Timer.start("StringDiffVisualizer:calculateDiffLines")
local oldString, newString = self.props.oldString, self.props.newString
-- Diff the two texts
local startClock = os.clock()
local diffs = StringDiff.findDiffs(oldString, newString)
local stopClock = os.clock()
Log.trace(
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
#oldString,
#newString,
math.round((stopClock - startClock) * 1000 * 1000),
#diffs
)
-- Determine which lines to highlight
local add, remove = {}, {}
local oldLineNum, newLineNum = 1, 1
for _, diff in diffs do
local actionType, text = diff.actionType, diff.value
local lines = select(2, string.gsub(text, "\n", "\n"))
if actionType == StringDiff.ActionTypes.Equal then
oldLineNum += lines
newLineNum += lines
elseif actionType == StringDiff.ActionTypes.Insert then
if lines > 0 then
local textLines = string.split(text, "\n")
for i, textLine in textLines do
if string.match(textLine, "%S") then
add[newLineNum + i - 1] = true
end
end
else
if string.match(text, "%S") then
add[newLineNum] = true
end
end
newLineNum += lines
elseif actionType == StringDiff.ActionTypes.Delete then
if lines > 0 then
local textLines = string.split(text, "\n")
for i, textLine in textLines do
if string.match(textLine, "%S") then
remove[oldLineNum + i - 1] = true
end
end
else
if string.match(text, "%S") then
remove[oldLineNum] = true
end
end
oldLineNum += lines
else
Log.warn("Unknown diff action: {} {}", actionType, text)
end
end
Timer.stop()
return add, remove
end
function StringDiffVisualizer:render()
local oldString, newString = self.props.oldString, self.props.newString
return Theme.with(function(theme)
return e(BorderedContainer, {
size = self.props.size,
position = self.props.position,
anchorPoint = self.props.anchorPoint,
transparency = self.props.transparency,
}, {
Background = e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
BorderSizePixel = 0,
BackgroundColor3 = self.scriptBackground,
ZIndex = -10,
}, {
UICorner = e("UICorner", {
CornerRadius = UDim.new(0, 5),
}),
}),
Separator = e("Frame", {
Size = UDim2.new(0, 2, 1, 0),
Position = UDim2.new(0.5, 0, 0, 0),
AnchorPoint = Vector2.new(0.5, 0),
BorderSizePixel = 0,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
BackgroundTransparency = 0.5,
}),
Old = e(ScrollingFrame, {
position = UDim2.new(0, 2, 0, 2),
size = UDim2.new(0.5, -7, 1, -4),
scrollingDirection = Enum.ScrollingDirection.XY,
transparency = self.props.transparency,
contentSize = self.contentSize,
}, {
Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0),
text = oldString,
lineBackground = theme.Diff.Remove,
markedLines = self.state.remove,
}),
}),
New = e(ScrollingFrame, {
position = UDim2.new(0.5, 5, 0, 2),
size = UDim2.new(0.5, -7, 1, -4),
scrollingDirection = Enum.ScrollingDirection.XY,
transparency = self.props.transparency,
contentSize = self.contentSize,
}, {
Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0),
text = newString,
lineBackground = theme.Diff.Add,
markedLines = self.state.add,
}),
}),
})
end)
end
return StringDiffVisualizer

View File

@@ -10,11 +10,15 @@ local StudioPluginContext = require(script.Parent.StudioPluginContext)
local e = Roact.createElement local e = Roact.createElement
local StudioPluginAction = Roact.Component:extend("StudioPluginAction") local StudioPluginAction = Roact.Component:extend("StudioPluginAction")
function StudioPluginAction:init() function StudioPluginAction:init()
self.pluginAction = self.props.plugin:CreatePluginAction( self.pluginAction = self.props.plugin:CreatePluginAction(
self.props.name, self.props.title, self.props.description, self.props.icon, self.props.bindable self.props.name,
self.props.title,
self.props.description,
self.props.icon,
self.props.bindable
) )
self.pluginAction.Triggered:Connect(self.props.onTriggered) self.pluginAction.Triggered:Connect(self.props.onTriggered)
@@ -31,9 +35,12 @@ end
local function StudioPluginActionWrapper(props) local function StudioPluginActionWrapper(props)
return e(StudioPluginContext.Consumer, { return e(StudioPluginContext.Consumer, {
render = function(plugin) render = function(plugin)
return e(StudioPluginAction, Dictionary.merge(props, { return e(
plugin = plugin, StudioPluginAction,
})) Dictionary.merge(props, {
plugin = plugin,
})
)
end, end,
}) })
end end

View File

@@ -1,3 +1,5 @@
local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
@@ -36,7 +38,10 @@ function StudioPluginGui:init()
minimumSize.Y minimumSize.Y
) )
local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(self.props.id, dockWidgetPluginGuiInfo) local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(
if self.props.isEphemeral then HttpService:GenerateGUID(false) else self.props.id,
dockWidgetPluginGuiInfo
)
pluginGui.Name = self.props.id pluginGui.Name = self.props.id
pluginGui.Title = self.props.title pluginGui.Title = self.props.title
@@ -76,6 +81,12 @@ function StudioPluginGui:didUpdate(lastProps)
if self.props.active ~= lastProps.active then if self.props.active ~= lastProps.active then
-- This is intentionally in didUpdate to make sure the initial active state -- This is intentionally in didUpdate to make sure the initial active state
-- (if the PluginGui is open initially) is preserved. -- (if the PluginGui is open initially) is preserved.
-- Studio widgets are very unreliable and sometimes need to be flickered
-- in order to force them to render correctly
-- This happens within a single frame so it doesn't flicker visibly
self.pluginGui.Enabled = self.props.active
self.pluginGui.Enabled = not self.props.active
self.pluginGui.Enabled = self.props.active self.pluginGui.Enabled = self.props.active
end end
end end

View File

@@ -18,12 +18,8 @@ StudioToggleButton.defaultProps = {
} }
function StudioToggleButton:init() function StudioToggleButton:init()
local button = self.props.toolbar:CreateButton( local button =
self.props.name, self.props.toolbar:CreateButton(self.props.name, self.props.tooltip, self.props.icon, self.props.text)
self.props.tooltip,
self.props.icon,
self.props.text
)
button.Click:Connect(function() button.Click:Connect(function()
if self.props.onClick then if self.props.onClick then
@@ -61,9 +57,12 @@ end
local function StudioToggleButtonWrapper(props) local function StudioToggleButtonWrapper(props)
return e(StudioToolbarContext.Consumer, { return e(StudioToolbarContext.Consumer, {
render = function(toolbar) render = function(toolbar)
return e(StudioToggleButton, Dictionary.merge(props, { return e(
toolbar = toolbar, StudioToggleButton,
})) Dictionary.merge(props, {
toolbar = toolbar,
})
)
end, end,
}) })
end end

View File

@@ -36,9 +36,12 @@ end
local function StudioToolbarWrapper(props) local function StudioToolbarWrapper(props)
return e(StudioPluginContext.Consumer, { return e(StudioPluginContext.Consumer, {
render = function(plugin) render = function(plugin)
return e(StudioToolbar, Dictionary.merge(props, { return e(
plugin = plugin, StudioToolbar,
})) Dictionary.merge(props, {
plugin = plugin,
})
)
end, end,
}) })
end 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 = if patchType == "Remain" then theme.Diff.Row else theme.Diff[patchType],
BorderSizePixel = 0,
LayoutOrder = i,
}, {
DiffIcon = if patchType ~= "Remain"
then e("ImageLabel", {
Image = Assets.Images.Diff[patchType],
ImageColor3 = theme.AddressEntry.PlaceholderColor,
ImageTransparency = self.props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 15, 0, 15),
Position = UDim2.new(0, 7, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
})
else nil,
Old = e("Frame", {
Size = UDim2.new(0.5, -30, 1, 0),
Position = UDim2.new(0, 30, 0, 0),
BackgroundTransparency = 1,
}, {
Display = if oldValue ~= nil
then e(DisplayValue, {
value = oldValue,
transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor,
})
else nil,
}),
New = e("Frame", {
Size = UDim2.new(0.5, -10, 1, 0),
Position = UDim2.new(0.5, 5, 0, 0),
BackgroundTransparency = 1,
}, {
Display = if newValue ~= nil
then e(DisplayValue, {
value = newValue,
transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor,
})
else nil,
}),
})
)
end
return Roact.createFragment({
Headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 25),
BackgroundTransparency = self.props.transparency:map(function(t)
return 0.95 + (0.05 * t)
end),
BackgroundColor3 = theme.Diff.Row,
}, {
ColumnA = e("TextLabel", {
Size = UDim2.new(0.5, -30, 1, 0),
Position = UDim2.new(0, 30, 0, 0),
BackgroundTransparency = 1,
Text = "Old",
TextXAlignment = Enum.TextXAlignment.Left,
Font = Enum.Font.GothamBold,
TextSize = 14,
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,
Font = Enum.Font.GothamBold,
TextSize = 14,
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,211 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local DisplayValue = require(Plugin.App.Components.PatchVisualizer.DisplayValue)
local e = Roact.createElement
local Dictionary = Roact.Component:extend("Dictionary")
function Dictionary:init()
self:setState({
diff = self:calculateDiff(),
})
end
function Dictionary:calculateDiff()
Timer.start("Dictionary:calculateDiff")
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
-- Diff the two tables and find the added keys, removed keys, and changed keys
local diff = {}
for key, oldValue in oldTable do
local newValue = newTable[key]
if newValue == nil then
table.insert(diff, {
key = key,
patchType = "Remove",
})
elseif newValue ~= oldValue then
-- Note: should this do some sort of deep comparison for various types?
table.insert(diff, {
key = key,
patchType = "Edit",
})
else
table.insert(diff, {
key = key,
patchType = "Remain",
})
end
end
for key in newTable do
if oldTable[key] == nil then
table.insert(diff, {
key = key,
patchType = "Add",
})
end
end
table.sort(diff, function(a, b)
return a.key < b.key
end)
Timer.stop()
return diff
end
function Dictionary:didUpdate(previousProps)
if previousProps.oldTable ~= self.props.oldTable or previousProps.newTable ~= self.props.newTable then
self:setState({
diff = self:calculateDiff(),
})
end
end
function Dictionary:render()
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
local diff = self.state.diff
return Theme.with(function(theme)
local lines = table.create(#diff)
for order, line in diff do
local key = line.key
local oldValue = oldTable[key]
local newValue = newTable[key]
table.insert(
lines,
e("Frame", {
Size = UDim2.new(1, 0, 0, 25),
LayoutOrder = order,
BorderSizePixel = 0,
BackgroundTransparency = if line.patchType == "Remain" then 1 else self.props.transparency,
BackgroundColor3 = if line.patchType == "Remain"
then theme.Diff.Row
else theme.Diff[line.patchType],
}, {
DiffIcon = if line.patchType ~= "Remain"
then e("ImageLabel", {
Image = Assets.Images.Diff[line.patchType],
ImageColor3 = theme.AddressEntry.PlaceholderColor,
ImageTransparency = self.props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 15, 0, 15),
Position = UDim2.new(0, 7, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
})
else nil,
KeyName = e("TextLabel", {
Size = UDim2.new(0.3, -15, 1, 0),
Position = UDim2.new(0, 30, 0, 0),
BackgroundTransparency = 1,
Text = key,
TextXAlignment = Enum.TextXAlignment.Left,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextTruncate = Enum.TextTruncate.AtEnd,
}),
OldValue = e("Frame", {
Size = UDim2.new(0.35, -7, 1, 0),
Position = UDim2.new(0.3, 15, 0, 0),
BackgroundTransparency = 1,
}, {
e(DisplayValue, {
value = oldValue,
transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor,
}),
}),
NewValue = e("Frame", {
Size = UDim2.new(0.35, -8, 1, 0),
Position = UDim2.new(0.65, 8, 0, 0),
BackgroundTransparency = 1,
}, {
e(DisplayValue, {
value = newValue,
transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor,
}),
}),
})
)
end
return Roact.createFragment({
Headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 25),
BackgroundTransparency = self.props.transparency:map(function(t)
return 0.95 + (0.05 * t)
end),
BackgroundColor3 = theme.Diff.Row,
}, {
ColumnA = e("TextLabel", {
Size = UDim2.new(0.3, -15, 1, 0),
Position = UDim2.new(0, 30, 0, 0),
BackgroundTransparency = 1,
Text = "Key",
TextXAlignment = Enum.TextXAlignment.Left,
Font = Enum.Font.GothamBold,
TextSize = 14,
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,
Font = Enum.Font.GothamBold,
TextSize = 14,
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,
Font = Enum.Font.GothamBold,
TextSize = 14,
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,56 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Assets = require(Plugin.Assets)
local SlicedImage = require(Plugin.App.Components.SlicedImage)
local e = Roact.createElement
return function(props)
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, 16),
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,
Font = Enum.Font.GothamMedium,
TextSize = 12,
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

View File

@@ -41,10 +41,8 @@ end
function TextButton:render() function TextButton:render()
return Theme.with(function(theme) return Theme.with(function(theme)
local textSize = TextService:GetTextSize( local textSize =
self.props.text, 18, Enum.Font.GothamSemibold, TextService:GetTextSize(self.props.text, 18, Enum.Font.GothamMedium, Vector2.new(math.huge, math.huge))
Vector2.new(math.huge, math.huge)
)
local style = self.props.style local style = self.props.style
@@ -85,7 +83,7 @@ function TextButton:render()
Text = e("TextLabel", { Text = e("TextLabel", {
Text = self.props.text, Text = self.props.text,
Font = Enum.Font.GothamSemibold, Font = Enum.Font.GothamMedium,
TextSize = 18, TextSize = 18,
TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.TextColor, theme.Disabled.TextColor), TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.TextColor, theme.Disabled.TextColor),
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
@@ -124,7 +122,11 @@ function TextButton:render()
Background = style == "Solid" and e(SlicedImage, { Background = style == "Solid" and e(SlicedImage, {
slice = Assets.Slices.RoundedBackground, slice = Assets.Slices.RoundedBackground,
color = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.BackgroundColor, theme.Disabled.BackgroundColor), color = bindingUtil.mapLerp(
bindingEnabled,
theme.Enabled.BackgroundColor,
theme.Disabled.BackgroundColor
),
transparency = self.props.transparency, transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),

View File

@@ -0,0 +1,107 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets)
local bindingUtil = require(Plugin.App.bindingUtil)
local SlicedImage = require(script.Parent.SlicedImage)
local SPRING_PROPS = {
frequency = 5,
dampingRatio = 1,
}
local e = Roact.createElement
local TextInput = Roact.Component:extend("TextInput")
function TextInput:init()
self.motor = Flipper.GroupMotor.new({
hover = 0,
enabled = self.props.enabled and 1 or 0,
})
self.binding = bindingUtil.fromMotor(self.motor)
end
function TextInput:didUpdate(lastProps)
if lastProps.enabled ~= self.props.enabled then
self.motor:setGoal({
enabled = Flipper.Spring.new(self.props.enabled and 1 or 0),
})
end
end
function TextInput:render()
return Theme.with(function(theme)
theme = 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),
transparency = self.props.transparency,
size = self.props.size or UDim2.new(1, 0, 1, 0),
position = self.props.position,
layoutOrder = self.props.layoutOrder,
anchorPoint = self.props.anchorPoint,
}, {
HoverOverlay = e(SlicedImage, {
slice = Assets.Slices.RoundedBackground,
color = theme.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 })
end),
size = UDim2.new(1, 0, 1, 0),
zIndex = -1,
}),
Input = e("TextBox", {
BackgroundTransparency = 1,
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),
PlaceholderColor3 = bindingUtil.mapLerp(
bindingEnabled,
theme.Disabled.PlaceholderColor,
theme.Enabled.PlaceholderColor
),
TextSize = 18,
TextEditable = self.props.enabled,
ClearTextOnFocus = self.props.clearTextOnFocus,
[Roact.Event.MouseEnter] = function()
self.motor:setGoal({
hover = Flipper.Spring.new(1, SPRING_PROPS),
})
end,
[Roact.Event.MouseLeave] = function()
self.motor:setGoal({
hover = Flipper.Spring.new(0, SPRING_PROPS),
})
end,
[Roact.Event.FocusLost] = function(rbx)
self.props.onEntered(rbx.Text)
end,
}),
Children = Roact.createFragment(self.props[Roact.Children]),
})
end)
end
return TextInput

View File

@@ -22,12 +22,16 @@ local TooltipContext = Roact.createContext({})
local function Popup(props) local function Popup(props)
local textSize = TextService:GetTextSize( local textSize = TextService:GetTextSize(
props.Text, 16, Enum.Font.GothamMedium, Vector2.new(math.min(props.parentSize.X, 160), math.huge) props.Text,
16,
Enum.Font.GothamMedium,
Vector2.new(math.min(props.parentSize.X, 160), math.huge)
) + TEXT_PADDING + (Vector2.one * 2) ) + TEXT_PADDING + (Vector2.one * 2)
local trigger = props.Trigger:getValue() local trigger = props.Trigger:getValue()
local spaceBelow = props.parentSize.Y - (trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y - Y_OVERLAP + TAIL_SIZE) local spaceBelow = props.parentSize.Y
- (trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y - Y_OVERLAP + TAIL_SIZE)
local spaceAbove = trigger.AbsolutePosition.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 -- If there's not enough space below, and there's more space above, then show the tooltip above the trigger
@@ -39,7 +43,10 @@ local function Popup(props)
if displayAbove then if displayAbove then
Y = math.max(trigger.AbsolutePosition.Y - TAIL_SIZE - textSize.Y + Y_OVERLAP, 0) Y = math.max(trigger.AbsolutePosition.Y - TAIL_SIZE - textSize.Y + Y_OVERLAP, 0)
else else
Y = math.min(trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y + TAIL_SIZE - Y_OVERLAP, props.parentSize.Y - textSize.Y) Y = math.min(
trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y + TAIL_SIZE - Y_OVERLAP,
props.parentSize.Y - textSize.Y
)
end end
return Theme.with(function(theme) return Theme.with(function(theme)
@@ -64,17 +71,9 @@ local function Popup(props)
Tail = e("ImageLabel", { Tail = e("ImageLabel", {
ZIndex = 100, ZIndex = 100,
Position = Position = if displayAbove
if displayAbove then then UDim2.new(0, math.clamp(props.Position.X - X, 6, textSize.X - 6), 1, -1)
UDim2.new( else UDim2.new(0, math.clamp(props.Position.X - X, 6, textSize.X - 6), 0, -TAIL_SIZE + 1),
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
),
Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE), Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE),
AnchorPoint = Vector2.new(0.5, 0), AnchorPoint = Vector2.new(0.5, 0),
Rotation = if displayAbove then 180 else 0, Rotation = if displayAbove then 180 else 0,
@@ -90,7 +89,7 @@ local function Popup(props)
ImageColor3 = theme.BorderedContainer.BorderColor, ImageColor3 = theme.BorderedContainer.BorderColor,
ImageTransparency = props.transparency, ImageTransparency = props.transparency,
}), }),
}) }),
}) })
end) end)
end end
@@ -164,47 +163,93 @@ local Trigger = Roact.Component:extend("TooltipTrigger")
function Trigger:init() function Trigger:init()
self.id = HttpService:GenerateGUID(false) self.id = HttpService:GenerateGUID(false)
self.ref = Roact.createRef() self.ref = Roact.createRef()
self.mousePos = Vector2.zero self.showingPopup = false
self.destroy = function() self.destroy = function()
self.props.context.removeTip(self.id) self.props.context.removeTip(self.id)
self.showingPopup = false
end end
end end
function Trigger:willUnmount() function Trigger:willUnmount()
if self.showDelayThread then if self.showDelayThread then
task.cancel(self.showDelayThread) pcall(task.cancel, self.showDelayThread)
end end
if self.destroy then if self.destroy then
self.destroy() self.destroy()
end end
end end
function Trigger:didUpdate(prevProps)
if prevProps.text ~= self.props.text then
-- Any existing popup is now invalid
self.props.context.removeTip(self.id)
self.showingPopup = false
-- Let the new text propagate
self:managePopup()
end
end
function Trigger:isHovering()
local rbx = self.ref.current
if rbx then
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
-- Don't duplicate popups
return
end
self.showDelayThread = task.delay(DELAY, function()
self.props.context.addTip(self.id, {
Text = self.props.text,
Position = self:getMousePos(),
Trigger = self.ref,
})
self.showDelayThread = nil
self.showingPopup = true
end)
else
if self.showDelayThread then
pcall(task.cancel, self.showDelayThread)
self.showDelayThread = nil
end
self.props.context.removeTip(self.id)
self.showingPopup = false
end
end
function Trigger:render() function Trigger:render()
local function recalculate()
self:managePopup()
end
return e("Frame", { return e("Frame", {
Size = UDim2.fromScale(1, 1), Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1, BackgroundTransparency = 1,
ZIndex = self.props.zIndex or 100, ZIndex = self.props.zIndex or 100,
[Roact.Ref] = self.ref, [Roact.Ref] = self.ref,
[Roact.Event.MouseMoved] = function(_rbx, x, y) [Roact.Change.GuiState] = recalculate,
self.mousePos = Vector2.new(x, y) [Roact.Change.AbsolutePosition] = recalculate,
end, [Roact.Change.AbsoluteSize] = recalculate,
[Roact.Event.MouseEnter] = function()
self.showDelayThread = task.delay(DELAY, function()
self.props.context.addTip(self.id, {
Text = self.props.text,
Position = self.mousePos,
Trigger = self.ref,
})
end)
end,
[Roact.Event.MouseLeave] = function()
if self.showDelayThread then
task.cancel(self.showDelayThread)
end
self.props.context.removeTip(self.id)
end,
}) })
end end

View File

@@ -24,9 +24,7 @@ function TouchRipple:init()
}) })
self.binding = bindingUtil.fromMotor(self.motor) self.binding = bindingUtil.fromMotor(self.motor)
self.position, self.setPosition = Roact.createBinding( self.position, self.setPosition = Roact.createBinding(Vector2.new(0, 0))
Vector2.new(0, 0)
)
end end
function TouchRipple:reset() function TouchRipple:reset()
@@ -43,10 +41,7 @@ function TouchRipple:calculateRadius(position)
local container = self.ref.current local container = self.ref.current
if container then if container then
local corner = Vector2.new( local corner = Vector2.new(math.floor((1 - position.X) + 0.5), math.floor((1 - position.Y) + 0.5))
math.floor((1 - position.X) + 0.5),
math.floor((1 - position.Y) + 0.5)
)
local size = container.AbsoluteSize local size = container.AbsoluteSize
local ratio = size / math.min(size.X, size.Y) local ratio = size / math.min(size.X, size.Y)
@@ -93,10 +88,7 @@ function TouchRipple:render()
input:GetPropertyChangedSignal("UserInputState"):Connect(function() input:GetPropertyChangedSignal("UserInputState"):Connect(function()
local userInputState = input.UserInputState local userInputState = input.UserInputState
if if userInputState == Enum.UserInputState.Cancel or userInputState == Enum.UserInputState.End then
userInputState == Enum.UserInputState.Cancel
or userInputState == Enum.UserInputState.End
then
self.motor:setGoal({ self.motor:setGoal({
opacity = Flipper.Spring.new(0, { opacity = Flipper.Spring.new(0, {
frequency = 5, frequency = 5,
@@ -127,8 +119,10 @@ function TouchRipple:render()
local containerAspect = containerSize.X / containerSize.Y local containerAspect = containerSize.X / containerSize.Y
return UDim2.new( return UDim2.new(
currentSize / math.max(containerAspect, 1), 0, currentSize / math.max(containerAspect, 1),
currentSize * math.min(containerAspect, 1), 0 0,
currentSize * math.min(containerAspect, 1),
0
) )
end end
end), end),

View File

@@ -99,18 +99,30 @@ function VirtualScroller:refresh()
}) })
end end
function VirtualScroller:didUpdate(previousProps)
if self.props.count ~= previousProps.count then
-- Items have changed, so we need to refresh
self:refresh()
end
end
function VirtualScroller:render() function VirtualScroller:render()
local props, state = self.props, self.state local props, state = self.props, self.state
local items = {} local items = {}
for i = state.Start, state.End do for i = state.Start, state.End do
local content = props.render(i)
if content == nil then
continue
end
items["Item" .. i] = e("Frame", { items["Item" .. i] = e("Frame", {
LayoutOrder = i, LayoutOrder = i,
Size = props.getHeightBinding(i):map(function(height) Size = props.getHeightBinding(i):map(function(height)
return UDim2.new(1, 0, 0, height) return UDim2.new(1, 0, 0, height)
end), end),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, props.render(i)) }, content)
end end
return Theme.with(function(theme) return Theme.with(function(theme)
@@ -119,8 +131,8 @@ function VirtualScroller:render()
Position = props.position, Position = props.position,
AnchorPoint = props.anchorPoint, AnchorPoint = props.anchorPoint,
BackgroundTransparency = props.backgroundTransparency or 1, BackgroundTransparency = props.backgroundTransparency or 1,
BackgroundColor3 = props.backgroundColor3, BackgroundColor3 = props.backgroundColor3 or theme.BorderedContainer.BackgroundColor,
BorderColor3 = props.borderColor3, BorderColor3 = props.borderColor3 or theme.BorderedContainer.BorderColor,
CanvasSize = self.totalCanvas:map(function(s) CanvasSize = self.totalCanvas:map(function(s)
return UDim2.fromOffset(0, s) return UDim2.fromOffset(0, s)
end), end),

View File

@@ -7,6 +7,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper) local Flipper = require(Packages.Flipper)
local Log = require(Packages.Log)
local bindingUtil = require(script.Parent.bindingUtil) local bindingUtil = require(script.Parent.bindingUtil)
@@ -14,6 +15,7 @@ local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton)
local baseClock = DateTime.now().UnixTimestampMillis local baseClock = DateTime.now().UnixTimestampMillis
@@ -28,37 +30,31 @@ function Notification:init()
self.lifetime = self.props.timeout self.lifetime = self.props.timeout
self.motor:onStep(function(value) self.motor:onStep(function(value)
if value <= 0 then if value <= 0 and self.props.onClose then
if self.props.onClose then self.props.onClose()
self.props.onClose()
end
end end
end) end)
end end
function Notification:dismiss() function Notification:dismiss()
self.motor:setGoal( self.motor:setGoal(Flipper.Spring.new(0, {
Flipper.Spring.new(0, { frequency = 5,
frequency = 5, dampingRatio = 1,
dampingRatio = 1, }))
})
)
end end
function Notification:didMount() function Notification:didMount()
self.motor:setGoal( self.motor:setGoal(Flipper.Spring.new(1, {
Flipper.Spring.new(1, { frequency = 3,
frequency = 3, dampingRatio = 1,
dampingRatio = 1, }))
})
)
self.props.soundPlayer:play(Assets.Sounds.Notification) self.props.soundPlayer:play(Assets.Sounds.Notification)
self.timeout = task.spawn(function() self.timeout = task.spawn(function()
local clock = os.clock() local clock = os.clock()
local seen = false local seen = false
while task.wait(1/10) do while task.wait(1 / 10) do
local now = os.clock() local now = os.clock()
local dt = now - clock local dt = now - clock
clock = now clock = now
@@ -86,23 +82,51 @@ function Notification:willUnmount()
end end
function Notification:render() function Notification:render()
local time = DateTime.fromUnixTimestampMillis(self.props.timestamp)
local textBounds = TextService:GetTextSize(
self.props.text,
15,
Enum.Font.GothamSemibold,
Vector2.new(350, 700)
)
local transparency = self.binding:map(function(value) local transparency = self.binding:map(function(value)
return 1 - value return 1 - value
end) end)
local textBounds = TextService:GetTextSize(self.props.text, 15, Enum.Font.GothamMedium, Vector2.new(350, 700))
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 += TextService:GetTextSize(
action.text,
18,
Enum.Font.GothamMedium,
Vector2.new(math.huge, math.huge)
).X + 30
count += 1
end
buttonsX += (count - 1) * 5
end
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) local size = self.binding:map(function(value)
return UDim2.fromOffset( return UDim2.fromOffset(
(35+40+textBounds.X)*value, (35 + 40 + contentX) * value,
math.max(14+20+textBounds.Y, 32+20) 5 + actionsY + paddingY + math.max(logoSize, textBounds.Y)
) )
end) end)
@@ -122,22 +146,22 @@ function Notification:render()
transparency = transparency, transparency = transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
}, { }, {
TextContainer = e("Frame", { Contents = e("Frame", {
Size = UDim2.new(0, 35+textBounds.X, 1, -20), Size = UDim2.new(0, 35 + contentX, 1, -paddingY),
Position = UDim2.new(0, 0, 0, 10), Position = UDim2.new(0, 0, 0, paddingY / 2),
BackgroundTransparency = 1 BackgroundTransparency = 1,
}, { }, {
Logo = e("ImageLabel", { Logo = e("ImageLabel", {
ImageTransparency = transparency, ImageTransparency = transparency,
Image = Assets.Images.PluginButton, Image = Assets.Images.PluginButton,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(0, 32, 0, 32), Size = UDim2.new(0, logoSize, 0, logoSize),
Position = UDim2.new(0, 0, 0.5, 0), Position = UDim2.new(0, 0, 0, 0),
AnchorPoint = Vector2.new(0, 0.5), AnchorPoint = Vector2.new(0, 0),
}), }),
Info = e("TextLabel", { Info = e("TextLabel", {
Text = self.props.text, Text = self.props.text,
Font = Enum.Font.GothamSemibold, Font = Enum.Font.GothamMedium,
TextSize = 15, TextSize = 15,
TextColor3 = theme.Notification.InfoColor, TextColor3 = theme.Notification.InfoColor,
TextTransparency = transparency, TextTransparency = transparency,
@@ -150,27 +174,30 @@ function Notification:render()
LayoutOrder = 1, LayoutOrder = 1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
Time = e("TextLabel", { Actions = if self.props.actions
Text = time:FormatLocalTime("LTS", "en-us"), then e("Frame", {
Font = Enum.Font.Code, Size = UDim2.new(1, -40, 0, 35),
TextSize = 12, Position = UDim2.new(1, 0, 1, 0),
TextColor3 = theme.Notification.InfoColor, AnchorPoint = Vector2.new(1, 1),
TextTransparency = transparency, BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left, }, {
Layout = e("UIListLayout", {
Size = UDim2.new(1, -35, 0, 14), FillDirection = Enum.FillDirection.Horizontal,
Position = UDim2.new(0, 35, 1, -14), HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Center,
LayoutOrder = 1, SortOrder = Enum.SortOrder.LayoutOrder,
BackgroundTransparency = 1, Padding = UDim.new(0, 5),
}), }),
Buttons = Roact.createFragment(actionButtons),
})
else nil,
}), }),
Padding = e("UIPadding", { Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 17), PaddingLeft = UDim.new(0, 17),
PaddingRight = UDim.new(0, 15), PaddingRight = UDim.new(0, 15),
}), }),
}) }),
}) })
end) end)
end end
@@ -180,15 +207,16 @@ local Notifications = Roact.Component:extend("Notifications")
function Notifications:render() function Notifications:render()
local notifs = {} local notifs = {}
for index, notif in ipairs(self.props.notifications) do for id, notif in self.props.notifications do
notifs[notif] = e(Notification, { notifs["NotifID_" .. id] = e(Notification, {
soundPlayer = self.props.soundPlayer, soundPlayer = self.props.soundPlayer,
text = notif.text, text = notif.text,
timestamp = notif.timestamp, timestamp = notif.timestamp,
timeout = notif.timeout, timeout = notif.timeout,
actions = notif.actions,
layoutOrder = (notif.timestamp - baseClock), layoutOrder = (notif.timestamp - baseClock),
onClose = function() onClose = function()
self.props.onClose(index) self.props.onClose(id)
end, end,
}) })
end end

View File

@@ -15,7 +15,7 @@ local Page = Roact.Component:extend("Page")
function Page:init() function Page:init()
self:setState({ self:setState({
rendered = self.props.active rendered = self.props.active,
}) })
self.motor = Flipper.SingleMotor.new(self.props.active and 1 or 0) self.motor = Flipper.SingleMotor.new(self.props.active and 1 or 0)
@@ -51,20 +51,21 @@ function Page:render()
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Component = e(self.props.component, Dictionary.merge(self.props, { Component = e(
transparency = transparency, self.props.component,
})) Dictionary.merge(self.props, {
transparency = transparency,
})
),
}) })
end end
function Page:didUpdate(lastProps) function Page:didUpdate(lastProps)
if self.props.active ~= lastProps.active then if self.props.active ~= lastProps.active then
self.motor:setGoal( self.motor:setGoal(Flipper.Spring.new(self.props.active and 1 or 0, {
Flipper.Spring.new(self.props.active and 1 or 0, { frequency = 6,
frequency = 6, dampingRatio = 1,
dampingRatio = 1, }))
})
)
end end
end end

View File

@@ -1,18 +1,19 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local PatchTree = require(Plugin.PatchTree)
local Settings = require(Plugin.Settings) local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer)
local e = Roact.createElement local e = Roact.createElement
@@ -21,26 +22,52 @@ local ConfirmingPage = Roact.Component:extend("ConfirmingPage")
function ConfirmingPage:init() function ConfirmingPage:init()
self.contentSize, self.setContentSize = Roact.createBinding(0) self.contentSize, self.setContentSize = Roact.createBinding(0)
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({
patchTree = nil,
showingStringDiff = false,
oldString = "",
newString = "",
showingTableDiff = false,
oldTable = {},
newTable = {},
})
if self.props.confirmData and self.props.confirmData.patch and self.props.confirmData.instanceMap then
self:buildPatchTree()
end
end
function ConfirmingPage:didUpdate(prevProps)
if prevProps.confirmData ~= self.props.confirmData then
self:buildPatchTree()
end
end
function ConfirmingPage:buildPatchTree()
Timer.start("ConfirmingPage:buildPatchTree")
self:setState({
patchTree = PatchTree.build(
self.props.confirmData.patch,
self.props.confirmData.instanceMap,
{ "Property", "Current", "Incoming" }
),
})
Timer.stop()
end end
function ConfirmingPage:render() function ConfirmingPage:render()
return Theme.with(function(theme) return Theme.with(function(theme)
local pageContent = Roact.createFragment({ local pageContent = Roact.createFragment({
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
}),
Title = e("TextLabel", { Title = e("TextLabel", {
Text = string.format( Text = string.format(
"Sync changes for project '%s':", "Sync changes for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN" self.props.confirmData.serverInfo.projectName or "UNKNOWN"
), ),
LayoutOrder = 2,
Font = Enum.Font.Gotham, Font = Enum.Font.Gotham,
LineHeight = 1.2, LineHeight = 1.2,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 20), Size = UDim2.new(1, 0, 0, 20),
@@ -48,13 +75,26 @@ function ConfirmingPage:render()
}), }),
PatchVisualizer = e(PatchVisualizer, { PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, -150), size = UDim2.new(1, 0, 1, -100),
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 3,
columnVisibility = {true, true, true}, patchTree = self.state.patchTree,
patch = self.props.confirmData.patch,
instanceMap = self.props.confirmData.instanceMap, showStringDiff = function(oldString: string, newString: string)
self:setState({
showingStringDiff = true,
oldString = oldString,
newString = newString,
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
self:setState({
showingTableDiff = true,
oldTable = oldTable,
newTable = newTable,
})
end,
}), }),
Buttons = e("Frame", { Buttons = e("Frame", {
@@ -70,7 +110,7 @@ function ConfirmingPage:render()
onClick = self.props.onAbort, onClick = self.props.onAbort,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "Stop the connection process" text = "Stop the connection process",
}), }),
}), }),
@@ -83,7 +123,7 @@ function ConfirmingPage:render()
onClick = self.props.onReject, onClick = self.props.onReject,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "Push Studio changes to the Rojo server" text = "Push Studio changes to the Rojo server",
}), }),
}) })
else nil, else nil,
@@ -96,7 +136,7 @@ function ConfirmingPage:render()
onClick = self.props.onAccept, onClick = self.props.onAccept,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "Pull Rojo server changes to Studio" text = "Pull Rojo server changes to Studio",
}), }),
}), }),
@@ -108,6 +148,11 @@ function ConfirmingPage:render()
}), }),
}), }),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 8),
PaddingRight = UDim.new(0, 8),
}),
Layout = e("UIListLayout", { Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center, HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center,
@@ -116,9 +161,80 @@ function ConfirmingPage:render()
Padding = UDim.new(0, 10), Padding = UDim.new(0, 10),
}), }),
Padding = e("UIPadding", { StringDiff = e(StudioPluginGui, {
PaddingLeft = UDim.new(0, 20), id = "Rojo_ConfirmingStringDiff",
PaddingRight = UDim.new(0, 20), title = "String diff",
active = self.state.showingStringDiff,
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({
showingStringDiff = false,
})
end,
}, {
TooltipsProvider = e(Tooltip.Provider, nil, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
e(StringDiffVisualizer, {
size = UDim2.new(1, -10, 1, -10),
position = UDim2.new(0, 5, 0, 5),
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldString = self.state.oldString,
newString = self.state.newString,
}),
}),
}),
}),
TableDiff = e(StudioPluginGui, {
id = "Rojo_ConfirmingTableDiff",
title = "Table diff",
active = self.state.showingTableDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({
showingTableDiff = false,
})
end,
}, {
TooltipsProvider = e(Tooltip.Provider, nil, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
e(TableDiffVisualizer, {
size = UDim2.new(1, -10, 1, -10),
position = UDim2.new(0, 5, 0, 5),
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldTable = self.state.oldTable,
newTable = self.state.newTable,
}),
}),
}),
}), }),
}) })
@@ -130,10 +246,10 @@ function ConfirmingPage:render()
self.props.confirmData.serverInfo.projectName or "UNKNOWN" self.props.confirmData.serverInfo.projectName or "UNKNOWN"
), ),
active = true, active = true,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float, initDockState = Enum.InitialDockState.Float,
initEnabled = true, overridePreviousState = false,
overridePreviousState = true,
floatingSize = Vector2.new(500, 350), floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250), minimumSize = Vector2.new(400, 250),

View File

@@ -3,80 +3,202 @@ local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local bindingUtil = require(Plugin.App.bindingUtil) local timeUtil = require(Plugin.timeUtil)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local PatchSet = require(Plugin.PatchSet) local PatchSet = require(Plugin.PatchSet)
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Header = require(Plugin.App.Components.Header) local Header = require(Plugin.App.Components.Header)
local IconButton = require(Plugin.App.Components.IconButton) local IconButton = require(Plugin.App.Components.IconButton)
local TextButton = require(Plugin.App.Components.TextButton)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer)
local e = Roact.createElement local e = Roact.createElement
local AGE_UNITS = { {31556909, "year"}, {2629743, "month"}, {604800, "week"}, {86400, "day"}, {3600, "hour"}, {60, "minute"}, } local ChangesViewer = Roact.Component:extend("ChangesViewer")
function timeSinceText(elapsed: number): string
if elapsed < 3 then
return "just now"
end
local ageText = string.format("%d seconds ago", elapsed) function ChangesViewer:init()
-- Hold onto the serve session during the lifecycle of this component
for _,UnitData in ipairs(AGE_UNITS) do -- so that it can still render during the fade out after disconnecting
local UnitSeconds, UnitName = UnitData[1], UnitData[2] self.serveSession = self.props.serveSession
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 end
local function ChangesDrawer(props) function ChangesViewer:render()
if props.rendered == false then if self.props.rendered == false or self.serveSession == nil or self.props.patchData == nil then
return nil return nil
end end
local unapplied = PatchSet.countChanges(self.props.patchData.unapplied)
local applied = PatchSet.countChanges(self.props.patchData.patch) - unapplied
return Theme.with(function(theme) return Theme.with(function(theme)
return e(BorderedContainer, { return Roact.createFragment({
transparency = props.transparency, Navbar = e("Frame", {
size = props.height:map(function(y) Size = UDim2.new(1, 0, 0, 40),
return UDim2.new(1, 0, y, -180 * y) BackgroundTransparency = 1,
end),
position = UDim2.new(0, 0, 1, 0),
anchorPoint = Vector2.new(0, 1),
layoutOrder = props.layoutOrder,
}, {
Close = e(IconButton, {
icon = Assets.Images.Icons.Close,
iconSize = 24,
color = theme.ConnectionDetails.DisconnectColor,
transparency = props.transparency,
position = UDim2.new(1, 0, 0, 0),
anchorPoint = Vector2.new(1, 0),
onClick = props.onClose,
}, { }, {
Tip = e(Tooltip.Trigger, { Close = e(IconButton, {
text = "Close the patch visualizer" icon = Assets.Images.Icons.Close,
iconSize = 24,
color = theme.Settings.Navbar.BackButtonColor,
transparency = self.props.transparency,
position = UDim2.new(0, 0, 0.5, 0),
anchorPoint = Vector2.new(0, 0.5),
onClick = self.props.onBack,
}, {
Tip = e(Tooltip.Trigger, {
text = "Close",
}),
}),
Title = e("TextLabel", {
Text = "Sync",
Font = Enum.Font.GothamMedium,
TextSize = 17,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = theme.TextColor,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, -40, 0, 20),
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,
Font = Enum.Font.Gotham,
TextSize = 15,
TextColor3 = theme.SubTextColor,
TextTruncate = Enum.TextTruncate.AtEnd,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, -40, 0, 16),
Position = UDim2.new(0, 40, 0, 20),
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,
Font = Enum.Font.Gotham,
TextSize = 15,
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,
Font = Enum.Font.Gotham,
TextSize = 15,
TextColor3 = theme.Diff.Warning,
TextTransparency = self.props.transparency,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1,
LayoutOrder = 5,
}),
})
else nil,
}),
}),
Divider = e("Frame", {
BackgroundColor3 = theme.Settings.DividerColor,
BackgroundTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, 0),
BorderSizePixel = 0,
}, {
Gradient = e("UIGradient", {
Transparency = NumberSequence.new({
NumberSequenceKeypoint.new(0, 1),
NumberSequenceKeypoint.new(0.1, 0),
NumberSequenceKeypoint.new(0.9, 0),
NumberSequenceKeypoint.new(1, 1),
}),
}),
}), }),
}), }),
PatchVisualizer = e(PatchVisualizer, { Patch = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, -10, 1, -65),
transparency = props.transparency, position = UDim2.new(0, 5, 1, -5),
layoutOrder = 3, anchorPoint = Vector2.new(0, 1),
transparency = self.props.transparency,
layoutOrder = self.props.layoutOrder,
columnVisibility = {true, false, true}, patchTree = self.props.patchTree,
patch = props.patchInfo:getValue().patch,
instanceMap = props.serveSession.__instanceMap, showStringDiff = self.props.showStringDiff,
showTableDiff = self.props.showTableDiff,
}), }),
}) })
end) end)
@@ -91,7 +213,7 @@ local function ConnectionDetails(props)
}, { }, {
TextContainer = e("Frame", { TextContainer = e("Frame", {
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1 BackgroundTransparency = 1,
}, { }, {
ProjectName = e("TextLabel", { ProjectName = e("TextLabel", {
Text = props.projectName, Text = props.projectName,
@@ -129,22 +251,6 @@ local function ConnectionDetails(props)
}), }),
}), }),
Disconnect = e(IconButton, {
icon = Assets.Images.Icons.Close,
iconSize = 24,
color = theme.ConnectionDetails.DisconnectColor,
transparency = props.transparency,
position = UDim2.new(1, 0, 0.5, 0),
anchorPoint = Vector2.new(1, 0.5),
onClick = props.onDisconnect,
}, {
Tip = e(Tooltip.Trigger, {
text = "Disconnect from the Rojo sync server"
}),
}),
Padding = e("UIPadding", { Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 17), PaddingLeft = UDim.new(0, 17),
PaddingRight = UDim.new(0, 15), PaddingRight = UDim.new(0, 15),
@@ -155,30 +261,80 @@ end
local ConnectedPage = Roact.Component:extend("ConnectedPage") local ConnectedPage = Roact.Component:extend("ConnectedPage")
function ConnectedPage:init() function ConnectedPage:getChangeInfoText()
self.changeDrawerMotor = Flipper.SingleMotor.new(0) local patchData = self.props.patchData
self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor) if patchData == nil then
return ""
end
return timeUtil.elapsedToText(DateTime.now().UnixTimestamp - patchData.timestamp)
end
self.changeDrawerMotor:onStep(function(value) function ConnectedPage:startChangeInfoTextUpdater()
local renderChanges = value > 0.05 -- Cancel any existing updater
self:stopChangeInfoTextUpdater()
self:setState(function(state) -- Start a new updater
if state.renderChanges == renderChanges then self.changeInfoTextUpdater = task.defer(function()
return nil while true do
self.setChangeInfoText(self:getChangeInfoText())
local elapsed = DateTime.now().UnixTimestamp - self.props.patchData.timestamp
local updateInterval = 1
-- Update timestamp text as frequently as currently needed
for _, UnitData in ipairs(timeUtil.AGE_UNITS) do
local UnitSeconds = UnitData[1]
if elapsed > UnitSeconds then
updateInterval = UnitSeconds
break
end
end end
return { task.wait(updateInterval)
renderChanges = renderChanges, end
}
end)
end) end)
end
function ConnectedPage:stopChangeInfoTextUpdater()
if self.changeInfoTextUpdater then
task.cancel(self.changeInfoTextUpdater)
self.changeInfoTextUpdater = nil
end
end
function ConnectedPage:init()
self:setState({ self:setState({
renderChanges = false, renderChanges = false,
hoveringChangeInfo = false,
showingStringDiff = false,
oldString = "",
newString = "",
}) })
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
self:startChangeInfoTextUpdater()
end
function ConnectedPage:willUnmount()
self:stopChangeInfoTextUpdater()
end
function ConnectedPage:didUpdate(previousProps)
if self.props.patchData.timestamp ~= previousProps.patchData.timestamp then
-- New patch recieved
self:startChangeInfoTextUpdater()
self:setState({
showingStringDiff = false,
})
end
end end
function ConnectedPage:render() function ConnectedPage:render()
local syncWarning = self.props.patchData
and self.props.patchData.unapplied
and PatchSet.countChanges(self.props.patchData.unapplied) > 0
return Theme.with(function(theme) return Theme.with(function(theme)
return Roact.createFragment({ return Roact.createFragment({
Padding = e("UIPadding", { Padding = e("UIPadding", {
@@ -193,9 +349,88 @@ function ConnectedPage:render()
Padding = UDim.new(0, 10), Padding = UDim.new(0, 10),
}), }),
Header = e(Header, { Heading = e("Frame", {
transparency = self.props.transparency, BackgroundTransparency = 1,
layoutOrder = 1, Size = UDim2.new(1, 0, 0, 32),
}, {
Header = e(Header, {
transparency = self.props.transparency,
}),
ChangeInfo = e("TextButton", {
Text = "",
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = theme.BorderedContainer.BorderedColor,
BackgroundTransparency = if self.state.hoveringChangeInfo then 0.7 else 1,
BorderSizePixel = 0,
Position = UDim2.new(1, -5, 0.5, 0),
AnchorPoint = Vector2.new(1, 0.5),
[Roact.Event.MouseEnter] = function()
self:setState({
hoveringChangeInfo = true,
})
end,
[Roact.Event.MouseLeave] = function()
self:setState({
hoveringChangeInfo = false,
})
end,
[Roact.Event.Activated] = function()
self:setState(function(prevState)
prevState = prevState or {}
return {
renderChanges = not prevState.renderChanges,
}
end)
end,
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 5),
}),
Tooltip = e(Tooltip.Trigger, {
text = if self.state.renderChanges then "Hide changes" else "View changes",
}),
Content = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 5),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
Text = e("TextLabel", {
BackgroundTransparency = 1,
Text = self.changeInfoText,
Font = Enum.Font.Gotham,
TextSize = 15,
TextColor3 = if syncWarning then theme.Diff.Warning else theme.Header.VersionColor,
TextTransparency = self.props.transparency,
TextXAlignment = Enum.TextXAlignment.Right,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
LayoutOrder = 1,
}),
Icon = e("ImageLabel", {
BackgroundTransparency = 1,
Image = if syncWarning
then Assets.Images.Icons.SyncWarning
else Assets.Images.Icons.SyncSuccess,
ImageColor3 = if syncWarning then theme.Diff.Warning else theme.Header.VersionColor,
ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 24, 0, 24),
LayoutOrder = 2,
}),
}),
}),
}), }),
ConnectionDetails = e(ConnectionDetails, { ConnectionDetails = e(ConnectionDetails, {
@@ -207,59 +442,173 @@ function ConnectedPage:render()
onDisconnect = self.props.onDisconnect, onDisconnect = self.props.onDisconnect,
}), }),
ChangeInfo = e("TextButton", { Buttons = e("Frame", {
Text = self.props.patchInfo:map(function(info) Size = UDim2.new(1, 0, 0, 34),
local changes = PatchSet.countChanges(info.patch)
return string.format(
"<i>Synced %d change%s %s</i>",
changes,
changes == 1 and "" or "s",
timeSinceText(os.time() - info.timestamp)
)
end),
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,
Size = UDim2.new(1, 0, 0, 28),
LayoutOrder = 3, LayoutOrder = 3,
BackgroundTransparency = 1, BackgroundTransparency = 1,
ZIndex = 2,
}, {
Settings = e(TextButton, {
text = "Settings",
style = "Bordered",
transparency = self.props.transparency,
layoutOrder = 1,
onClick = self.props.onNavigateSettings,
}, {
Tip = e(Tooltip.Trigger, {
text = "View and modify plugin settings",
}),
}),
[Roact.Event.Activated] = function() Disconnect = e(TextButton, {
if self.state.renderChanges then text = "Disconnect",
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { style = "Solid",
frequency = 4, transparency = self.props.transparency,
dampingRatio = 1, layoutOrder = 2,
})) onClick = self.props.onDisconnect,
else }, {
self.changeDrawerMotor:setGoal(Flipper.Spring.new(1, { Tip = e(Tooltip.Trigger, {
frequency = 3, text = "Disconnect from the Rojo sync server",
dampingRatio = 1, }),
})) }),
end
end, Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Right,
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
}), }),
ChangesDrawer = e(ChangesDrawer, { ChangesViewer = e(StudioPluginGui, {
rendered = self.state.renderChanges, id = "Rojo_ChangesViewer",
transparency = self.props.transparency, title = "View changes",
patchInfo = self.props.patchInfo, active = self.state.renderChanges,
serveSession = self.props.serveSession, isEphemeral = true,
height = self.changeDrawerHeight,
layoutOrder = 4, initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(400, 500),
minimumSize = Vector2.new(300, 300),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function() onClose = function()
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, { self:setState({
frequency = 4, renderChanges = false,
dampingRatio = 1, })
}))
end, end,
}, {
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(oldString: string, newString: string)
self:setState({
showingStringDiff = true,
oldString = oldString,
newString = newString,
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
self:setState({
showingTableDiff = true,
oldTable = oldTable,
newTable = newTable,
})
end,
onBack = function()
self:setState({
renderChanges = false,
})
end,
}),
}),
}),
}),
StringDiff = e(StudioPluginGui, {
id = "Rojo_ConnectedStringDiff",
title = "String diff",
active = self.state.showingStringDiff,
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({
showingStringDiff = false,
})
end,
}, {
TooltipsProvider = e(Tooltip.Provider, nil, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
e(StringDiffVisualizer, {
size = UDim2.new(1, -10, 1, -10),
position = UDim2.new(0, 5, 0, 5),
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldString = self.state.oldString,
newString = self.state.newString,
}),
}),
}),
}),
TableDiff = e(StudioPluginGui, {
id = "Rojo_ConnectedTableDiff",
title = "Table diff",
active = self.state.showingTableDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = false,
floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({
showingTableDiff = false,
})
end,
}, {
TooltipsProvider = e(Tooltip.Provider, nil, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
e(TableDiffVisualizer, {
size = UDim2.new(1, -10, 1, -10),
position = UDim2.new(0, 5, 0, 5),
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldTable = self.state.oldTable,
newTable = self.state.newTable,
}),
}),
}),
}), }),
}) })
end) end)

View File

@@ -50,7 +50,9 @@ function Error:render()
local containerSize = object.AbsoluteSize - ERROR_PADDING * 2 local containerSize = object.AbsoluteSize - ERROR_PADDING * 2
local textBounds = TextService:GetTextSize( local textBounds = TextService:GetTextSize(
self.props.errorMessage, 16, Enum.Font.Code, self.props.errorMessage,
16,
Enum.Font.Code,
Vector2.new(containerSize.X, math.huge) Vector2.new(containerSize.X, math.huge)
) )
@@ -60,12 +62,13 @@ function Error:render()
ErrorMessage = Theme.with(function(theme) ErrorMessage = Theme.with(function(theme)
return e("TextBox", { return e("TextBox", {
[Roact.Event.InputBegan] = function(rbx, input) [Roact.Event.InputBegan] = function(rbx, input)
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
return
end
rbx.SelectionStart = 0 rbx.SelectionStart = 0
rbx.CursorPosition = #rbx.Text+1 rbx.CursorPosition = #rbx.Text + 1
end, end,
Text = self.props.errorMessage, Text = self.props.errorMessage,
TextEditable = false, TextEditable = false,
Font = Enum.Font.Code, Font = Enum.Font.Code,
@@ -126,7 +129,7 @@ function ErrorPage:render()
onClick = self.props.onClose, onClick = self.props.onClose,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "Dismiss message" text = "Dismiss message",
}), }),
}), }),

View File

@@ -46,7 +46,7 @@ local function AddressEntry(props)
if props.onHostChange ~= nil then if props.onHostChange ~= nil then
props.onHostChange(object.Text) props.onHostChange(object.Text)
end end
end end,
}), }),
Port = e("TextBox", { Port = e("TextBox", {
@@ -120,7 +120,7 @@ function NotConnectedPage:render()
onClick = self.props.onNavigateSettings, onClick = self.props.onNavigateSettings,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "View and modify plugin settings" text = "View and modify plugin settings",
}), }),
}), }),
@@ -132,7 +132,7 @@ function NotConnectedPage:render()
onClick = self.props.onConnect, onClick = self.props.onConnect,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "Connect to a Rojo sync server" text = "Connect to a Rojo sync server",
}), }),
}), }),

View File

@@ -13,10 +13,23 @@ local Theme = require(Plugin.App.Theme)
local Checkbox = require(Plugin.App.Components.Checkbox) local Checkbox = require(Plugin.App.Components.Checkbox)
local Dropdown = require(Plugin.App.Components.Dropdown) local Dropdown = require(Plugin.App.Components.Dropdown)
local IconButton = require(Plugin.App.Components.IconButton) local IconButton = require(Plugin.App.Components.IconButton)
local Tag = require(Plugin.App.Components.Tag)
local e = Roact.createElement local e = Roact.createElement
local DIVIDER_FADE_SIZE = 0.1 local DIVIDER_FADE_SIZE = 0.1
local TAG_TYPES = {
unstable = {
text = "UNSTABLE",
icon = Assets.Images.Icons.Warning,
color = { "Settings", "Setting", "UnstableColor" },
},
debug = {
text = "DEBUG",
icon = Assets.Images.Icons.Debug,
color = { "Settings", "Setting", "DebugColor" },
},
}
local function getTextBounds(text, textSize, font, lineHeight, bounds) local function getTextBounds(text, textSize, font, lineHeight, bounds)
local textBounds = TextService:GetTextSize(text, textSize, font, bounds) local textBounds = TextService:GetTextSize(text, textSize, font, bounds)
@@ -27,11 +40,23 @@ local function getTextBounds(text, textSize, font, lineHeight, bounds)
return Vector2.new(textBounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize)) return Vector2.new(textBounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize))
end end
local function getThemeColorFromPath(theme, path)
local color = theme
for _, key in path do
if color[key] == nil then
return theme.BrandColor
end
color = color[key]
end
return color
end
local Setting = Roact.Component:extend("Setting") local Setting = Roact.Component:extend("Setting")
function Setting:init() function Setting:init()
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0)) self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self.inputSize, self.setInputSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({ self:setState({
setting = Settings:get(self.props.id), setting = Settings:get(self.props.id),
@@ -50,72 +75,110 @@ end
function Setting:render() function Setting:render()
return Theme.with(function(theme) return Theme.with(function(theme)
theme = theme.Settings local settingsTheme = theme.Settings
return e("Frame", { return e("Frame", {
Size = self.contentSize:map(function(value) Size = self.contentSize:map(function(value)
return UDim2.new(1, 0, 0, 20 + value.Y + 20) return UDim2.new(1, 0, 0, value.Y + 20)
end), end),
LayoutOrder = self.props.layoutOrder, LayoutOrder = self.props.layoutOrder,
ZIndex = -self.props.layoutOrder, ZIndex = -self.props.layoutOrder,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Visible = self.props.visible,
[Roact.Change.AbsoluteSize] = function(object) [Roact.Change.AbsoluteSize] = function(object)
self.setContainerSize(object.AbsoluteSize) self.setContainerSize(object.AbsoluteSize)
end, end,
}, { }, {
Input = if self.props.options ~= nil then RightAligned = Roact.createElement("Frame", {
e(Dropdown, { BackgroundTransparency = 1,
options = self.props.options, Size = UDim2.new(1, 0, 1, 0),
active = self.state.setting, }, {
transparency = self.props.transparency, Layout = e("UIListLayout", {
position = UDim2.new(1, 0, 0.5, 0), VerticalAlignment = Enum.VerticalAlignment.Center,
anchorPoint = Vector2.new(1, 0.5), HorizontalAlignment = Enum.HorizontalAlignment.Right,
onClick = function(option) FillDirection = Enum.FillDirection.Horizontal,
Settings:set(self.props.id, option) SortOrder = Enum.SortOrder.LayoutOrder,
end, Padding = UDim.new(0, 2),
}) [Roact.Change.AbsoluteContentSize] = function(rbx)
else self.setInputSize(rbx.AbsoluteContentSize)
e(Checkbox, {
active = self.state.setting,
transparency = self.props.transparency,
position = UDim2.new(1, 0, 0.5, 0),
anchorPoint = Vector2.new(1, 0.5),
onClick = function()
local currentValue = Settings:get(self.props.id)
Settings:set(self.props.id, not currentValue)
end, end,
}), }),
Reset = if self.props.onReset then e(IconButton, { Input = if self.props.input ~= nil
icon = Assets.Images.Icons.Reset, then self.props.input
iconSize = 24, elseif self.props.options ~= nil then e(Dropdown, {
color = theme.BackButtonColor, locked = self.props.locked,
transparency = self.props.transparency, options = self.props.options,
visible = self.props.showReset, active = self.state.setting,
transparency = self.props.transparency,
onClick = function(option)
Settings:set(self.props.id, option)
end,
})
else e(Checkbox, {
locked = self.props.locked,
active = self.state.setting,
transparency = self.props.transparency,
onClick = function()
local currentValue = Settings:get(self.props.id)
Settings:set(self.props.id, not currentValue)
end,
}),
position = UDim2.new(1, -32 - (self.props.options ~= nil and 120 or 40), 0.5, 0), Reset = if self.props.onReset
anchorPoint = Vector2.new(0, 0.5), then e(IconButton, {
icon = Assets.Images.Icons.Reset,
iconSize = 24,
color = settingsTheme.BackButtonColor,
transparency = self.props.transparency,
visible = self.props.showReset,
layoutOrder = -1,
onClick = self.props.onReset, onClick = self.props.onReset,
}) else nil, })
else nil,
}),
Text = e("Frame", { Text = e("Frame", {
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Name = e("TextLabel", { Heading = e("Frame", {
Text = self.props.name, Size = UDim2.new(1, 0, 0, 16),
Font = Enum.Font.GothamBold,
TextSize = 17,
TextColor3 = theme.Setting.NameColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 17),
LayoutOrder = 1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, {
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 5),
}),
Tag = if self.props.tag and TAG_TYPES[self.props.tag]
then e(Tag, {
layoutOrder = 1,
transparency = self.props.transparency,
text = TAG_TYPES[self.props.tag].text,
icon = TAG_TYPES[self.props.tag].icon,
color = getThemeColorFromPath(theme, TAG_TYPES[self.props.tag].color),
})
else nil,
Name = e("TextLabel", {
Text = self.props.name,
Font = Enum.Font.GothamBold,
TextSize = 16,
TextColor3 = if self.props.tag and TAG_TYPES[self.props.tag]
then getThemeColorFromPath(theme, TAG_TYPES[self.props.tag].color)
else settingsTheme.Setting.NameColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
RichText = true,
Size = UDim2.new(1, 0, 0, 16),
LayoutOrder = 2,
BackgroundTransparency = 1,
}),
}), }),
Description = e("TextLabel", { Description = e("TextLabel", {
@@ -123,21 +186,28 @@ function Setting:render()
Font = Enum.Font.Gotham, Font = Enum.Font.Gotham,
LineHeight = 1.2, LineHeight = 1.2,
TextSize = 14, TextSize = 14,
TextColor3 = theme.Setting.DescriptionColor, TextColor3 = settingsTheme.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
TextWrapped = true, TextWrapped = true,
RichText = true,
Size = self.containerSize:map(function(value) Size = Roact.joinBindings({
local offset = (self.props.onReset and 34 or 0) + (self.props.options ~= nil and 120 or 40) containerSize = self.containerSize,
inputSize = self.inputSize,
}):map(function(values)
local offset = values.inputSize.X + 5
local textBounds = getTextBounds( local textBounds = getTextBounds(
self.props.description, 14, Enum.Font.Gotham, 1.2, self.props.description,
Vector2.new(value.X - offset, math.huge) 14,
Enum.Font.Gotham,
1.2,
Vector2.new(values.containerSize.X - offset, math.huge)
) )
return UDim2.new(1, -offset, 0, textBounds.Y) return UDim2.new(1, -offset, 0, textBounds.Y)
end), end),
LayoutOrder = 2, LayoutOrder = 3,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
@@ -145,21 +215,16 @@ function Setting:render()
VerticalAlignment = Enum.VerticalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical, FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder, SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 6), Padding = UDim.new(0, 5),
[Roact.Change.AbsoluteContentSize] = function(object) [Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize) self.setContentSize(object.AbsoluteContentSize)
end, end,
}), }),
Padding = e("UIPadding", {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 20),
}),
}), }),
Divider = e("Frame", { Divider = e("Frame", {
BackgroundColor3 = theme.DividerColor, BackgroundColor3 = settingsTheme.DividerColor,
BackgroundTransparency = self.props.transparency, BackgroundTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 1), Size = UDim2.new(1, 0, 0, 1),
BorderSizePixel = 0, BorderSizePixel = 0,

View File

@@ -12,6 +12,7 @@ local Theme = require(Plugin.App.Theme)
local IconButton = require(Plugin.App.Components.IconButton) local IconButton = require(Plugin.App.Components.IconButton)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
local TextInput = require(Plugin.App.Components.TextInput)
local Setting = require(script.Setting) local Setting = require(script.Setting)
local e = Roact.createElement local e = Roact.createElement
@@ -25,6 +26,7 @@ local function invertTbl(tbl)
end end
local invertedLevels = invertTbl(Log.Level) local invertedLevels = invertTbl(Log.Level)
local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" }
local function Navbar(props) local function Navbar(props)
return Theme.with(function(theme) return Theme.with(function(theme)
@@ -47,7 +49,7 @@ local function Navbar(props)
onClick = props.onBack, onClick = props.onBack,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "Back" text = "Back",
}), }),
}), }),
@@ -61,7 +63,7 @@ local function Navbar(props)
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}) }),
}) })
end) end)
end end
@@ -73,88 +75,188 @@ function SettingsPage:init()
end end
function SettingsPage:render() function SettingsPage:render()
local layoutOrder = 0
local function layoutIncrement()
layoutOrder += 1
return layoutOrder
end
return Theme.with(function(theme) return Theme.with(function(theme)
theme = theme.Settings theme = theme.Settings
return e(ScrollingFrame, { return Roact.createFragment({
size = UDim2.new(1, 0, 1, 0),
contentSize = self.contentSize,
transparency = self.props.transparency,
}, {
Navbar = e(Navbar, { Navbar = e(Navbar, {
onBack = self.props.onBack, onBack = self.props.onBack,
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 0, layoutOrder = layoutIncrement(),
}), }),
Content = e(ScrollingFrame, {
OpenScriptsExternally = e(Setting, { size = UDim2.new(1, 0, 1, -47),
id = "openScriptsExternally", position = UDim2.new(0, 0, 0, 47),
name = "Open Scripts Externally", contentSize = self.contentSize,
description = "Attempt to open scripts in an external editor",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 1, }, {
}), ShowNotifications = e(Setting, {
id = "showNotifications",
name = "Show Notifications",
description = "Popup notifications in viewport",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
ShowNotifications = e(Setting, { SyncReminder = e(Setting, {
id = "showNotifications", id = "syncReminder",
name = "Show Notifications", name = "Sync Reminder",
description = "Popup notifications in viewport", description = "Notify to sync when opening a place that has previously been synced",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 2, visible = Settings:getBinding("showNotifications"),
}), layoutOrder = layoutIncrement(),
}),
PlaySounds = e(Setting, { ConfirmationBehavior = e(Setting, {
id = "playSounds", id = "confirmationBehavior",
name = "Play Sounds", name = "Confirmation Behavior",
description = "Toggle sound effects", description = "When to prompt for confirmation before syncing",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = layoutIncrement(),
}),
TwoWaySync = e(Setting, { options = confirmationBehaviors,
id = "twoWaySync", }),
name = "Two-Way Sync",
description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem",
transparency = self.props.transparency,
layoutOrder = 4,
}),
LogLevel = e(Setting, { LargeChangesConfirmationThreshold = e(Setting, {
id = "logLevel", id = "largeChangesConfirmationThreshold",
name = "Log Level", name = "Confirmation Threshold",
description = "Plugin output verbosity level", description = "How many modified instances to be considered a large change",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 5, layoutOrder = layoutIncrement(),
visible = Settings:getBinding("confirmationBehavior"):map(function(value)
return value == "Large Changes"
end),
input = e(TextInput, {
size = UDim2.new(0, 40, 0, 28),
text = Settings:getBinding("largeChangesConfirmationThreshold"):map(function(value)
return tostring(value)
end),
transparency = self.props.transparency,
enabled = true,
onEntered = function(text)
local number = tonumber(string.match(text, "%d+"))
if number then
Settings:set("largeChangesConfirmationThreshold", math.clamp(number, 1, 999))
else
-- Force text back to last valid value
Settings:set(
"largeChangesConfirmationThreshold",
Settings:get("largeChangesConfirmationThreshold")
)
end
end,
}),
}),
options = invertedLevels, PlaySounds = e(Setting, {
showReset = Settings:getBinding("logLevel"):map(function(value) id = "playSounds",
return value ~= "Info" name = "Play Sounds",
end), description = "Toggle sound effects",
onReset = function() transparency = self.props.transparency,
Settings:set("logLevel", "Info") layoutOrder = layoutIncrement(),
end, }),
}),
TypecheckingEnabled = e(Setting, { CheckForUpdates = e(Setting, {
id = "typecheckingEnabled", id = "checkForUpdates",
name = "Typechecking", name = "Check For Updates",
description = "Toggle typechecking on the API surface", description = "Notify about newer compatible Rojo releases",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 6, layoutOrder = layoutIncrement(),
}), }),
Layout = e("UIListLayout", { CheckForPreleases = e(Setting, {
FillDirection = Enum.FillDirection.Vertical, id = "checkForPrereleases",
SortOrder = Enum.SortOrder.LayoutOrder, name = "Include Prerelease Updates",
description = "Include prereleases when checking for updates",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
visible = if string.find(debug.traceback(), "\n[^\n]-user_.-$") == nil
then false -- Must be a local install to allow prerelease checks
else Settings:getBinding("checkForUpdates"),
}),
[Roact.Change.AbsoluteContentSize] = function(object) AutoConnectPlaytestServer = e(Setting, {
self.setContentSize(object.AbsoluteContentSize) id = "autoConnectPlaytestServer",
end, name = "Auto Connect Playtest Server",
}), description = "Automatically connect game server to Rojo when playtesting while connected in Edit",
tag = "unstable",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
Padding = e("UIPadding", { OpenScriptsExternally = e(Setting, {
PaddingLeft = UDim.new(0, 20), id = "openScriptsExternally",
PaddingRight = UDim.new(0, 20), name = "Open Scripts Externally",
description = "Attempt to open scripts in an external editor",
tag = "unstable",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
TwoWaySync = e(Setting, {
id = "twoWaySync",
name = "Two-Way Sync",
description = "Editing files in Studio will sync them into the filesystem",
locked = self.props.syncActive,
tag = "unstable",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
LogLevel = e(Setting, {
id = "logLevel",
name = "Log Level",
description = "Plugin output verbosity level",
tag = "debug",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
options = invertedLevels,
showReset = Settings:getBinding("logLevel"):map(function(value)
return value ~= "Info"
end),
onReset = function()
Settings:set("logLevel", "Info")
end,
}),
TypecheckingEnabled = e(Setting, {
id = "typecheckingEnabled",
name = "Typechecking",
description = "Toggle typechecking on the API surface",
tag = "debug",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
TimingLogsEnabled = e(Setting, {
id = "timingLogsEnabled",
name = "Timing Logs",
description = "Toggle logging timing of internal actions for benchmarking Rojo performance",
tag = "debug",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
}), }),
}) })
end) end)

View File

@@ -19,212 +19,12 @@ local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local strict = require(script.Parent.Parent.strict) local strict = require(script.Parent.Parent.strict)
-- Copying hex colors back and forth from design programs is faster local BRAND_COLOR = Color3.fromHex("E13835")
local function hexColor(decimal)
local red = bit32.band(bit32.rshift(decimal, 16), 2^8 - 1)
local green = bit32.band(bit32.rshift(decimal, 8), 2^8 - 1)
local blue = bit32.band(decimal, 2^8 - 1)
return Color3.fromRGB(red, green, blue) local Context = Roact.createContext({})
end
local BRAND_COLOR = hexColor(0xE13835)
local lightTheme = strict("LightTheme", {
BackgroundColor = hexColor(0xFFFFFF),
Button = {
Solid = {
ActionFillColor = hexColor(0xFFFFFF),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = hexColor(0xFFFFFF),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = hexColor(0xFFFFFF),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = hexColor(0x000000),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = hexColor(0x393939),
BorderColor = hexColor(0xACACAC),
},
Disabled = {
TextColor = hexColor(0x393939),
BorderColor = hexColor(0xACACAC),
},
},
},
Checkbox = {
Active = {
IconColor = hexColor(0xFFFFFF),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = hexColor(0xEEEEEE),
BorderColor = hexColor(0xAFAFAF),
},
},
Dropdown = {
TextColor = hexColor(0x00000),
BorderColor = hexColor(0xAFAFAF),
BackgroundColor = hexColor(0xEEEEEE),
Open = {
IconColor = BRAND_COLOR,
},
Closed = {
IconColor = hexColor(0xEEEEEE),
},
},
AddressEntry = {
TextColor = hexColor(0x000000),
PlaceholderColor = hexColor(0x8C8C8C)
},
BorderedContainer = {
BorderColor = hexColor(0xCBCBCB),
BackgroundColor = hexColor(0xEEEEEE),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = hexColor(0xEEEEEE),
},
Diff = {
Add = hexColor(0xbaffbd),
Remove = hexColor(0xffbdba),
Edit = hexColor(0xbacdff),
Row = hexColor(0x000000),
},
ConnectionDetails = {
ProjectNameColor = hexColor(0x00000),
AddressColor = hexColor(0x00000),
DisconnectColor = BRAND_COLOR,
},
Settings = {
DividerColor = hexColor(0xCBCBCB),
Navbar = {
BackButtonColor = hexColor(0x000000),
TextColor = hexColor(0x000000),
},
Setting = {
NameColor = hexColor(0x000000),
DescriptionColor = hexColor(0x5F5F5F),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = hexColor(0x727272),
},
Notification = {
InfoColor = hexColor(0x00000),
CloseColor = BRAND_COLOR,
},
ErrorColor = hexColor(0x000000),
ScrollBarColor = hexColor(0x000000),
})
local darkTheme = strict("DarkTheme", {
BackgroundColor = hexColor(0x2E2E2E),
Button = {
Solid = {
ActionFillColor = hexColor(0xFFFFFF),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = hexColor(0xFFFFFF),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = hexColor(0xFFFFFF),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = hexColor(0xFFFFFF),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = hexColor(0xDBDBDB),
BorderColor = hexColor(0x535353),
},
Disabled = {
TextColor = hexColor(0xDBDBDB),
BorderColor = hexColor(0x535353),
},
},
},
Checkbox = {
Active = {
IconColor = hexColor(0xFFFFFF),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = hexColor(0x484848),
BorderColor = hexColor(0x5A5A5A),
},
},
Dropdown = {
TextColor = hexColor(0xFFFFFF),
BorderColor = hexColor(0x5A5A5A),
BackgroundColor = hexColor(0x2B2B2B),
Open = {
IconColor = BRAND_COLOR,
},
Closed = {
IconColor = hexColor(0x484848),
},
},
AddressEntry = {
TextColor = hexColor(0xFFFFFF),
PlaceholderColor = hexColor(0x8B8B8B)
},
BorderedContainer = {
BorderColor = hexColor(0x535353),
BackgroundColor = hexColor(0x2B2B2B),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = hexColor(0x2B2B2B),
},
Diff = {
Add = hexColor(0x273732),
Remove = hexColor(0x3F2D32),
Edit = hexColor(0x193345),
Row = hexColor(0xFFFFFF),
},
ConnectionDetails = {
ProjectNameColor = hexColor(0xFFFFFF),
AddressColor = hexColor(0xFFFFFF),
DisconnectColor = hexColor(0xFFFFFF),
},
Settings = {
DividerColor = hexColor(0x535353),
Navbar = {
BackButtonColor = hexColor(0xFFFFFF),
TextColor = hexColor(0xFFFFFF),
},
Setting = {
NameColor = hexColor(0xFFFFFF),
DescriptionColor = hexColor(0xD3D3D3),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = hexColor(0xD3D3D3)
},
Notification = {
InfoColor = hexColor(0xFFFFFF),
CloseColor = hexColor(0xFFFFFF),
},
ErrorColor = hexColor(0xFFFFFF),
ScrollBarColor = hexColor(0xFFFFFF),
})
local Context = Roact.createContext(lightTheme)
local StudioProvider = Roact.Component:extend("StudioProvider") local StudioProvider = Roact.Component:extend("StudioProvider")
@@ -232,21 +32,160 @@ local StudioProvider = Roact.Component:extend("StudioProvider")
function StudioProvider:updateTheme() function StudioProvider:updateTheme()
local studioTheme = getStudio().Theme local studioTheme = getStudio().Theme
if studioTheme.Name == "Light" then local isDark = studioTheme.Name == "Dark"
self:setState({
theme = lightTheme,
})
elseif studioTheme.Name == "Dark" then
self:setState({
theme = darkTheme,
})
else
Log.warn("Unexpected theme '{}'' -- falling back to light theme!", studioTheme.Name)
self:setState({ local theme = strict(studioTheme.Name .. "Theme", {
theme = lightTheme, BrandColor = BRAND_COLOR,
}) BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground),
end TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
SubTextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
Button = {
Solid = {
-- Solid uses brand theming, not Studio theming.
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.ButtonText,
Enum.StudioStyleGuideModifier.Selected
),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.ButtonText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
Disabled = {
TextColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.ButtonText,
Enum.StudioStyleGuideModifier.Disabled
),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
},
},
Checkbox = {
Active = {
-- Active checkboxes use brand theming, not Studio theming.
IconColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldIndicator,
Enum.StudioStyleGuideModifier.Disabled
),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
},
Dropdown = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.ButtonText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground),
IconColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldIndicator,
Enum.StudioStyleGuideModifier.Disabled
),
},
TextInput = {
Enabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
Disabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
ActionFillColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
ActionFillTransparency = 0.9,
},
AddressEntry = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
},
BorderedContainer = {
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
},
Diff = {
-- Studio doesn't have good colors since their diffs use backgrounds, not text
Add = if isDark then Color3.fromRGB(143, 227, 154) else Color3.fromRGB(41, 164, 45),
Remove = if isDark then Color3.fromRGB(242, 125, 125) else Color3.fromRGB(150, 29, 29),
Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160),
Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
},
ConnectionDetails = {
ProjectNameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
AddressColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
DisconnectColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Settings = {
DividerColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
Navbar = {
BackButtonColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Setting = {
NameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
DescriptionColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
UnstableColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
DebugColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InfoText),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
},
Notification = {
InfoColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
CloseColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
ErrorColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
ScrollBarColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
})
self:setState({
theme = theme,
})
end end
function StudioProvider:init() function StudioProvider:init()

View File

@@ -1,5 +1,7 @@
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Players = game:GetService("Players") local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage") local ServerStorage = game:GetService("ServerStorage")
local RunService = game:GetService("RunService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
@@ -17,8 +19,11 @@ local Dictionary = require(Plugin.Dictionary)
local ServeSession = require(Plugin.ServeSession) local ServeSession = require(Plugin.ServeSession)
local ApiContext = require(Plugin.ApiContext) local ApiContext = require(Plugin.ApiContext)
local PatchSet = require(Plugin.PatchSet) local PatchSet = require(Plugin.PatchSet)
local PatchTree = require(Plugin.PatchTree)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer) local soundPlayer = require(Plugin.soundPlayer)
local ignorePlaceIds = require(Plugin.ignorePlaceIds)
local timeUtil = require(Plugin.timeUtil)
local Theme = require(script.Theme) local Theme = require(script.Theme)
local Page = require(script.Page) local Page = require(script.Page)
@@ -51,58 +56,262 @@ function App:init()
self.host, self.setHost = Roact.createBinding(priorHost or "") self.host, self.setHost = Roact.createBinding(priorHost or "")
self.port, self.setPort = Roact.createBinding(priorPort or "") self.port, self.setPort = Roact.createBinding(priorPort or "")
self.patchInfo, self.setPatchInfo = Roact.createBinding({
patch = PatchSet.newEmpty(),
timestamp = os.time(),
})
self.confirmationBindable = Instance.new("BindableEvent") self.confirmationBindable = Instance.new("BindableEvent")
self.confirmationEvent = self.confirmationBindable.Event self.confirmationEvent = self.confirmationBindable.Event
self.knownProjects = {}
self.notifId = 0
self.waypointConnection = ChangeHistoryService.OnUndo:Connect(function(action: string)
if not string.find(action, "^Rojo: Patch") then
return
end
local undoConnection, redoConnection = nil, nil
local function cleanup()
undoConnection:Disconnect()
redoConnection:Disconnect()
end
Log.warn(
string.format(
"You've undone '%s'.\nIf this was not intended, please Redo in the topbar or with Ctrl/⌘+Y.",
action
)
)
local dismissNotif = self:addNotification(
string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
10,
{
Restore = {
text = "Restore",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
cleanup()
notification:dismiss()
ChangeHistoryService:Redo()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
cleanup()
notification:dismiss()
end,
},
}
)
undoConnection = ChangeHistoryService.OnUndo:Once(function()
-- Our notif is now out of date- redoing will not restore the patch
-- since we've undone even further. Dismiss the notif.
cleanup()
dismissNotif()
end)
redoConnection = ChangeHistoryService.OnRedo:Once(function(redoneAction: string)
if redoneAction == action then
-- The user has restored the patch, so we can dismiss the notif
cleanup()
dismissNotif()
end
end)
end)
self.disconnectUpdatesCheckChanged = Settings:onChanged("checkForUpdates", function()
self:checkForUpdates()
end)
self.disconnectPrereleasesCheckChanged = Settings:onChanged("checkForPrereleases", function()
self:checkForUpdates()
end)
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
guiEnabled = false, guiEnabled = false,
confirmData = {}, confirmData = {},
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
notifications = {}, notifications = {},
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
if RunService:IsEdit() then
self:checkForUpdates()
if
Settings:get("syncReminder")
and self.serveSession == nil
and self:getLastSyncTimestamp()
and (self:isSyncLockAvailable())
then
self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, {
Connect = {
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
notification:dismiss()
self:startSession()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
},
})
end
end
if self:isAutoConnectPlaytestServerAvailable() then
self:useRunningConnectionInfo()
self:startSession()
end
self.autoConnectPlaytestServerListener = Settings:onChanged("autoConnectPlaytestServer", function(enabled)
if enabled then
if self:isAutoConnectPlaytestServerWriteable() and self.serveSession ~= nil then
-- Write the existing session
local baseUrl = self.serveSession.__apiContext.__baseUrl
self:setRunningConnectionInfo(baseUrl)
end
else
self:clearRunningConnectionInfo()
end
end)
end end
function App:addNotification(text: string, timeout: number?) function App:willUnmount()
self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy()
self.disconnectUpdatesCheckChanged()
self.disconnectPrereleasesCheckChanged()
self.autoConnectPlaytestServerListener()
self:clearRunningConnectionInfo()
end
function App:addNotification(
text: string,
timeout: number?,
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> () } }?
)
if not Settings:get("showNotifications") then if not Settings:get("showNotifications") then
return return
end end
self.notifId += 1
local id = self.notifId
local notifications = table.clone(self.state.notifications) local notifications = table.clone(self.state.notifications)
table.insert(notifications, { notifications[id] = {
text = text, text = text,
timestamp = DateTime.now().UnixTimestampMillis, timestamp = DateTime.now().UnixTimestampMillis,
timeout = timeout or 3, timeout = timeout or 3,
actions = actions,
}
self:setState({
notifications = notifications,
}) })
return function()
self:closeNotification(id)
end
end
function App:closeNotification(id: number)
if not self.state.notifications[id] then
return
end
local notifications = table.clone(self.state.notifications)
notifications[id] = nil
self:setState({ self:setState({
notifications = notifications, notifications = notifications,
}) })
end end
function App:closeNotification(index: number) function App:checkForUpdates()
local notifications = table.clone(self.state.notifications) if not Settings:get("checkForUpdates") then
table.remove(notifications, index) return
end
self:setState({ local isLocalInstall = string.find(debug.traceback(), "\n[^\n]-user_.-$") ~= nil
notifications = notifications, local latestCompatibleVersion = Version.retrieveLatestCompatible({
version = Config.version,
includePrereleases = isLocalInstall and Settings:get("checkForPrereleases"),
}) })
if not latestCompatibleVersion then
return
end
self:addNotification(
string.format(
"A newer compatible version of Rojo, %s, was published %s! Go to the Rojo releases page to learn more.",
Version.display(latestCompatibleVersion.version),
timeUtil.elapsedToText(DateTime.now().UnixTimestamp - latestCompatibleVersion.publishedUnixTimestamp)
),
500,
{
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
},
}
)
end end
function App:getPriorEndpoint() function App:getPriorEndpoint()
local priorEndpoints = Settings:get("priorEndpoints") local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end if not priorEndpoints then
return
end
local place = priorEndpoints[tostring(game.PlaceId)] local id = tostring(game.PlaceId)
if not place then return end if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id]
if not place then
return
end
return place.host, place.port return place.host, place.port
end end
function App:getLastSyncTimestamp()
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then
return
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id]
if not place then
return
end
return place.timestamp
end
function App:setPriorEndpoint(host: string, port: string) function App:setPriorEndpoint(host: string, port: string)
local priorEndpoints = Settings:get("priorEndpoints") local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then if not priorEndpoints then
@@ -117,18 +326,18 @@ function App:setPriorEndpoint(host: string, port: string)
end end
end end
if host == Config.defaultHost and port == Config.defaultPort then local id = tostring(game.PlaceId)
-- Don't save default if ignorePlaceIds[id] then
priorEndpoints[tostring(game.PlaceId)] = nil return
else
priorEndpoints[tostring(game.PlaceId)] = {
host = host ~= Config.defaultHost and host or nil,
port = port ~= Config.defaultPort and port or nil,
timestamp = os.time(),
}
Log.trace("Saved last used endpoint for {}", game.PlaceId)
end end
priorEndpoints[id] = {
host = if host ~= Config.defaultHost then host else nil,
port = if port ~= Config.defaultPort then port else nil,
timestamp = os.time(),
}
Log.trace("Saved last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorEndpoints) Settings:set("priorEndpoints", priorEndpoints)
end end
@@ -136,10 +345,28 @@ function App:getHostAndPort()
local host = self.host:getValue() local host = self.host:getValue()
local port = self.port:getValue() local port = self.port:getValue()
local host = if #host > 0 then host else Config.defaultHost return if #host > 0 then host else Config.defaultHost, if #port > 0 then port else Config.defaultPort
local port = if #port > 0 then port else Config.defaultPort end
return host, port function App:isSyncLockAvailable()
if #Players:GetPlayers() == 0 then
-- Team Create is not active, so no one can be holding the lock
return true
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then
-- No lock is made yet, so it is available
return true
end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
-- Someone else is holding the lock
return false, lock.Value
end
-- The lock exists, but is not claimed
return true
end end
function App:claimSyncLock() function App:claimSyncLock()
@@ -148,6 +375,12 @@ function App:claimSyncLock()
return true return true
end end
local isAvailable, priorOwner = self:isSyncLockAvailable()
if not isAvailable then
Log.trace("Skipping sync lock because it is already claimed")
return false, priorOwner
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock") local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then if not lock then
lock = Instance.new("ObjectValue") lock = Instance.new("ObjectValue")
@@ -159,11 +392,6 @@ function App:claimSyncLock()
return true return true
end end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
Log.trace("Found existing sync lock owned by {}", lock.Value)
return false, lock.Value
end
lock.Value = Players.LocalPlayer lock.Value = Players.LocalPlayer
Log.trace("Claimed existing sync lock") Log.trace("Claimed existing sync lock")
return true return true
@@ -185,6 +413,49 @@ function App:releaseSyncLock()
Log.trace("Could not relase sync lock because it is owned by {}", lock.Value) Log.trace("Could not relase sync lock because it is owned by {}", lock.Value)
end end
function App:isAutoConnectPlaytestServerAvailable()
return RunService:IsRunMode()
and RunService:IsServer()
and Settings:get("autoConnectPlaytestServer")
and workspace:GetAttribute("__Rojo_ConnectionUrl")
end
function App:isAutoConnectPlaytestServerWriteable()
return RunService:IsEdit() and Settings:get("autoConnectPlaytestServer")
end
function App:setRunningConnectionInfo(baseUrl: string)
if not self:isAutoConnectPlaytestServerWriteable() then
return
end
Log.trace("Setting connection info for play solo auto-connect")
workspace:SetAttribute("__Rojo_ConnectionUrl", baseUrl)
end
function App:clearRunningConnectionInfo()
if not RunService:IsEdit() then
-- Only write connection info from edit mode
return
end
Log.trace("Clearing connection info for play solo auto-connect")
workspace:SetAttribute("__Rojo_ConnectionUrl", nil)
end
function App:useRunningConnectionInfo()
local connectionInfo = workspace:GetAttribute("__Rojo_ConnectionUrl")
if not connectionInfo then
return
end
Log.trace("Using connection info for play solo auto-connect")
local host, port = string.match(connectionInfo, "^(.+):(.-)$")
self.setHost(host)
self.setPort(port)
end
function App:startSession() function App:startSession()
local claimedLock, priorOwner = self:claimSyncLock() local claimedLock, priorOwner = self:claimSyncLock()
if not claimedLock then if not claimedLock then
@@ -203,11 +474,6 @@ function App:startSession()
local host, port = self:getHostAndPort() local host, port = self:getHostAndPort()
local sessionOptions = {
openScriptsExternally = Settings:get("openScriptsExternally"),
twoWaySync = Settings:get("twoWaySync"),
}
local baseUrl = if string.find(host, "^https?://") local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port) then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port) else string.format("http://%s:%s", host, port)
@@ -215,35 +481,54 @@ function App:startSession()
local serveSession = ServeSession.new({ local serveSession = ServeSession.new({
apiContext = apiContext, apiContext = apiContext,
openScriptsExternally = sessionOptions.openScriptsExternally, twoWaySync = Settings:get("twoWaySync"),
twoWaySync = sessionOptions.twoWaySync,
}) })
serveSession:onPatchApplied(function(patch, _unapplied) self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
})
end)
self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch)
-- Update tree with unapplied metadata
self:setState(function(prevState)
return {
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
}
end)
end)
serveSession:hookPostcommit(function(patch, _instanceMap, unapplied)
local now = DateTime.now().UnixTimestamp
local old = self.state.patchData
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
-- Ignore empty patches -- Ignore empty patch, but update timestamp
self:setState({
patchData = {
patch = old.patch,
unapplied = old.unapplied,
timestamp = now,
},
})
return return
end end
local now = os.time()
local old = self.patchInfo:getValue()
if now - old.timestamp < 2 then if now - old.timestamp < 2 then
-- Patches that apply in the same second are -- Patches that apply in the same second are
-- considered to be part of the same change for human clarity -- considered to be part of the same change for human clarity
local merged = PatchSet.newEmpty() patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch)
PatchSet.assign(merged, old.patch, patch) unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied)
self.setPatchInfo({
patch = merged,
timestamp = now,
})
else
self.setPatchInfo({
patch = patch,
timestamp = now,
})
end end
self:setState({
patchData = {
patch = patch,
unapplied = unapplied,
timestamp = now,
},
})
end) end)
serveSession:onStatusChanged(function(status, details) serveSession:onStatusChanged(function(status, details)
@@ -256,6 +541,9 @@ function App:startSession()
}) })
self:addNotification("Connecting to session...") self:addNotification("Connecting to session...")
elseif status == ServeSession.Status.Connected then elseif status == ServeSession.Status.Connected then
self.knownProjects[details] = true
self:setRunningConnectionInfo(baseUrl)
local address = ("%s:%s"):format(host, port) local address = ("%s:%s"):format(host, port)
self:setState({ self:setState({
appStatus = AppStatus.Connected, appStatus = AppStatus.Connected,
@@ -267,6 +555,14 @@ function App:startSession()
elseif status == ServeSession.Status.Disconnected then elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil self.serveSession = nil
self:releaseSyncLock() self:releaseSyncLock()
self:clearRunningConnectionInfo()
self:setState({
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
})
-- Details being present indicates that this -- Details being present indicates that this
-- disconnection was from an error. -- disconnection was from an error.
@@ -291,9 +587,67 @@ function App:startSession()
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
Log.trace("Accepting patch without confirmation because it is empty")
return "Accept" return "Accept"
end end
-- Play solo auto-connect does not require confirmation
if self:isAutoConnectPlaytestServerAvailable() then
Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled")
return "Accept"
end
local confirmationBehavior = Settings:get("confirmationBehavior")
if confirmationBehavior == "Initial" then
-- Only confirm if we haven't synced this project yet this session
if self.knownProjects[serverInfo.projectName] then
Log.trace(
"Accepting patch without confirmation because project has already been connected and behavior is set to Initial"
)
return "Accept"
end
elseif confirmationBehavior == "Large Changes" then
-- Only confirm if the patch impacts many instances
if PatchSet.countInstances(patch) < Settings:get("largeChangesConfirmationThreshold") then
Log.trace(
"Accepting patch without confirmation because patch is small and behavior is set to Large Changes"
)
return "Accept"
end
elseif confirmationBehavior == "Unlisted PlaceId" then
-- Only confirm if the current placeId is not in the servePlaceIds allowlist
if serverInfo.expectedPlaceIds then
local isListed = table.find(serverInfo.expectedPlaceIds, game.PlaceId) ~= nil
if isListed then
Log.trace(
"Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId"
)
return "Accept"
end
end
elseif confirmationBehavior == "Never" then
Log.trace("Accepting patch without confirmation because behavior is set to Never")
return "Accept"
end
-- The datamodel name gets overwritten by Studio, making confirmation of it intrusive
-- and unnecessary. This special case allows it to be accepted without confirmation.
if
PatchSet.hasAdditions(patch) == false
and PatchSet.hasRemoves(patch) == false
and PatchSet.containsOnlyInstance(patch, instanceMap, game)
then
local datamodelUpdates = PatchSet.getUpdateForInstance(patch, instanceMap, game)
if
datamodelUpdates ~= nil
and next(datamodelUpdates.changedProperties) == nil
and datamodelUpdates.changedClassName == nil
then
Log.trace("Accepting patch without confirmation because it only contains a datamodel name change")
return "Accept"
end
end
self:setState({ self:setState({
appStatus = AppStatus.Confirming, appStatus = AppStatus.Confirming,
confirmData = { confirmData = {
@@ -318,16 +672,6 @@ function App:startSession()
serveSession:start() serveSession:start()
self.serveSession = serveSession self.serveSession = serveSession
task.defer(function()
while self.serveSession == serveSession do
-- Trigger rerender to update timestamp text
local patchInfo = table.clone(self.patchInfo:getValue())
self.setPatchInfo(patchInfo)
local elapsed = os.time() - patchInfo.timestamp
task.wait(elapsed < 60 and 1 or elapsed / 5)
end
end)
end end
function App:endSession() function App:endSession()
@@ -343,6 +687,13 @@ function App:endSession()
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
}) })
if self.cleanupPrecommit ~= nil then
self.cleanupPrecommit()
end
if self.cleanupPostcommit ~= nil then
self.cleanupPostcommit()
end
Log.trace("Session terminated by user") Log.trace("Session terminated by user")
end end
@@ -364,17 +715,17 @@ function App:render()
value = self.props.plugin, value = self.props.plugin,
}, { }, {
e(Theme.StudioProvider, nil, { e(Theme.StudioProvider, nil, {
e(Tooltip.Provider, nil, { tooltip = e(Tooltip.Provider, nil, {
gui = e(StudioPluginGui, { gui = e(StudioPluginGui, {
id = pluginName, id = pluginName,
title = pluginName, title = pluginName,
active = self.state.guiEnabled, active = self.state.guiEnabled,
isEphemeral = false,
initDockState = Enum.InitialDockState.Right, initDockState = Enum.InitialDockState.Right,
initEnabled = false,
overridePreviousState = false, overridePreviousState = false,
floatingSize = Vector2.new(300, 200), floatingSize = Vector2.new(320, 210),
minimumSize = Vector2.new(300, 120), minimumSize = Vector2.new(300, 210),
zIndexBehavior = Enum.ZIndexBehavior.Sibling, zIndexBehavior = Enum.ZIndexBehavior.Sibling,
@@ -403,6 +754,7 @@ function App:render()
end, end,
onNavigateSettings = function() onNavigateSettings = function()
self.backPage = AppStatus.NotConnected
self:setState({ self:setState({
appStatus = AppStatus.Settings, appStatus = AppStatus.Settings,
}) })
@@ -429,18 +781,29 @@ function App:render()
Connected = createPageElement(AppStatus.Connected, { Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName, projectName = self.state.projectName,
address = self.state.address, address = self.state.address,
patchInfo = self.patchInfo, patchTree = self.state.patchTree,
patchData = self.state.patchData,
serveSession = self.serveSession, serveSession = self.serveSession,
onDisconnect = function() onDisconnect = function()
self:endSession() self:endSession()
end, end,
onNavigateSettings = function()
self.backPage = AppStatus.Connected
self:setState({
appStatus = AppStatus.Settings,
})
end,
}), }),
Settings = createPageElement(AppStatus.Settings, { Settings = createPageElement(AppStatus.Settings, {
syncActive = self.serveSession ~= nil
and self.serveSession:getStatus() == ServeSession.Status.Connected,
onBack = function() onBack = function()
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = self.backPage or AppStatus.NotConnected,
}) })
end, end,
}), }),
@@ -457,7 +820,11 @@ function App:render()
}), }),
}), }),
RojoNotifications = e("ScreenGui", {}, { RojoNotifications = e("ScreenGui", {
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
ResetOnSpawn = false,
DisplayOrder = 100,
}, {
layout = e("UIListLayout", { layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder, SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right, HorizontalAlignment = Enum.HorizontalAlignment.Right,
@@ -473,8 +840,8 @@ function App:render()
notifs = e(Notifications, { notifs = e(Notifications, {
soundPlayer = self.props.soundPlayer, soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications, notifications = self.state.notifications,
onClose = function(index) onClose = function(id)
self:closeNotification(index) self:closeNotification(id)
end, end,
}), }),
}), }),

View File

@@ -24,6 +24,13 @@ local Assets = {
Close = "rbxassetid://6012985953", Close = "rbxassetid://6012985953",
Back = "rbxassetid://6017213752", Back = "rbxassetid://6017213752",
Reset = "rbxassetid://10142422327", Reset = "rbxassetid://10142422327",
Expand = "rbxassetid://12045401097",
Warning = "rbxassetid://16571019891",
Debug = "rbxassetid://16588411361",
Checkmark = "rbxassetid://16571012729",
Exclamation = "rbxassetid://16571172190",
SyncSuccess = "rbxassetid://16565035221",
SyncWarning = "rbxassetid://16565325171",
}, },
Diff = { Diff = {
Add = "rbxassetid://10434145835", Add = "rbxassetid://10434145835",
@@ -33,9 +40,11 @@ local Assets = {
Checkbox = { Checkbox = {
Active = "rbxassetid://6016251644", Active = "rbxassetid://6016251644",
Inactive = "rbxassetid://6016251963", Inactive = "rbxassetid://6016251963",
Locked = "rbxassetid://14011257320",
}, },
Dropdown = { Dropdown = {
Arrow = "rbxassetid://10131770538", Arrow = "rbxassetid://10131770538",
Locked = "rbxassetid://14011257320",
}, },
Spinner = { Spinner = {
Foreground = "rbxassetid://3222731032", Foreground = "rbxassetid://3222731032",
@@ -51,7 +60,7 @@ local Assets = {
[32] = "rbxassetid://3088713341", [32] = "rbxassetid://3088713341",
[64] = "rbxassetid://4918677124", [64] = "rbxassetid://4918677124",
[128] = "rbxassetid://2600845734", [128] = "rbxassetid://2600845734",
[500] = "rbxassetid://2609138523" [500] = "rbxassetid://2609138523",
}, },
}, },
Sounds = { Sounds = {

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