Compare commits

...

13 Commits

Author SHA1 Message Date
Lucien Greathouse
e17771a6a5 Release v7.3.0 2023-04-22 16:07:39 -04:00
Lucien Greathouse
bac30ae78b Update MSRV to try to fix CI workflow 2023-04-22 15:58:14 -04:00
Lucien Greathouse
c0219922b2 Update dependencies 2023-04-22 15:44:49 -04:00
boatbomber
b5ed952d5c Add visual diffs to syncing (#603)
* Add user confirmation to initial sync

* Use "Accept" instead of "Confirm"

* Draw tree alphabetically for determinism

* Add diff table dropdown

* Add diff table to newly added objects

* Unblock keybind workflow

* Only show reject button when two way is enabled

* Try to patch back to the files when changes are rejected

* Improve text spacing of the prop diff table

* Skip user confirmation of perfect syncs

* Give instances names for debugging UI

* Optimize tree building

* Efficiency: dynamic virtual scrolling & lazy rendering

* Simplify virtual scroller logic and avoid wasteful rerenders

* Remove debug print

* Consistent naming

* Move new patch applied callback into accept

* Pcall archivable

* Keybinds open popup diff window

* Theme rows in diff

* Remove relic of prototype

* Color value visuals and better component name

* changeBatcher is not needed when no sync is active

* Simplify popup roact entrypoint

* Alphabetical prop lists and refactor

* Add a stroke to color blot for contrast

* Make color blots animate transparency with the rest of the page

* StyLua formatting on newly added files

* Remove wasteful table

* Fix diffing custom properties

* Display tables more meaningfully

* Allow children in the button components

* Create a rough tooltip component

* Add tooltips to buttons

* Use provider+trigger schema to avoid tooltip ZIndex issues

* Add triangle point to tooltip

* Tooltip underneath instead of covering

* Cancel hovers when unmounting

* Allow multiple canvases from one provider

* Display above or below depending on available space

* Move patch equality to PatchSet.isEqual

* Use Container

* Remove old submodules

* Reduce false positives in diff

* Add debug log

* Fuzzy equals CFrame in diffs to avoid floating point in

* Fix decodeValue usage

* Support the .changedName patches

* Fix content overlapping border

* Fix tooltip tail alignment

* Fix tooltip text fit

* Whoops, fix it properly

* Move PatchVisualizer to Components

* Provide Connected info with full patch data

* Avoid implicit nil return

* Add patch visualizer to connected page

* Make Current column invisible when visualizing applied patches

* Avoid floating point diffs in a numbers and vectors
2023-04-01 23:17:23 -04:00
ok-nick
7994bc4909 Update setup-aftman (#648) 2022-11-18 03:32:13 -05:00
boatbomber
b88d34c639 Add tooltips to buttons (#637)
* Add tooltips

* Fix whitespace

* Avoid overloaded word canvas

* Clean render function

* Switch folder to fragment
2022-10-07 19:31:14 -04:00
fox
96cb1ee3fd Support explicitly specifying http or https protocol in plugin (#642)
* Support explicitly specifying http or https protocol in plugin

* Fix incorrect format string

Port is not a number
2022-09-30 17:59:09 -04:00
boatbomber
003abe86bb Save host and port by placeId (#613)
* Save host and port by placeId

* Bump to 5 months before clearing

* Fix indentation
2022-09-22 23:03:09 -04:00
Lucien Greathouse
6ec411a618 Add Patreon badge to README 2022-08-20 23:44:10 -04:00
Qualadore
c7c0903804 Reduce minimum plugin size (#606)
* Reduce minimum plugin size

* Resize to 300x120

Co-authored-by: Qualadore <me@qualadore.com>
2022-08-20 22:40:52 -04:00
boatbomber
cdc972a5ce Migrate DevSettings to PluginSettings for much better config flow (#572)
* Add the devsetting config options into settings

* Create dropdown component and add setting controls

* Static dropdwon width and spin arrow

* Improve dropdown option contrast and border

* Forgot to make the settings page respect the static spacing, oops

* Smaller arrow

* Vert padding

* Reset option for settings

* Hide reset button when on default

* Respect the logLevel setting

* Portal settings out to external typechecking module

* Implement new configs using the new singleton Settings

* Remove DevSettings

* Update test runner to use new settings

* More helpful test failure output

* Support non-plugin environment

* Migrate dropdown to new packages system

* Clean up components a tad
2022-08-20 22:39:34 -04:00
Boegie19
17de912608 fix_vfs_double_update (#616) 2022-08-20 22:33:19 -04:00
Boegie19
9876508887 added attributes to AdjacentMetadata (#624)
* added attributes to AdjacentMetadata

* ran fmt
2022-08-20 22:32:58 -04:00
45 changed files with 9316 additions and 1602 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
rust_version: [stable, 1.58.1]
rust_version: [stable, 1.69.0]
steps:
- uses: actions/checkout@v3
@@ -29,11 +29,9 @@ jobs:
profile: minimal
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.1.0
uses: ok-nick/setup-aftman@v0.3.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
trust-check: false
version: 'v0.2.6'
version: 'v0.2.7'
- name: Install packages
run: |
@@ -62,11 +60,9 @@ jobs:
components: rustfmt, clippy
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.1.0
uses: ok-nick/setup-aftman@v0.3.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
trust-check: false
version: 'v0.2.6'
version: 'v0.2.7'
- name: Install packages
run: |

View File

@@ -1,9 +1,42 @@
# Rojo Changelog
## Unreleased Changes
* Added `--watch` flag to `rojo sourcemap` ([#602])
## [7.3.0] - April 22, 2023
* Added `$attributes` to project format. ([#574])
* Added `--watch` flag to `rojo sourcemap`. ([#602])
* Added support for `init.csv` files. ([#594])
* Added real-time sync status to the Studio plugin. ([#569])
* Added support for copying error messages to the clipboard. ([#614])
* Added sync locking for Team Create. ([#590])
* Added support for specifying HTTP or HTTPS protocol in plugin. ([#642])
* Added tooltips to buttons in the Studio plugin. ([#637])
* Added visual diffs when connecting from the Studio plugin. ([#603])
* Host and port are now saved in the Studio plugin. ([#613])
* Improved padding on notifications in Studio plugin. ([#589])
* Renamed `Common` to `Shared` in the default Rojo project. ([#611])
* Reduced the minimum size of the Studio plugin widget. ([#606])
* Fixed current directory in `rojo fmt-project`. ([#581])
* Fixed errors after a session has already ended. ([#587])
* Fixed an uncommon security permission error ([#619])
[#569]: https://github.com/rojo-rbx/rojo/pull/569
[#574]: https://github.com/rojo-rbx/rojo/pull/574
[#581]: https://github.com/rojo-rbx/rojo/pull/581
[#587]: https://github.com/rojo-rbx/rojo/pull/587
[#589]: https://github.com/rojo-rbx/rojo/pull/589
[#590]: https://github.com/rojo-rbx/rojo/pull/590
[#594]: https://github.com/rojo-rbx/rojo/pull/594
[#602]: https://github.com/rojo-rbx/rojo/pull/602
[#603]: https://github.com/rojo-rbx/rojo/pull/603
[#606]: https://github.com/rojo-rbx/rojo/pull/606
[#611]: https://github.com/rojo-rbx/rojo/pull/611
[#613]: https://github.com/rojo-rbx/rojo/pull/613
[#614]: https://github.com/rojo-rbx/rojo/pull/614
[#619]: https://github.com/rojo-rbx/rojo/pull/619
[#637]: https://github.com/rojo-rbx/rojo/pull/637
[#642]: https://github.com/rojo-rbx/rojo/pull/642
[7.3.0]: https://github.com/rojo-rbx/rojo/releases/tag/v7.3.0
## [7.2.1] - July 8, 2022
* Fixed notification sound by changing it to a generic sound. ([#566])

1236
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package]
name = "rojo"
version = "7.2.1"
rust-version = "1.58.1"
version = "7.3.0"
rust-version = "1.68.2"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0"
@@ -51,11 +51,11 @@ memofs = { version = "0.2.0", path = "crates/memofs" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "0.6.5"
rbx_binary = "0.7.0"
rbx_dom_weak = "2.4.0"
rbx_reflection = "4.2.0"
rbx_reflection_database = "0.2.2"
rbx_xml = "0.12.3"
rbx_reflection_database = "0.2.6"
rbx_xml = "0.13.0"
anyhow = "1.0.44"
backtrace = "0.3.61"
@@ -102,7 +102,7 @@ maplit = "1.0.2"
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
criterion = "0.3.5"
insta = { version = "1.8.0", features = ["redactions"] }
insta = { version = "1.8.0", features = ["redactions", "yaml"] }
paste = "1.0.5"
pretty_assertions = "1.2.1"
serde_yaml = "0.8.21"

View File

@@ -8,6 +8,7 @@
<a href="https://github.com/rojo-rbx/rojo/actions"><img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" /></a>
<a href="https://crates.io/crates/rojo"><img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /></a>
<a href="https://rojo.space/docs"><img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" /></a>
<a href="https://www.patreon.com/lpghatguy"><img src="https://img.shields.io/badge/sponsor-patreon-red" alt="Patreon" /></a>
</div>
<hr />

View File

@@ -238,6 +238,23 @@ types = {
toPod = serializeFloat,
},
Font = {
fromPod = function(pod)
return Font.new(
pod.family,
if pod.weight ~= nil then Enum.FontWeight[pod.weight] else nil,
if pod.style ~= nil then Enum.FontStyle[pod.style] else nil
)
end,
toPod = function(roblox)
return {
family = roblox.Family,
weight = roblox.Weight.Name,
style = roblox.Style.Name,
}
end,
},
Int32 = {
fromPod = identity,
toPod = identity,

View File

@@ -53,6 +53,11 @@ function PropertyDescriptor:read(instance)
end
if self.scriptability == "Custom" then
if customProperties[self.className] == nil then
local fullName = ("%s.%s"):format(instance.className, self.name)
return false, Error.new(Error.Kind.PropertyNotReadable, fullName)
end
local interface = customProperties[self.className][self.name]
return interface.read(instance, self.name)
@@ -79,6 +84,11 @@ function PropertyDescriptor:write(instance, value)
end
if self.scriptability == "Custom" then
if customProperties[self.className] == nil then
local fullName = ("%s.%s"):format(instance.className, self.name)
return false, Error.new(Error.Kind.PropertyNotWritable, fullName)
end
local interface = customProperties[self.className][self.name]
return interface.write(instance, self.name, value)

View File

@@ -207,6 +207,17 @@
},
"ty": "Float64"
},
"Font": {
"value": {
"Font": {
"family": "rbxasset://fonts/families/SourceSansPro.json",
"weight": "Regular",
"style": "Normal",
"cachedFaceId": null
}
},
"ty": "Font"
},
"Int32": {
"value": {
"Int32": 6014

View File

@@ -61,4 +61,14 @@ return {
end,
},
},
Model = {
Scale = {
read = function(instance, _, _)
return true, instance:GetScale()
end,
write = function(instance, _, value)
return true, instance:ScaleTo(value)
end,
},
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,8 @@ local TestEZ = require(ReplicatedStorage.Packages.TestEZ)
local Rojo = ReplicatedStorage.Rojo
local DevSettings = require(Rojo.Plugin.DevSettings)
local setDevSettings = not DevSettings:hasChangedValues()
if setDevSettings then
DevSettings:createTestSettings()
end
local Settings = require(Rojo.Plugin.Settings)
Settings:set("logLevel", "Trace")
Settings:set("typecheckingEnabled", true)
require(Rojo.Plugin.runTests)(TestEZ)
if setDevSettings then
DevSettings:resetValues()
end

View File

@@ -24,8 +24,10 @@ local function BorderedContainer(props)
layoutOrder = props.layoutOrder,
}, {
Content = e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
Size = UDim2.new(1, -2, 1, -2),
Position = UDim2.new(0, 1, 0, 1),
BackgroundTransparency = 1,
ZIndex = 2,
}, props[Roact.Children]),
Border = e(SlicedImage, {

View File

@@ -10,6 +10,7 @@ local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil)
local SlicedImage = require(script.Parent.SlicedImage)
local Tooltip = require(script.Parent.Tooltip)
local e = Roact.createElement
@@ -52,6 +53,10 @@ function Checkbox:render()
[Roact.Event.Activated] = self.props.onClick,
}, {
StateTip = e(Tooltip.Trigger, {
text = if self.props.active then "Enabled" else "Disabled",
}),
Active = e(SlicedImage, {
slice = Assets.Slices.RoundedBackground,
color = theme.Active.BackgroundColor,

View File

@@ -0,0 +1,169 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil)
local SlicedImage = require(script.Parent.SlicedImage)
local ScrollingFrame = require(script.Parent.ScrollingFrame)
local e = Roact.createElement
local Dropdown = Roact.Component:extend("Dropdown")
function Dropdown:init()
self.openMotor = Flipper.SingleMotor.new(0)
self.openBinding = bindingUtil.fromMotor(self.openMotor)
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({
open = false,
})
end
function Dropdown:didUpdate()
self.openMotor:setGoal(
Flipper.Spring.new(self.state.open and 1 or 0, {
frequency = 6,
dampingRatio = 1.1,
})
)
end
function Dropdown:render()
return Theme.with(function(theme)
theme = theme.Dropdown
local optionButtons = {}
local width = -1
for i, option in self.props.options do
local text = tostring(option or "")
local textSize = TextService:GetTextSize(
text, 15, Enum.Font.GothamMedium,
Vector2.new(math.huge, 20)
)
if textSize.X > width then
width = textSize.X
end
optionButtons[text] = e("TextButton", {
Text = text,
LayoutOrder = i,
Size = UDim2.new(1, 0, 0, 24),
BackgroundColor3 = theme.BackgroundColor,
TextTransparency = self.props.transparency,
BackgroundTransparency = self.props.transparency,
BorderSizePixel = 0,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 15,
Font = Enum.Font.GothamMedium,
[Roact.Event.Activated] = function()
self:setState({
open = false,
})
self.props.onClick(option)
end,
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 6),
}),
})
end
return e("ImageButton", {
Size = UDim2.new(0, width+50, 0, 28),
Position = self.props.position,
AnchorPoint = self.props.anchorPoint,
LayoutOrder = self.props.layoutOrder,
ZIndex = self.props.zIndex,
BackgroundTransparency = 1,
[Roact.Event.Activated] = function()
self:setState({
open = not self.state.open,
})
end,
}, {
Border = e(SlicedImage, {
slice = Assets.Slices.RoundedBorder,
color = theme.BorderColor,
transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0),
}, {
DropArrow = e("ImageLabel", {
Image = Assets.Images.Dropdown.Arrow,
ImageColor3 = self.openBinding:map(function(a)
return theme.Closed.IconColor:Lerp(theme.Open.IconColor, a)
end),
ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 18, 0, 18),
Position = UDim2.new(1, -6, 0.5, 0),
AnchorPoint = Vector2.new(1, 0.5),
Rotation = self.openBinding:map(function(a)
return a * 180
end),
BackgroundTransparency = 1,
}),
Active = e("TextLabel", {
Size = UDim2.new(1, -30, 1, 0),
Position = UDim2.new(0, 6, 0, 0),
BackgroundTransparency = 1,
Text = self.props.active,
Font = Enum.Font.GothamMedium,
TextSize = 15,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
}),
}),
Options = if self.state.open then e(SlicedImage, {
slice = Assets.Slices.RoundedBackground,
color = theme.BackgroundColor,
position = UDim2.new(1, 0, 1, 3),
size = self.openBinding:map(function(a)
return UDim2.new(1, 0, a*math.min(3, #self.props.options), 0)
end),
anchorPoint = Vector2.new(1, 0),
}, {
Border = e(SlicedImage, {
slice = Assets.Slices.RoundedBorder,
color = theme.BorderColor,
transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0),
}),
ScrollingFrame = e(ScrollingFrame, {
size = UDim2.new(1, -4, 1, -4),
position = UDim2.new(0, 2, 0, 2),
transparency = self.props.transparency,
contentSize = self.contentSize,
}, {
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,
}),
Roact.createFragment(optionButtons),
}),
}) else nil,
})
end)
end
return Dropdown

View File

@@ -30,6 +30,7 @@ function IconButton:render()
Position = self.props.position,
AnchorPoint = self.props.anchorPoint,
Visible = self.props.visible,
LayoutOrder = self.props.layoutOrder,
ZIndex = self.props.zIndex,
BackgroundTransparency = 1,
@@ -74,6 +75,8 @@ function IconButton:render()
BackgroundTransparency = 1,
}),
Children = Roact.createFragment(self.props[Roact.Children]),
})
end

View File

@@ -0,0 +1,181 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local DisplayValue = require(script.Parent.DisplayValue)
local e = Roact.createElement
local ChangeList = Roact.Component:extend("ChangeList")
function ChangeList:init()
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
end
function ChangeList:render()
return Theme.with(function(theme)
local props = self.props
local changes = props.changes
-- Color alternating rows for readability
local rowTransparency = props.transparency:map(function(t)
return 0.93 + (0.07 * t)
end)
local columnVisibility = props.columnVisibility
local rows = {}
local pad = {
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}
local headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = rowTransparency,
BackgroundColor3 = theme.Diff.Row,
LayoutOrder = 0,
}, {
Padding = e("UIPadding", pad),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Visible = columnVisibility[1],
Text = tostring(changes[1][1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
B = e("TextLabel", {
Visible = columnVisibility[2],
Text = tostring(changes[1][2]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
}),
C = e("TextLabel", {
Visible = columnVisibility[3],
Text = tostring(changes[1][3]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
}),
})
for row, values in changes do
if row == 1 then
continue -- Skip headers, already handled above
end
rows[row] = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
BackgroundColor3 = theme.Diff.Row,
BorderSizePixel = 0,
LayoutOrder = row,
}, {
Padding = e("UIPadding", pad),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Visible = columnVisibility[1],
Text = tostring(values[1]),
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.3, 0, 1, 0),
LayoutOrder = 1,
}),
B = e(
"Frame",
{
Visible = columnVisibility[2],
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
},
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
})
),
C = e(
"Frame",
{
Visible = columnVisibility[3],
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
},
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
})
),
})
end
table.insert(
rows,
e("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Top,
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
})
)
return e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
}, {
Headers = headers,
Values = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, -30),
position = UDim2.new(0, 0, 0, 30),
contentSize = self.contentSize,
transparency = props.transparency,
}, rows),
})
end)
end
return ChangeList

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 Theme = require(Plugin.App.Theme)
local e = Roact.createElement
local function DisplayValue(props)
return Theme.with(function(theme)
local t = typeof(props.value)
if t == "Color3" then
-- Colors get a blot that shows the color
return Roact.createFragment({
Blot = e("Frame", {
BackgroundTransparency = props.transparency,
BackgroundColor3 = props.value,
Size = UDim2.new(0, 20, 0, 20),
Position = UDim2.new(0, 0, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 4),
}),
Stroke = e("UIStroke", {
Color = theme.BorderedContainer.BorderColor,
Transparency = props.transparency,
}),
}),
Label = e("TextLabel", {
Text = string.format("%d,%d,%d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
BackgroundTransparency = 1,
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(1, -25, 1, 0),
Position = UDim2.new(0, 25, 0, 0),
}),
})
elseif t == "table" then
-- Showing a memory address for tables is useless, so we want to show the best we can
local textRepresentation = nil
local meta = getmetatable(props.value)
if meta and meta.__tostring then
-- If the table has a tostring metamethod, use that
textRepresentation = tostring(props.value)
elseif next(props.value) == nil then
-- If it's empty, show empty braces
textRepresentation = "{}"
else
-- If it has children, list them out
local out, i = {}, 0
for k, v in pairs(props.value) do
i += 1
-- Wrap strings in quotes
if type(k) == "string" then
k = "\"" .. k .. "\""
end
if type(v) == "string" then
v = "\"" .. v .. "\""
end
out[i] = string.format("[%s] = %s", tostring(k), tostring(v))
end
textRepresentation = "{ " .. table.concat(out, ", ") .. " }"
end
return e("TextLabel", {
Text = textRepresentation,
BackgroundTransparency = 1,
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(1, 0, 1, 0),
})
end
-- TODO: Maybe add visualizations to other datatypes?
-- Or special text handling tostring for some?
-- Will add as needed, let's see what cases arise.
return e("TextLabel", {
Text = string.gsub(tostring(props.value), "%s", " "),
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(1, 0, 1, 0),
})
end)
end
return DisplayValue

View File

@@ -0,0 +1,180 @@
local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement
local ChangeList = require(script.Parent.ChangeList)
local Expansion = Roact.Component:extend("Expansion")
function Expansion:render()
local props = self.props
if not props.rendered then
return nil
end
return e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(1, -props.indent, 1, -30),
Position = UDim2.new(0, props.indent, 0, 30),
}, {
ChangeList = e(ChangeList, {
changes = props.changeList,
transparency = props.transparency,
columnVisibility = props.columnVisibility,
}),
})
end
local DomLabel = Roact.Component:extend("DomLabel")
function DomLabel:init()
self.maxElementHeight = 0
if self.props.changeList then
self.maxElementHeight = math.clamp(#self.props.changeList * 30, 30, 30 * 6)
end
local initHeight = self.props.elementHeight:getValue()
self.expanded = initHeight > 30
self.motor = Flipper.SingleMotor.new(initHeight)
self.binding = bindingUtil.fromMotor(self.motor)
self:setState({
renderExpansion = self.expanded,
})
self.motor:onStep(function(value)
local renderExpansion = value > 30
self.props.setElementHeight(value)
if self.props.updateEvent then
self.props.updateEvent:Fire()
end
self:setState(function(state)
if state.renderExpansion == renderExpansion then
return nil
end
return {
renderExpansion = renderExpansion,
}
end)
end)
end
function DomLabel:render()
local props = self.props
return Theme.with(function(theme)
local iconProps = StudioService:GetClassIcon(props.className)
local indent = (props.depth or 0) * 20 + 25
-- Line guides help indent depth remain readable
local lineGuides = {}
for i = 1, props.depth or 0 do
table.insert(
lineGuides,
e("Frame", {
Name = "Line_" .. i,
Size = UDim2.new(0, 2, 1, 2),
Position = UDim2.new(0, (20 * i) + 15, 0, -1),
BorderSizePixel = 0,
BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
})
)
end
return e("Frame", {
Name = "Change",
ClipsDescendants = true,
BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil,
BorderSizePixel = 0,
BackgroundTransparency = props.patchType and props.transparency or 1,
Size = self.binding:map(function(expand)
return UDim2.new(1, 0, 0, expand)
end),
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10),
}),
ExpandButton = if props.changeList
then e("TextButton", {
BackgroundTransparency = 1,
Text = "",
Size = UDim2.new(1, 0, 1, 0),
[Roact.Event.Activated] = function()
self.expanded = not self.expanded
self.motor:setGoal(Flipper.Spring.new((self.expanded and self.maxElementHeight or 0) + 30, {
frequency = 5,
dampingRatio = 1,
}))
end,
})
else nil,
Expansion = if props.changeList
then e(Expansion, {
rendered = self.state.renderExpansion,
indent = indent,
transparency = props.transparency,
changeList = props.changeList,
columnVisibility = props.columnVisibility,
})
else nil,
DiffIcon = if props.patchType
then e("ImageLabel", {
Image = Assets.Images.Diff[props.patchType],
ImageColor3 = theme.AddressEntry.PlaceholderColor,
ImageTransparency = props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 20, 0, 20),
Position = UDim2.new(0, 0, 0, 15),
AnchorPoint = Vector2.new(0, 0.5),
})
else nil,
ClassIcon = e("ImageLabel", {
Image = iconProps.Image,
ImageTransparency = props.transparency,
ImageRectOffset = iconProps.ImageRectOffset,
ImageRectSize = iconProps.ImageRectSize,
BackgroundTransparency = 1,
Size = UDim2.new(0, 20, 0, 20),
Position = UDim2.new(0, indent, 0, 15),
AnchorPoint = Vector2.new(0, 0.5),
}),
InstanceName = e("TextLabel", {
Text = props.name .. (props.hint and string.format(
' <font color="#%s">%s</font>',
theme.AddressEntry.PlaceholderColor:ToHex(),
props.hint
) or ""),
RichText = true,
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(1, -indent - 50, 0, 30),
Position = UDim2.new(0, indent + 30, 0, 0),
}),
LineGuides = e("Folder", nil, lineGuides),
})
end)
end
return DomLabel

View File

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

View File

@@ -5,6 +5,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Dictionary = require(Plugin.Dictionary)
local Theme = require(Plugin.App.Theme)
local StudioPluginContext = require(script.Parent.StudioPluginContext)
@@ -29,8 +30,10 @@ function StudioPluginGui:init()
self.props.initDockState,
self.props.active,
self.props.overridePreviousState,
floatingSize.X, floatingSize.Y,
minimumSize.X, minimumSize.Y
floatingSize.X,
floatingSize.Y,
minimumSize.X,
minimumSize.Y
)
local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(self.props.id, dockWidgetPluginGuiInfo)
@@ -57,7 +60,16 @@ end
function StudioPluginGui:render()
return e(Roact.Portal, {
target = self.pluginGui,
}, self.props[Roact.Children])
}, {
Background = Theme.with(function(theme)
return e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = theme.BackgroundColor,
ZIndex = 0,
BorderSizePixel = 0,
}, self.props[Roact.Children])
end),
})
end
function StudioPluginGui:didUpdate(lastProps)
@@ -75,9 +87,12 @@ end
local function StudioPluginGuiWrapper(props)
return e(StudioPluginContext.Consumer, {
render = function(plugin)
return e(StudioPluginGui, Dictionary.merge(props, {
plugin = plugin,
}))
return e(
StudioPluginGui,
Dictionary.merge(props, {
plugin = plugin,
})
)
end,
})
end

View File

@@ -131,6 +131,8 @@ function TextButton:render()
zIndex = -2,
}),
Children = Roact.createFragment(self.props[Roact.Children]),
})
end)
end

View File

@@ -0,0 +1,226 @@
local TextService = game:GetService("TextService")
local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local e = Roact.createElement
local DELAY = 0.75 -- How long to hover before a popup is shown (seconds)
local TEXT_PADDING = Vector2.new(8 * 2, 6 * 2) -- Padding for the popup text containers
local TAIL_SIZE = 16 -- Size of the triangle tail piece
local X_OFFSET = 30 -- How far right (from left) the tail will be (assuming enough space)
local Y_OVERLAP = 10 -- Let the triangle tail piece overlap the target a bit to help "connect" it
local TooltipContext = Roact.createContext({})
local function Popup(props)
local textSize = TextService:GetTextSize(
props.Text, 16, Enum.Font.GothamMedium, Vector2.new(math.min(props.parentSize.X, 160), math.huge)
) + TEXT_PADDING + (Vector2.one * 2)
local trigger = props.Trigger:getValue()
local spaceBelow = props.parentSize.Y - (trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y - Y_OVERLAP + TAIL_SIZE)
local spaceAbove = trigger.AbsolutePosition.Y + Y_OVERLAP - TAIL_SIZE
-- If there's not enough space below, and there's more space above, then show the tooltip above the trigger
local displayAbove = spaceBelow < textSize.Y and spaceAbove > spaceBelow
local X = math.clamp(props.Position.X - X_OFFSET, 0, props.parentSize.X - textSize.X)
local Y = 0
if displayAbove then
Y = math.max(trigger.AbsolutePosition.Y - TAIL_SIZE - textSize.Y + Y_OVERLAP, 0)
else
Y = math.min(trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y + TAIL_SIZE - Y_OVERLAP, props.parentSize.Y - textSize.Y)
end
return Theme.with(function(theme)
return e(BorderedContainer, {
position = UDim2.fromOffset(X, Y),
size = UDim2.fromOffset(textSize.X, textSize.Y),
transparency = props.transparency,
}, {
Label = e("TextLabel", {
BackgroundTransparency = 1,
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.new(1, -TEXT_PADDING.X, 1, -TEXT_PADDING.Y),
AnchorPoint = Vector2.new(0.5, 0.5),
Text = props.Text,
TextSize = 16,
Font = Enum.Font.GothamMedium,
TextWrapped = true,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = theme.Button.Bordered.Enabled.TextColor,
TextTransparency = props.transparency,
}),
Tail = e("ImageLabel", {
ZIndex = 100,
Position =
if displayAbove then
UDim2.new(
0, math.clamp(props.Position.X - X, 6, textSize.X-6),
1, -1
)
else
UDim2.new(
0, math.clamp(props.Position.X - X, 6, textSize.X-6),
0, -TAIL_SIZE+1
),
Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE),
AnchorPoint = Vector2.new(0.5, 0),
Rotation = if displayAbove then 180 else 0,
BackgroundTransparency = 1,
Image = "rbxassetid://10983945016",
ImageColor3 = theme.BorderedContainer.BackgroundColor,
ImageTransparency = props.transparency,
}, {
Border = e("ImageLabel", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
Image = "rbxassetid://10983946430",
ImageColor3 = theme.BorderedContainer.BorderColor,
ImageTransparency = props.transparency,
}),
})
})
end)
end
local Provider = Roact.Component:extend("TooltipManager")
function Provider:init()
self:setState({
tips = {},
addTip = function(id: string, data: { Text: string, Position: Vector2, Trigger: any })
self:setState(function(state)
state.tips[id] = data
return state
end)
end,
removeTip = function(id: string)
self:setState(function(state)
state.tips[id] = nil
return state
end)
end,
})
end
function Provider:render()
return Roact.createElement(TooltipContext.Provider, {
value = self.state,
}, self.props[Roact.Children])
end
local Container = Roact.Component:extend("TooltipContainer")
function Container:init()
self:setState({
size = Vector2.new(200, 100),
})
end
function Container:render()
return Roact.createElement(TooltipContext.Consumer, {
render = function(context)
local tips = context.tips
local popups = {}
for key, value in tips do
popups[key] = e(Popup, {
Text = value.Text or "",
Position = value.Position or Vector2.zero,
Trigger = value.Trigger,
parentSize = self.state.size,
})
end
return e("Frame", {
[Roact.Change.AbsoluteSize] = function(rbx)
self:setState({
size = rbx.AbsoluteSize,
})
end,
ZIndex = 100,
BackgroundTransparency = 1,
Size = UDim2.fromScale(1, 1),
}, popups)
end,
})
end
local Trigger = Roact.Component:extend("TooltipTrigger")
function Trigger:init()
self.id = HttpService:GenerateGUID(false)
self.ref = Roact.createRef()
self.mousePos = Vector2.zero
self.destroy = function()
self.props.context.removeTip(self.id)
end
end
function Trigger:willUnmount()
if self.showDelayThread then
task.cancel(self.showDelayThread)
end
if self.destroy then
self.destroy()
end
end
function Trigger:render()
return e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
ZIndex = self.props.zIndex or 100,
[Roact.Ref] = self.ref,
[Roact.Event.MouseMoved] = function(_rbx, x, y)
self.mousePos = Vector2.new(x, y)
end,
[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
local function TriggerConsumer(props)
return Roact.createElement(TooltipContext.Consumer, {
render = function(context)
local innerProps = table.clone(props)
innerProps.context = context
return e(Trigger, innerProps)
end,
})
end
return {
Provider = Provider,
Container = Container,
Trigger = TriggerConsumer,
}

View File

@@ -0,0 +1,156 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement
local VirtualScroller = Roact.Component:extend("VirtualScroller")
function VirtualScroller:init()
self.scrollFrameRef = Roact.createRef()
self:setState({
WindowSize = Vector2.new(),
CanvasPosition = Vector2.new(),
})
self.totalCanvas, self.setTotalCanvas = Roact.createBinding(0)
self.padding, self.setPadding = Roact.createBinding(0)
self:refresh()
if self.props.updateEvent then
self.connection = self.props.updateEvent:Connect(function()
self:refresh()
end)
end
end
function VirtualScroller:didMount()
local rbx = self.scrollFrameRef:getValue()
local windowSizeSignal = rbx:GetPropertyChangedSignal("AbsoluteWindowSize")
self.windowSizeChanged = windowSizeSignal:Connect(function()
self:setState({ WindowSize = rbx.AbsoluteWindowSize })
self:refresh()
end)
local canvasPositionSignal = rbx:GetPropertyChangedSignal("CanvasPosition")
self.canvasPositionChanged = canvasPositionSignal:Connect(function()
if math.abs(rbx.CanvasPosition.Y - self.state.CanvasPosition.Y) > 5 then
self:setState({ CanvasPosition = rbx.CanvasPosition })
self:refresh()
end
end)
self:refresh()
end
function VirtualScroller:willUnmount()
self.windowSizeChanged:Disconnect()
self.canvasPositionChanged:Disconnect()
if self.connection then
self.connection:Disconnect()
self.connection = nil
end
end
function VirtualScroller:refresh()
local props = self.props
local state = self.state
local count = props.count
local windowSize, canvasPosition = state.WindowSize.Y, state.CanvasPosition.Y
local bottom = canvasPosition + windowSize
local minIndex, maxIndex = 1, count
local padding, canvasSize = 0, 0
local pos = 0
for i = 1, count do
local height = props.getHeightBinding(i):getValue()
canvasSize += height
if pos > bottom then
-- Below window
if maxIndex > i then
maxIndex = i
end
end
pos += height
if pos < canvasPosition then
-- Above window
minIndex = i
padding = pos - height
end
end
self.setPadding(padding)
self.setTotalCanvas(canvasSize)
self:setState({
Start = minIndex,
End = maxIndex,
})
end
function VirtualScroller:render()
local props, state = self.props, self.state
local items = {}
for i = state.Start, state.End do
items["Item" .. i] = e("Frame", {
LayoutOrder = i,
Size = props.getHeightBinding(i):map(function(height)
return UDim2.new(1, 0, 0, height)
end),
BackgroundTransparency = 1,
}, props.render(i))
end
return Theme.with(function(theme)
return e("ScrollingFrame", {
Size = props.size,
Position = props.position,
AnchorPoint = props.anchorPoint,
BackgroundTransparency = props.backgroundTransparency or 1,
BackgroundColor3 = props.backgroundColor3,
BorderColor3 = props.borderColor3,
CanvasSize = self.totalCanvas:map(function(s)
return UDim2.fromOffset(0, s)
end),
ScrollBarThickness = 9,
ScrollBarImageColor3 = theme.ScrollBarColor,
ScrollBarImageTransparency = props.transparency:map(function(value)
return bindingUtil.blendAlpha({ 0.65, value })
end),
TopImage = Assets.Images.ScrollBar.Top,
MidImage = Assets.Images.ScrollBar.Middle,
BottomImage = Assets.Images.ScrollBar.Bottom,
ElasticBehavior = Enum.ElasticBehavior.Always,
ScrollingDirection = Enum.ScrollingDirection.Y,
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
[Roact.Ref] = self.scrollFrameRef,
}, {
Layout = e("UIListLayout", {
Padding = UDim.new(0, 0),
SortOrder = Enum.SortOrder.LayoutOrder,
FillDirection = Enum.FillDirection.Vertical,
}),
Padding = e("UIPadding", {
PaddingTop = self.padding:map(function(p)
return UDim.new(0, p)
end),
}),
Content = Roact.createFragment(items),
})
end)
end
return VirtualScroller

View File

@@ -0,0 +1,156 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local e = Roact.createElement
local ConfirmingPage = Roact.Component:extend("ConfirmingPage")
function ConfirmingPage:init()
self.contentSize, self.setContentSize = Roact.createBinding(0)
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
end
function ConfirmingPage:render()
return Theme.with(function(theme)
local pageContent = Roact.createFragment({
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
}),
Title = e("TextLabel", {
Text = string.format(
"Sync changes for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN"
),
LayoutOrder = 2,
Font = Enum.Font.Gotham,
LineHeight = 1.2,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 20),
BackgroundTransparency = 1,
}),
PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, -150),
transparency = self.props.transparency,
layoutOrder = 3,
columnVisibility = {true, true, true},
patch = self.props.confirmData.patch,
instanceMap = self.props.confirmData.instanceMap,
}),
Buttons = e("Frame", {
Size = UDim2.new(1, 0, 0, 34),
LayoutOrder = 4,
BackgroundTransparency = 1,
}, {
Abort = e(TextButton, {
text = "Abort",
style = "Bordered",
transparency = self.props.transparency,
layoutOrder = 1,
onClick = self.props.onAbort,
}, {
Tip = e(Tooltip.Trigger, {
text = "Stop the connection process"
}),
}),
Reject = if Settings:get("twoWaySync")
then e(TextButton, {
text = "Reject",
style = "Bordered",
transparency = self.props.transparency,
layoutOrder = 2,
onClick = self.props.onReject,
}, {
Tip = e(Tooltip.Trigger, {
text = "Push Studio changes to the Rojo server"
}),
})
else nil,
Accept = e(TextButton, {
text = "Accept",
style = "Solid",
transparency = self.props.transparency,
layoutOrder = 3,
onClick = self.props.onAccept,
}, {
Tip = e(Tooltip.Trigger, {
text = "Pull Rojo server changes to Studio"
}),
}),
Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Right,
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
}),
Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
})
if self.props.createPopup then
return e(StudioPluginGui, {
id = "Rojo_DiffSync",
title = string.format(
"Confirm sync for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN"
),
active = true,
initDockState = Enum.InitialDockState.Float,
initEnabled = true,
overridePreviousState = true,
floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = self.props.onAbort,
}, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, pageContent),
})
end
return pageContent
end)
end
return ConfirmingPage

View File

@@ -3,13 +3,18 @@ local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local bindingUtil = require(Plugin.App.bindingUtil)
local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets)
local PatchSet = require(Plugin.PatchSet)
local Header = require(Plugin.App.Components.Header)
local IconButton = require(Plugin.App.Components.IconButton)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local e = Roact.createElement
@@ -33,6 +38,50 @@ function timeSinceText(elapsed: number): string
return ageText
end
local function ChangesDrawer(props)
if props.rendered == false then
return nil
end
return Theme.with(function(theme)
return e(BorderedContainer, {
transparency = props.transparency,
size = props.height:map(function(y)
return UDim2.new(1, 0, y, -180 * y)
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, {
text = "Close the patch visualizer"
}),
}),
PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, 0),
transparency = props.transparency,
layoutOrder = 3,
columnVisibility = {true, false, true},
patch = props.patchInfo:getValue().patch,
instanceMap = props.serveSession.__instanceMap,
}),
})
end)
end
local function ConnectionDetails(props)
return Theme.with(function(theme)
return e(BorderedContainer, {
@@ -90,6 +139,10 @@ local function ConnectionDetails(props)
anchorPoint = Vector2.new(1, 0.5),
onClick = props.onDisconnect,
}, {
Tip = e(Tooltip.Trigger, {
text = "Disconnect from the Rojo sync server"
}),
}),
Padding = e("UIPadding", {
@@ -102,9 +155,44 @@ end
local ConnectedPage = Roact.Component:extend("ConnectedPage")
function ConnectedPage:init()
self.changeDrawerMotor = Flipper.SingleMotor.new(0)
self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor)
self.changeDrawerMotor:onStep(function(value)
local renderChanges = value > 0.05
self:setState(function(state)
if state.renderChanges == renderChanges then
return nil
end
return {
renderChanges = renderChanges,
}
end)
end)
self:setState({
renderChanges = false,
})
end
function ConnectedPage:render()
return Theme.with(function(theme)
return Roact.createFragment({
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
@@ -119,12 +207,13 @@ function ConnectedPage:render()
onDisconnect = self.props.onDisconnect,
}),
Info = e("TextLabel", {
ChangeInfo = e("TextButton", {
Text = self.props.patchInfo:map(function(info)
local changes = PatchSet.countChanges(info.patch)
return string.format(
"<i>Synced %d change%s %s</i>",
info.changes,
info.changes == 1 and "" or "s",
changes,
changes == 1 and "" or "s",
timeSinceText(os.time() - info.timestamp)
)
end),
@@ -141,18 +230,36 @@ function ConnectedPage:render()
LayoutOrder = 3,
BackgroundTransparency = 1,
[Roact.Event.Activated] = function()
if self.state.renderChanges then
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
frequency = 4,
dampingRatio = 1,
}))
else
self.changeDrawerMotor:setGoal(Flipper.Spring.new(1, {
frequency = 3,
dampingRatio = 1,
}))
end
end,
}),
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
ChangesDrawer = e(ChangesDrawer, {
rendered = self.state.renderChanges,
transparency = self.props.transparency,
patchInfo = self.props.patchInfo,
serveSession = self.props.serveSession,
height = self.changeDrawerHeight,
layoutOrder = 4,
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
onClose = function()
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
frequency = 4,
dampingRatio = 1,
}))
end,
}),
})
end)

View File

@@ -11,6 +11,7 @@ local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local Tooltip = require(Plugin.App.Components.Tooltip)
local e = Roact.createElement
@@ -123,6 +124,10 @@ function ErrorPage:render()
transparency = self.props.transparency,
layoutOrder = 1,
onClick = self.props.onClose,
}, {
Tip = e(Tooltip.Trigger, {
text = "Dismiss message"
}),
}),
Layout = e("UIListLayout", {

View File

@@ -10,6 +10,7 @@ local Theme = require(Plugin.App.Theme)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PORT_WIDTH = 74
local DIVIDER_WIDTH = 1
@@ -109,6 +110,7 @@ function NotConnectedPage:render()
Size = UDim2.new(1, 0, 0, 34),
LayoutOrder = 3,
BackgroundTransparency = 1,
ZIndex = 2,
}, {
Settings = e(TextButton, {
text = "Settings",
@@ -116,6 +118,10 @@ function NotConnectedPage:render()
transparency = self.props.transparency,
layoutOrder = 1,
onClick = self.props.onNavigateSettings,
}, {
Tip = e(Tooltip.Trigger, {
text = "View and modify plugin settings"
}),
}),
Connect = e(TextButton, {
@@ -124,6 +130,10 @@ function NotConnectedPage:render()
transparency = self.props.transparency,
layoutOrder = 2,
onClick = self.props.onConnect,
}, {
Tip = e(Tooltip.Trigger, {
text = "Connect to a Rojo sync server"
}),
}),
Layout = e("UIListLayout", {

View File

@@ -7,9 +7,12 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Settings = require(Plugin.Settings)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local Checkbox = require(Plugin.App.Components.Checkbox)
local Dropdown = require(Plugin.App.Components.Dropdown)
local IconButton = require(Plugin.App.Components.IconButton)
local e = Roact.createElement
@@ -54,22 +57,48 @@ function Setting:render()
return UDim2.new(1, 0, 0, 20 + value.Y + 20)
end),
LayoutOrder = self.props.layoutOrder,
ZIndex = -self.props.layoutOrder,
BackgroundTransparency = 1,
[Roact.Change.AbsoluteSize] = function(object)
self.setContainerSize(object.AbsoluteSize)
end,
}, {
Checkbox = e(Checkbox, {
active = self.state.setting,
Input = if self.props.options ~= nil then
e(Dropdown, {
options = self.props.options,
active = self.state.setting,
transparency = self.props.transparency,
position = UDim2.new(1, 0, 0.5, 0),
anchorPoint = Vector2.new(1, 0.5),
onClick = function(option)
Settings:set(self.props.id, option)
end,
})
else
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,
}),
Reset = if self.props.onReset then e(IconButton, {
icon = Assets.Images.Icons.Reset,
iconSize = 24,
color = theme.BackButtonColor,
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,
}),
visible = self.props.showReset,
position = UDim2.new(1, -32 - (self.props.options ~= nil and 120 or 40), 0.5, 0),
anchorPoint = Vector2.new(0, 0.5),
onClick = self.props.onReset,
}) else nil,
Text = e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
@@ -100,11 +129,12 @@ function Setting:render()
TextWrapped = true,
Size = self.containerSize:map(function(value)
local offset = (self.props.onReset and 34 or 0) + (self.props.options ~= nil and 120 or 40)
local textBounds = getTextBounds(
self.props.description, 14, Enum.Font.Gotham, 1.2,
Vector2.new(value.X - 50, math.huge)
Vector2.new(value.X - offset, math.huge)
)
return UDim2.new(1, -50, 0, textBounds.Y)
return UDim2.new(1, -offset, 0, textBounds.Y)
end),
LayoutOrder = 2,

View File

@@ -3,16 +3,29 @@ local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Assets = require(Plugin.Assets)
local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme)
local IconButton = require(Plugin.App.Components.IconButton)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local Tooltip = require(Plugin.App.Components.Tooltip)
local Setting = require(script.Setting)
local e = Roact.createElement
local function invertTbl(tbl)
local new = {}
for key, value in tbl do
new[value] = key
end
return new
end
local invertedLevels = invertTbl(Log.Level)
local function Navbar(props)
return Theme.with(function(theme)
theme = theme.Settings.Navbar
@@ -32,6 +45,10 @@ local function Navbar(props)
anchorPoint = Vector2.new(0, 0.5),
onClick = props.onBack,
}, {
Tip = e(Tooltip.Trigger, {
text = "Back"
}),
}),
Text = e("TextLabel", {
@@ -102,6 +119,30 @@ function SettingsPage:render()
layoutOrder = 4,
}),
LogLevel = e(Setting, {
id = "logLevel",
name = "Log Level",
description = "Plugin output verbosity level",
transparency = self.props.transparency,
layoutOrder = 5,
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",
transparency = self.props.transparency,
layoutOrder = 6,
}),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,

View File

@@ -2,6 +2,7 @@ return {
NotConnected = require(script.NotConnected),
Settings = require(script.Settings),
Connecting = require(script.Connecting),
Confirming = require(script.Confirming),
Connected = require(script.Connected),
Error = require(script.Error),
}
}

View File

@@ -72,6 +72,17 @@ local lightTheme = strict("LightTheme", {
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)
@@ -84,6 +95,12 @@ local lightTheme = strict("LightTheme", {
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),
@@ -150,6 +167,17 @@ local darkTheme = strict("DarkTheme", {
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)
@@ -162,6 +190,12 @@ local darkTheme = strict("DarkTheme", {
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),

View File

@@ -16,12 +16,14 @@ local strict = require(Plugin.strict)
local Dictionary = require(Plugin.Dictionary)
local ServeSession = require(Plugin.ServeSession)
local ApiContext = require(Plugin.ApiContext)
local PatchSet = require(Plugin.PatchSet)
local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer)
local Theme = require(script.Theme)
local Page = require(script.Page)
local Notifications = require(script.Notifications)
local Tooltip = require(script.Components.Tooltip)
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
local StudioToolbar = require(script.Components.Studio.StudioToolbar)
local StudioToggleButton = require(script.Components.Studio.StudioToggleButton)
@@ -33,6 +35,7 @@ local AppStatus = strict("AppStatus", {
NotConnected = "NotConnected",
Settings = "Settings",
Connecting = "Connecting",
Confirming = "Confirming",
Connected = "Connected",
Error = "Error",
})
@@ -44,16 +47,21 @@ local App = Roact.Component:extend("App")
function App:init()
preloadAssets()
self.host, self.setHost = Roact.createBinding("")
self.port, self.setPort = Roact.createBinding("")
local priorHost, priorPort = self:getPriorEndpoint()
self.host, self.setHost = Roact.createBinding(priorHost or "")
self.port, self.setPort = Roact.createBinding(priorPort or "")
self.patchInfo, self.setPatchInfo = Roact.createBinding({
changes = 0,
patch = PatchSet.newEmpty(),
timestamp = os.time(),
})
self.confirmationBindable = Instance.new("BindableEvent")
self.confirmationEvent = self.confirmationBindable.Event
self:setState({
appStatus = AppStatus.NotConnected,
guiEnabled = false,
confirmData = {},
notifications = {},
toolbarIcon = Assets.Images.PluginButton,
})
@@ -85,6 +93,45 @@ function App:closeNotification(index: number)
})
end
function App:getPriorEndpoint()
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end
local place = priorEndpoints[tostring(game.PlaceId)]
if not place then return end
return place.host, place.port
end
function App:setPriorEndpoint(host: string, port: string)
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then
priorEndpoints = {}
end
-- Clear any stale saves to avoid disc bloat
for placeId, endpoint in priorEndpoints do
if os.time() - endpoint.timestamp > 12_960_000 then
priorEndpoints[placeId] = nil
Log.trace("Cleared stale saved endpoint for {}", placeId)
end
end
if host == Config.defaultHost and port == Config.defaultPort then
-- Don't save default
priorEndpoints[tostring(game.PlaceId)] = nil
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
Settings:set("priorEndpoints", priorEndpoints)
end
function App:getHostAndPort()
local host = self.host:getValue()
local port = self.port:getValue()
@@ -161,7 +208,9 @@ function App:startSession()
twoWaySync = Settings:get("twoWaySync"),
}
local baseUrl = ("http://%s:%s"):format(host, port)
local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port)
local apiContext = ApiContext.new(baseUrl)
local serveSession = ServeSession.new({
@@ -170,36 +219,37 @@ function App:startSession()
twoWaySync = sessionOptions.twoWaySync,
})
serveSession:onPatchApplied(function(patch, unapplied)
serveSession:onPatchApplied(function(patch, _unapplied)
if PatchSet.isEmpty(patch) then
-- Ignore empty patches
return
end
local now = os.time()
local changes = 0
for _, set in patch do
for _ in set do
changes += 1
end
end
for _, set in unapplied do
for _ in set do
changes -= 1
end
end
if changes == 0 then return end
local old = self.patchInfo:getValue()
if now - old.timestamp < 2 then
changes += old.changes
end
-- Patches that apply in the same second are
-- considered to be part of the same change for human clarity
local merged = PatchSet.newEmpty()
PatchSet.assign(merged, old.patch, patch)
self.setPatchInfo({
changes = changes,
timestamp = now,
})
self.setPatchInfo({
patch = merged,
timestamp = now,
})
else
self.setPatchInfo({
patch = patch,
timestamp = now,
})
end
end)
serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then
self:setPriorEndpoint(host, port)
self:setState({
appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton,
@@ -239,6 +289,32 @@ function App:startSession()
end
end)
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
if PatchSet.isEmpty(patch) then
return "Accept"
end
self:setState({
appStatus = AppStatus.Confirming,
confirmData = {
instanceMap = instanceMap,
patch = patch,
serverInfo = serverInfo,
},
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification(
string.format(
"Please accept%sor abort the initializing sync session.",
Settings:get("twoWaySync") and ", reject, " or " "
),
7
)
return self.confirmationEvent:Wait()
end)
serveSession:start()
self.serveSession = serveSession
@@ -249,7 +325,7 @@ function App:startSession()
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)
task.wait(elapsed < 60 and 1 or elapsed / 5)
end
end)
end
@@ -288,108 +364,119 @@ function App:render()
value = self.props.plugin,
}, {
e(Theme.StudioProvider, nil, {
gui = e(StudioPluginGui, {
id = pluginName,
title = pluginName,
active = self.state.guiEnabled,
e(Tooltip.Provider, nil, {
gui = e(StudioPluginGui, {
id = pluginName,
title = pluginName,
active = self.state.guiEnabled,
initDockState = Enum.InitialDockState.Right,
initEnabled = false,
overridePreviousState = false,
floatingSize = Vector2.new(300, 200),
minimumSize = Vector2.new(300, 200),
initDockState = Enum.InitialDockState.Right,
initEnabled = false,
overridePreviousState = false,
floatingSize = Vector2.new(300, 200),
minimumSize = Vector2.new(300, 120),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onInitialState = function(initialState)
self:setState({
guiEnabled = initialState,
})
end,
onClose = function()
self:setState({
guiEnabled = false,
})
end,
}, {
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
host = self.host,
onHostChange = self.setHost,
port = self.port,
onPortChange = self.setPort,
onConnect = function()
self:startSession()
end,
onNavigateSettings = function()
onInitialState = function(initialState)
self:setState({
appStatus = AppStatus.Settings,
guiEnabled = initialState,
})
end,
}),
Connecting = createPageElement(AppStatus.Connecting),
Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName,
address = self.state.address,
patchInfo = self.patchInfo,
onDisconnect = function()
self:endSession()
end,
}),
Settings = createPageElement(AppStatus.Settings, {
onBack = function()
self:setState({
appStatus = AppStatus.NotConnected,
})
end,
}),
Error = createPageElement(AppStatus.Error, {
errorMessage = self.state.errorMessage,
onClose = function()
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
guiEnabled = false,
})
end,
}, {
Tooltips = e(Tooltip.Container, nil),
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
host = self.host,
onHostChange = self.setHost,
port = self.port,
onPortChange = self.setPort,
onConnect = function()
self:startSession()
end,
onNavigateSettings = function()
self:setState({
appStatus = AppStatus.Settings,
})
end,
}),
ConfirmingPage = createPageElement(AppStatus.Confirming, {
confirmData = self.state.confirmData,
createPopup = not self.state.guiEnabled,
onAbort = function()
self.confirmationBindable:Fire("Abort")
end,
onAccept = function()
self.confirmationBindable:Fire("Accept")
end,
onReject = function()
self.confirmationBindable:Fire("Reject")
end,
}),
Connecting = createPageElement(AppStatus.Connecting),
Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName,
address = self.state.address,
patchInfo = self.patchInfo,
serveSession = self.serveSession,
onDisconnect = function()
self:endSession()
end,
}),
Settings = createPageElement(AppStatus.Settings, {
onBack = function()
self:setState({
appStatus = AppStatus.NotConnected,
})
end,
}),
Error = createPageElement(AppStatus.Error, {
errorMessage = self.state.errorMessage,
onClose = function()
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
})
end,
}),
}),
Background = Theme.with(function(theme)
return e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = theme.BackgroundColor,
ZIndex = 0,
BorderSizePixel = 0,
})
end),
}),
RojoNotifications = e("ScreenGui", {}, {
layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 5),
}),
padding = e("UIPadding", {
PaddingTop = UDim.new(0, 5);
PaddingBottom = UDim.new(0, 5);
PaddingLeft = UDim.new(0, 5);
PaddingRight = UDim.new(0, 5);
}),
notifs = e(Notifications, {
soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications,
onClose = function(index)
self:closeNotification(index)
end,
RojoNotifications = e("ScreenGui", {}, {
layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 5),
}),
padding = e("UIPadding", {
PaddingTop = UDim.new(0, 5),
PaddingBottom = UDim.new(0, 5),
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
notifs = e(Notifications, {
soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications,
onClose = function(index)
self:closeNotification(index)
end,
}),
}),
}),
@@ -402,7 +489,9 @@ function App:render()
onTriggered = function()
if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then
self:startSession()
elseif self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected then
elseif
self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected
then
self:endSession()
end
end,
@@ -450,7 +539,7 @@ function App:render()
}
end)
end,
})
}),
}),
}),
})

View File

@@ -23,11 +23,20 @@ local Assets = {
Icons = {
Close = "rbxassetid://6012985953",
Back = "rbxassetid://6017213752",
Reset = "rbxassetid://10142422327",
},
Diff = {
Add = "rbxassetid://10434145835",
Remove = "rbxassetid://10434408368",
Edit = "rbxassetid://10434144680",
},
Checkbox = {
Active = "rbxassetid://6016251644",
Inactive = "rbxassetid://6016251963",
},
Dropdown = {
Arrow = "rbxassetid://10131770538",
},
Spinner = {
Foreground = "rbxassetid://3222731032",
Background = "rbxassetid://3222730627",

View File

@@ -9,5 +9,5 @@ return strict("Config", {
expectedServerVersionString = "7.2 or newer",
protocolVersion = 4,
defaultHost = "localhost",
defaultPort = 34872,
})
defaultPort = "34872",
})

View File

@@ -1,139 +0,0 @@
local Config = require(script.Parent.Config)
local Environment = {
User = "User",
Dev = "Dev",
Test = "Test",
}
local DEFAULT_ENVIRONMENT = Config.isDevBuild and Environment.Dev or Environment.User
local VALUES = {
LogLevel = {
type = "IntValue",
values = {
[Environment.User] = 2,
[Environment.Dev] = 4,
[Environment.Test] = 4,
},
},
TypecheckingEnabled = {
type = "BoolValue",
values = {
[Environment.User] = false,
[Environment.Dev] = true,
[Environment.Test] = true,
},
},
}
local CONTAINER_NAME = "RojoDevSettings" .. Config.codename
local function getValueContainer()
return game:FindFirstChild(CONTAINER_NAME)
end
local valueContainer = getValueContainer()
game.ChildAdded:Connect(function(child)
local success, name = pcall(function()
return child.Name
end)
if success and name == CONTAINER_NAME then
valueContainer = child
end
end)
local function getStoredValue(name)
if valueContainer == nil then
return nil
end
local valueObject = valueContainer:FindFirstChild(name)
if valueObject == nil then
return nil
end
return valueObject.Value
end
local function setStoredValue(name, kind, value)
local object = valueContainer:FindFirstChild(name)
if object == nil then
object = Instance.new(kind)
object.Name = name
object.Parent = valueContainer
end
object.Value = value
end
local function createAllValues(environment)
assert(Environment[environment] ~= nil, "Invalid environment")
valueContainer = getValueContainer()
if valueContainer == nil then
valueContainer = Instance.new("Folder")
valueContainer.Name = CONTAINER_NAME
valueContainer.Parent = game
end
for name, value in pairs(VALUES) do
setStoredValue(name, value.type, value.values[environment])
end
end
local function getValue(name)
assert(VALUES[name] ~= nil, "Invalid DevSettings name")
local stored = getStoredValue(name)
if stored ~= nil then
return stored
end
return VALUES[name].values[DEFAULT_ENVIRONMENT]
end
local DevSettings = {}
function DevSettings:createDevSettings()
createAllValues(Environment.Dev)
end
function DevSettings:createTestSettings()
createAllValues(Environment.Test)
end
function DevSettings:hasChangedValues()
return valueContainer ~= nil
end
function DevSettings:resetValues()
if valueContainer then
valueContainer:Destroy()
valueContainer = nil
end
end
function DevSettings:isEnabled()
return valueContainer ~= nil
end
function DevSettings:getLogLevel()
return getValue("LogLevel")
end
function DevSettings:shouldTypecheck()
return getValue("TypecheckingEnabled")
end
function _G.ROJO_DEV_CREATE()
DevSettings:createDevSettings()
end
return DevSettings

View File

@@ -8,6 +8,40 @@ local t = require(Packages.t)
local Types = require(script.Parent.Types)
local function deepEqual(a: any, b: any): boolean
local typeA = typeof(a)
if typeA ~= typeof(b) then
return false
end
if typeof(a) == "table" then
local checkedKeys = {}
for key, value in a do
checkedKeys[key] = true
if deepEqual(value, b[key]) == false then
return false
end
end
for key, value in b do
if checkedKeys[key] then continue end
if deepEqual(value, a[key]) == false then
return false
end
end
return true
end
if a == b then
return true
end
return false
end
local PatchSet = {}
PatchSet.validate = t.interface({
@@ -57,6 +91,32 @@ function PatchSet.hasUpdates(patchSet)
return next(patchSet.updated) ~= nil
end
--[[
Tells whether the given PatchSets are equal.
]]
function PatchSet.isEqual(patchA, patchB)
return deepEqual(patchA, patchB)
end
--[[
Count the number of changes in the given PatchSet.
]]
function PatchSet.countChanges(patch)
local count = 0
for _ in patch.added do
count += 1
end
for _ in patch.removed do
count += 1
end
for _ in patch.updated do
count += 1
end
return count
end
--[[
Merge multiple PatchSet objects into the given PatchSet.
]]

View File

@@ -15,6 +15,86 @@ local function isEmpty(table)
return next(table) == nil
end
local function fuzzyEq(a: number, b: number, epsilon: number): boolean
return math.abs(a - b) < epsilon
end
local function trueEquals(a, b): boolean
-- Exit early for simple equality values
if a == b then
return true
end
local typeA, typeB = typeof(a), typeof(b)
-- For tables, try recursive deep equality
if typeA == "table" and typeB == "table" then
local checkedKeys = {}
for key, value in pairs(a) do
checkedKeys[key] = true
if not trueEquals(value, b[key]) then
return false
end
end
for key, value in pairs(b) do
if checkedKeys[key] then continue end
if not trueEquals(value, a[key]) then
return false
end
end
return true
-- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "number" and typeB == "number" then
return fuzzyEq(a, b, 0.0001)
-- For EnumItem->number, compare the EnumItem's value
elseif typeA == "number" and typeB == "EnumItem" then
return a == b.Value
elseif typeA == "EnumItem" and typeB == "number" then
return a.Value == b
-- For Color3s, compare to RGB ints to avoid floating point inequality
elseif typeA == "Color3" and typeB == "Color3" then
local aR, aG, aB = math.floor(a.R * 255), math.floor(a.G * 255), math.floor(a.B * 255)
local bR, bG, bB = math.floor(b.R * 255), math.floor(b.G * 255), math.floor(b.B * 255)
return aR == bR and aG == bG and aB == bB
-- For CFrames, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "CFrame" and typeB == "CFrame" then
local aComponents, bComponents = {a:GetComponents()}, {b:GetComponents()}
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
-- For Vector3s, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "Vector3" and typeB == "Vector3" then
local aComponents, bComponents = {a.X, a.Y, a.Z}, {b.X, b.Y, b.Z}
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
-- For Vector2s, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "Vector2" and typeB == "Vector2" then
local aComponents, bComponents = {a.X, a.Y}, {b.X, b.Y}
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
end
return false
end
local function shouldDeleteUnknownInstances(virtualInstance)
if virtualInstance.Metadata ~= nil then
return not virtualInstance.Metadata.ignoreUnknownInstances
@@ -73,7 +153,8 @@ local function diff(instanceMap, virtualInstances, rootId)
local ok, decodedValue = decodeValue(virtualValue, instanceMap)
if ok then
if existingValue ~= decodedValue then
if not trueEquals(existingValue, decodedValue) then
Log.debug("{}.{} changed from '{}' to '{}'", instance:GetFullName(), propertyName, existingValue, decodedValue)
changedProperties[propertyName] = virtualValue
end
else

View File

@@ -5,8 +5,10 @@ local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log)
local Fmt = require(Packages.Fmt)
local t = require(Packages.t)
local Promise = require(Packages.Promise)
local ChangeBatcher = require(script.Parent.ChangeBatcher)
local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate)
local InstanceMap = require(script.Parent.InstanceMap)
local PatchSet = require(script.Parent.PatchSet)
local Reconciler = require(script.Parent.Reconciler)
@@ -123,6 +125,10 @@ function ServeSession:onStatusChanged(callback)
self.__statusChangedCallback = callback
end
function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback
end
function ServeSession:onPatchApplied(callback)
self.__patchAppliedCallback = callback
end
@@ -132,13 +138,12 @@ function ServeSession:start()
self.__apiContext:connect()
:andThen(function(serverInfo)
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
local rootInstanceId = serverInfo.rootInstanceId
return self:__initialSync(rootInstanceId)
return self:__initialSync(serverInfo)
:andThen(function()
self:__setStatus(Status.Connected, serverInfo.projectName)
return self:__mainSyncLoop()
end)
end)
@@ -202,8 +207,8 @@ function ServeSession:__onActiveScriptChanged(activeScript)
self.__apiContext:open(scriptId)
end
function ServeSession:__initialSync(rootInstanceId)
return self.__apiContext:read({ rootInstanceId })
function ServeSession:__initialSync(serverInfo)
return self.__apiContext:read({ serverInfo.rootInstanceId })
:andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of
-- the tree defined in this response.
@@ -212,14 +217,14 @@ function ServeSession:__initialSync(rootInstanceId)
-- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs")
self.__reconciler:hydrate(readResponseBody.instances, rootInstanceId, game)
self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game)
-- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like.
Log.trace("Computing changes that plugin needs to make to catch up to server...")
local success, catchUpPatch = self.__reconciler:diff(
readResponseBody.instances,
rootInstanceId,
serverInfo.rootInstanceId,
game
)
@@ -229,19 +234,50 @@ function ServeSession:__initialSync(rootInstanceId)
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
-- TODO: Prompt user to notify them of this patch, since it's
-- effectively a conflict between the Rojo server and the client. In
-- the future, we'll ask which changes the user wants to keep.
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
if not PatchSet.isEmpty(unappliedPatch) then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
local userDecision = "Accept"
if self.__userConfirmCallback ~= nil then
userDecision = self.__userConfirmCallback(self.__instanceMap, catchUpPatch, serverInfo)
end
if self.__patchAppliedCallback then
pcall(self.__patchAppliedCallback, catchUpPatch, unappliedPatch)
if userDecision == "Abort" then
return Promise.reject("Aborted Rojo sync operation")
elseif userDecision == "Reject" and self.__twoWaySync then
-- The user wants their studio DOM to write back to their Rojo DOM
-- so we will reverse the patch and send it back
local inversePatch = PatchSet.newEmpty()
-- Send back the current properties
for _, change in catchUpPatch.updated do
local instance = self.__instanceMap.fromIds[change.id]
if not instance then continue end
local update = encodePatchUpdate(instance, change.id, change.changedProperties)
table.insert(inversePatch.updated, update)
end
-- Add the removed instances back to Rojo
-- selene:allow(empty_if, unused_variable)
for _, instance in catchUpPatch.removed do
-- TODO: Generate ID for our instance and add it to inversePatch.added
end
-- Remove the additions we've rejected
for id, _change in catchUpPatch.added do
table.insert(inversePatch.removed, id)
end
self.__apiContext:write(inversePatch)
elseif userDecision == "Accept" then
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
if not PatchSet.isEmpty(unappliedPatch) then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end
if self.__patchAppliedCallback then
pcall(self.__patchAppliedCallback, catchUpPatch, unappliedPatch)
end
end
end)
end

View File

@@ -7,18 +7,23 @@ local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Log = require(Packages.Log)
local Roact = require(Packages.Roact)
local defaultSettings = {
openScriptsExternally = false,
twoWaySync = false,
showNotifications = true,
playSounds = true,
typecheckingEnabled = false,
logLevel = "Info",
priorEndpoints = {},
}
local Settings = {}
Settings._values = table.clone(defaultSettings)
Settings._updateListeners = {}
Settings._bindings = {}
if plugin then
for name, defaultValue in pairs(Settings._values) do
@@ -45,6 +50,9 @@ end
function Settings:set(name, value)
self._values[name] = value
if self._bindings[name] then
self._bindings[name].set(value)
end
if plugin then
-- plugin:SetSetting hits disc instead of memory, so it can be slow. Spawn so we don't hang.
@@ -76,4 +84,21 @@ function Settings:onChanged(name, callback)
end
end
function Settings:getBinding(name)
local cached = self._bindings[name]
if cached then
return cached.bind
end
local bind, set = Roact.createBinding(self._values[name])
self._bindings[name] = {
bind = bind,
set = set,
}
Log.trace(string.format("Created binding for setting '%s'", name))
return bind
end
return Settings

View File

@@ -1,6 +1,6 @@
local Packages = script.Parent.Parent.Packages
local t = require(Packages.t)
local DevSettings = require(script.Parent.DevSettings)
local Settings = require(script.Parent.Settings)
local strict = require(script.Parent.strict)
local RbxId = t.string
@@ -66,7 +66,7 @@ local ApiError = t.interface({
local function ifEnabled(innerCheck)
return function(...)
if DevSettings:shouldTypecheck() then
if Settings:get("typecheckingEnabled") then
return innerCheck(...)
else
return true

View File

@@ -8,12 +8,12 @@ local Packages = Rojo.Packages
local Log = require(Packages.Log)
local Roact = require(Packages.Roact)
local DevSettings = require(script.DevSettings)
local Settings = require(script.Settings)
local Config = require(script.Config)
local App = require(script.App)
Log.setLogLevelThunk(function()
return DevSettings:getLogLevel()
return Log.Level[Settings:get("logLevel")] or Log.Level.Info
end)
local app = Roact.createElement(App, {

View File

@@ -2,7 +2,10 @@ return function()
it("should load all submodules", function()
local function loadRecursive(container)
if container:IsA("ModuleScript") and not container.Name:find("%.spec$") then
require(container)
local success, err = pcall(require, container)
if not success then
error(string.format("Failed to load '%s': %s", container.Name, err))
end
end
for _, child in ipairs(container:GetChildren()) do
@@ -12,4 +15,4 @@ return function()
loadRecursive(script.Parent)
end)
end
end

2
plugin/watch-build.sh Normal file
View File

@@ -0,0 +1,2 @@
# Continously build the rojo plugin into the local plugin directory on Windows
rojo build plugin/default.project.json -o $LOCALAPPDATA/Roblox/Plugins/Rojo.rbxm --watch

View File

@@ -1,5 +1,6 @@
use std::{
fs,
path::PathBuf,
sync::{Arc, Mutex},
};
@@ -124,46 +125,20 @@ impl JobThreadContext {
// For a given VFS event, we might have many changes to different parts
// of the tree. Calculate and apply all of these changes.
let applied_patches = {
let mut tree = self.tree.lock().unwrap();
let mut applied_patches = Vec::new();
match event {
VfsEvent::Create(path) | VfsEvent::Write(path) | VfsEvent::Remove(path) => {
// Find the nearest ancestor to this path that has
// associated instances in the tree. This helps make sure
// that we handle additions correctly, especially if we
// receive events for descendants of a large tree being
// created all at once.
let mut current_path = path.as_path();
let affected_ids = loop {
let ids = tree.get_ids_at_path(&current_path);
log::trace!("Path {} affects IDs {:?}", current_path.display(), ids);
if !ids.is_empty() {
break ids.to_vec();
}
log::trace!("Trying parent path...");
match current_path.parent() {
Some(parent) => current_path = parent,
None => break Vec::new(),
}
};
for id in affected_ids {
if let Some(patch) = compute_and_apply_changes(&mut tree, &self.vfs, id) {
if !patch.is_empty() {
applied_patches.push(patch);
}
}
}
let applied_patches = match event {
VfsEvent::Write(path) => {
if path.is_dir() {
return;
}
_ => log::warn!("Unhandled VFS event: {:?}", event),
on_vfs_event(path, &self.tree, &self.vfs)
}
VfsEvent::Create(path) | VfsEvent::Remove(path) => {
on_vfs_event(path, &self.tree, &self.vfs)
}
_ => {
log::warn!("Unhandled VFS event: {:?}", event);
Vec::new()
}
applied_patches
};
// Notify anyone listening to the message queue about the changes we
@@ -261,6 +236,45 @@ impl JobThreadContext {
}
}
// Find the nearest ancestor to this path that has
// associated instances in the tree. This helps make sure
// that we handle additions correctly, especially if we
// receive events for descendants of a large tree being
// created all at once.
fn on_vfs_event(
path: PathBuf,
tree: &Arc<Mutex<RojoTree>>,
vfs: &Arc<Vfs>,
) -> Vec<AppliedPatchSet> {
let mut tree = tree.lock().unwrap();
let mut applied_patches = Vec::new();
let mut current_path = path.as_path();
let affected_ids = loop {
let ids = tree.get_ids_at_path(&current_path);
log::trace!("Path {} affects IDs {:?}", current_path.display(), ids);
if !ids.is_empty() {
break ids.to_vec();
}
log::trace!("Trying parent path...");
match current_path.parent() {
Some(parent) => current_path = parent,
None => break Vec::new(),
}
};
for id in affected_ids {
if let Some(patch) = compute_and_apply_changes(&mut tree, &vfs, id) {
if !patch.is_empty() {
applied_patches.push(patch);
}
}
}
applied_patches
}
fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<AppliedPatchSet> {
let metadata = tree
.get_metadata(id)

View File

@@ -19,6 +19,9 @@ pub struct AdjacentMetadata {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub properties: HashMap<String, UnresolvedValue>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub attributes: HashMap<String, UnresolvedValue>,
#[serde(skip)]
pub path: PathBuf,
}
@@ -53,6 +56,19 @@ impl AdjacentMetadata {
snapshot.properties.insert(key, value);
}
if !self.attributes.is_empty() {
let mut attributes = Attributes::new();
for (key, unresolved) in self.attributes.drain() {
let value = unresolved.resolve_unambiguous()?;
attributes.insert(key, value);
}
snapshot
.properties
.insert("Attributes".into(), attributes.into());
}
Ok(())
}