Merge branch 'master' of https://github.com/rojo-rbx/rojo into headless-api

This commit is contained in:
boatbomber 2024-02-12 21:14:54 -08:00
commit e459ae920f
18 changed files with 485 additions and 242 deletions

View file

@ -2,9 +2,12 @@
## Unreleased Changes ## Unreleased Changes
* Added headless API for Studio companion plugins. ([#639]) * Added headless API for Studio companion plugins. ([#639])
* Rojo now converts any line endings to LF, preventing spurious diffs when syncing Lua files on Windows ([#854])
* Fixed Rojo plugin failing to connect when project contains certain unreadable properties ([#848])
* Added popout diff visualizer for table properties like Attributes and Tags ([#834]) * Added popout diff visualizer for table properties like Attributes and Tags ([#834])
* Updated Theme to use Studio colors ([#838]) * Updated Theme to use Studio colors ([#838])
* Added experimental setting for Auto Connect in playtests ([#840]) * Added experimental setting for Auto Connect in playtests ([#840])
* Fixed http error handling so Rojo can be used in Github Codespaces ([#847])
* Projects may now specify rules for syncing files as if they had a different file extension. ([#813]) * Projects may now specify rules for syncing files as if they had a different file extension. ([#813])
This is specified via a new field on project files, `syncRules`: This is specified via a new field on project files, `syncRules`:
@ -57,6 +60,10 @@
[#834]: https://github.com/rojo-rbx/rojo/pull/834 [#834]: https://github.com/rojo-rbx/rojo/pull/834
[#838]: https://github.com/rojo-rbx/rojo/pull/838 [#838]: https://github.com/rojo-rbx/rojo/pull/838
[#840]: https://github.com/rojo-rbx/rojo/pull/840 [#840]: https://github.com/rojo-rbx/rojo/pull/840
[#847]: https://github.com/rojo-rbx/rojo/pull/847
[#848]: https://github.com/rojo-rbx/rojo/pull/848
[#854]: https://github.com/rojo-rbx/rojo/pull/854
## [7.4.0] - January 16, 2024 ## [7.4.0] - January 16, 2024
* Improved the visualization for array properties like Tags ([#829]) * Improved the visualization for array properties like Tags ([#829])

View file

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

View file

@ -22,9 +22,9 @@ mod noop_backend;
mod snapshot; mod snapshot;
mod std_backend; mod std_backend;
use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
use std::{io, str};
pub use in_memory_fs::InMemoryFs; pub use in_memory_fs::InMemoryFs;
pub use noop_backend::NoopBackend; pub use noop_backend::NoopBackend;
@ -155,6 +155,24 @@ impl VfsInner {
Ok(Arc::new(contents)) Ok(Arc::new(contents))
} }
fn read_to_string<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
let contents = self.backend.read(path)?;
if self.watch_enabled {
self.backend.watch(path)?;
}
let contents_str = str::from_utf8(&contents).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("File was not valid UTF-8: {}", path.display()),
)
})?;
Ok(Arc::new(contents_str.into()))
}
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> { fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
let path = path.as_ref(); let path = path.as_ref();
let contents = contents.as_ref(); let contents = contents.as_ref();
@ -258,6 +276,33 @@ impl Vfs {
self.inner.lock().unwrap().read(path) self.inner.lock().unwrap().read(path)
} }
/// Read a file from the VFS (or from the underlying backend if it isn't
/// resident) into a string.
///
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string].
///
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
#[inline]
pub fn read_to_string<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
self.inner.lock().unwrap().read_to_string(path)
}
/// Read a file from the VFS (or the underlying backend if it isn't
/// resident) into a string, and normalize its line endings to LF.
///
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string], but also performs
/// line ending normalization.
///
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
#[inline]
pub fn read_to_string_lf_normalized<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
let contents = self.inner.lock().unwrap().read_to_string(path)?;
Ok(contents.lines().collect::<Vec<&str>>().join("\n").into())
}
/// Write a file to the VFS and the underlying backend. /// Write a file to the VFS and the underlying backend.
/// ///
/// Roughly equivalent to [`std::fs::write`][std::fs::write]. /// Roughly equivalent to [`std::fs::write`][std::fs::write].

View file

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

View file

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

View file

@ -9,6 +9,7 @@ local Log = require(Packages.Log)
local Highlighter = require(Packages.Highlighter) local Highlighter = require(Packages.Highlighter)
local StringDiff = require(script:FindFirstChild("StringDiff")) local StringDiff = require(script:FindFirstChild("StringDiff"))
local Timer = require(Plugin.Timer)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local CodeLabel = require(Plugin.App.Components.CodeLabel) local CodeLabel = require(Plugin.App.Components.CodeLabel)
@ -74,6 +75,7 @@ function StringDiffVisualizer:calculateContentSize()
end end
function StringDiffVisualizer:calculateDiffLines() function StringDiffVisualizer:calculateDiffLines()
Timer.start("StringDiffVisualizer:calculateDiffLines")
local oldString, newString = self.props.oldString, self.props.newString local oldString, newString = self.props.oldString, self.props.newString
-- Diff the two texts -- Diff the two texts
@ -133,6 +135,7 @@ function StringDiffVisualizer:calculateDiffLines()
end end
end end
Timer.stop()
return add, remove return add, remove
end end

View file

@ -4,6 +4,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
@ -21,6 +22,7 @@ function Array:init()
end end
function Array:calculateDiff() function Array:calculateDiff()
Timer.start("Array:calculateDiff")
--[[ --[[
Find the indexes that are added or removed from the array, Find the indexes that are added or removed from the array,
and display them side by side with gaps for the indexes that and display them side by side with gaps for the indexes that
@ -63,6 +65,7 @@ function Array:calculateDiff()
j += 1 j += 1
end end
Timer.stop()
return diff return diff
end end

View file

@ -4,6 +4,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
@ -21,6 +22,7 @@ function Dictionary:init()
end end
function Dictionary:calculateDiff() function Dictionary:calculateDiff()
Timer.start("Dictionary:calculateDiff")
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {} local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
-- Diff the two tables and find the added keys, removed keys, and changed keys -- Diff the two tables and find the added keys, removed keys, and changed keys
@ -59,6 +61,7 @@ function Dictionary:calculateDiff()
return a.key < b.key return a.key < b.key
end) end)
Timer.stop()
return diff return diff
end end

View file

@ -4,6 +4,8 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local PatchTree = require(Plugin.PatchTree)
local Settings = require(Plugin.Settings) local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
@ -23,6 +25,7 @@ function ConfirmingPage:init()
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({ self:setState({
patchTree = nil,
showingStringDiff = false, showingStringDiff = false,
oldString = "", oldString = "",
newString = "", newString = "",
@ -30,6 +33,28 @@ function ConfirmingPage:init()
oldTable = {}, oldTable = {},
newTable = {}, newTable = {},
}) })
if self.props.confirmData and self.props.confirmData.patch and self.props.confirmData.instanceMap then
self:buildPatchTree()
end
end
function ConfirmingPage:didUpdate(prevProps)
if prevProps.confirmData ~= self.props.confirmData then
self:buildPatchTree()
end
end
function ConfirmingPage:buildPatchTree()
Timer.start("ConfirmingPage:buildPatchTree")
self:setState({
patchTree = PatchTree.build(
self.props.confirmData.patch,
self.props.confirmData.instanceMap,
{ "Property", "Current", "Incoming" }
),
})
Timer.stop()
end end
function ConfirmingPage:render() function ConfirmingPage:render()
@ -61,9 +86,7 @@ function ConfirmingPage:render()
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 3,
changeListHeaders = { "Property", "Current", "Incoming" }, patchTree = self.state.patchTree,
patch = self.props.confirmData.patch,
instanceMap = self.props.confirmData.instanceMap,
showStringDiff = function(oldString: string, newString: string) showStringDiff = function(oldString: string, newString: string)
self:setState({ self:setState({

View file

@ -124,8 +124,14 @@ function Setting:render()
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Name = e("TextLabel", { Name = e("TextLabel", {
Text = (if self.props.experimental then '<font color="#FF8E3C">⚠ </font>' else "") Text = (
.. self.props.name, if self.props.experimental
then '<font color="#FF8E3C">⚠ </font>'
elseif
self.props.developerDebug
then '<font family="rbxasset://fonts/families/Guru.json" color="#35B5FF">⚑ </font>' -- Guru is the only font with the flag emoji
else ""
) .. self.props.name,
Font = Enum.Font.GothamBold, Font = Enum.Font.GothamBold,
TextSize = 17, TextSize = 17,
TextColor3 = theme.Setting.NameColor, TextColor3 = theme.Setting.NameColor,
@ -140,8 +146,10 @@ function Setting:render()
}), }),
Description = e("TextLabel", { Description = e("TextLabel", {
Text = (if self.props.experimental then '<font color="#FF8E3C">[Experimental] </font>' else "") Text = (if self.props.experimental
.. self.props.description, then '<font color="#FF8E3C">[Experimental] </font>'
elseif self.props.developerDebug then '<font color="#35B5FF">[Dev Debug] </font>'
else "") .. self.props.description,
Font = Enum.Font.Gotham, Font = Enum.Font.Gotham,
LineHeight = 1.2, LineHeight = 1.2,
TextSize = 14, TextSize = 14,

View file

@ -83,185 +83,198 @@ function SettingsPage:render()
end end
return Theme.with(function(theme) return Theme.with(function(theme)
return e(ScrollingFrame, { return Roact.createFragment({
size = UDim2.new(1, 0, 1, 0),
contentSize = self.contentSize,
transparency = self.props.transparency,
}, {
Navbar = e(Navbar, { Navbar = e(Navbar, {
onBack = self.props.onBack, onBack = self.props.onBack,
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = layoutIncrement(), layoutOrder = layoutIncrement(),
}), }),
Content = e(ScrollingFrame, {
ShowNotifications = e(Setting, { size = UDim2.new(1, 0, 1, -47),
id = "showNotifications", position = UDim2.new(0, 0, 0, 47),
name = "Show Notifications", contentSize = self.contentSize,
description = "Popup notifications in viewport",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = layoutIncrement(), }, {
}), ShowNotifications = e(Setting, {
id = "showNotifications",
SyncReminder = e(Setting, { name = "Show Notifications",
id = "syncReminder", description = "Popup notifications in viewport",
name = "Sync Reminder",
description = "Notify to sync when opening a place that has previously been synced",
transparency = self.props.transparency,
visible = Settings:getBinding("showNotifications"),
layoutOrder = layoutIncrement(),
}),
ConfirmationBehavior = e(Setting, {
id = "confirmationBehavior",
name = "Confirmation Behavior",
description = "When to prompt for confirmation before syncing",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
options = confirmationBehaviors,
}),
LargeChangesConfirmationThreshold = e(Setting, {
id = "largeChangesConfirmationThreshold",
name = "Confirmation Threshold",
description = "How many modified instances to be considered a large change",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
visible = Settings:getBinding("confirmationBehavior"):map(function(value)
return value == "Large Changes"
end),
input = e(TextInput, {
size = UDim2.new(0, 40, 0, 28),
text = Settings:getBinding("largeChangesConfirmationThreshold"):map(function(value)
return tostring(value)
end),
transparency = self.props.transparency, transparency = self.props.transparency,
enabled = true, layoutOrder = layoutIncrement(),
onEntered = function(text)
local number = tonumber(string.match(text, "%d+"))
if number then
Settings:set("largeChangesConfirmationThreshold", math.clamp(number, 1, 999))
else
-- Force text back to last valid value
Settings:set(
"largeChangesConfirmationThreshold",
Settings:get("largeChangesConfirmationThreshold")
)
end
end,
}), }),
}),
PlaySounds = e(Setting, { SyncReminder = e(Setting, {
id = "playSounds", id = "syncReminder",
name = "Play Sounds", name = "Sync Reminder",
description = "Toggle sound effects", description = "Notify to sync when opening a place that has previously been synced",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = layoutIncrement(), visible = Settings:getBinding("showNotifications"),
}), layoutOrder = layoutIncrement(),
}),
AutoConnectPlaytestServer = e(Setting, { ConfirmationBehavior = e(Setting, {
id = "autoConnectPlaytestServer", id = "confirmationBehavior",
name = "Auto Connect Playtest Server", name = "Confirmation Behavior",
description = "Automatically connect game server to Rojo when playtesting while connected in Edit", description = "When to prompt for confirmation before syncing",
experimental = true, transparency = self.props.transparency,
transparency = self.props.transparency, layoutOrder = layoutIncrement(),
layoutOrder = layoutIncrement(),
}),
Permissions = e(Setting, { options = confirmationBehaviors,
name = "Third Party Permissions", }),
description = "Manage permissions for third party plugins",
transparency = self.props.transparency,
layoutOrder = 6,
input = e("TextButton", {
Text = "",
BackgroundTransparency = 1,
Size = UDim2.fromOffset(28, 28),
Position = UDim2.fromScale(1, 0.5),
AnchorPoint = Vector2.new(1, 0.5),
[Roact.Event.Activated] = function() LargeChangesConfirmationThreshold = e(Setting, {
self.props.onNavigatePermissions() id = "largeChangesConfirmationThreshold",
end, name = "Confirmation Threshold",
}, { description = "How many modified instances to be considered a large change",
Button = e(SlicedImage, { transparency = self.props.transparency,
slice = Assets.Slices.RoundedBackground, layoutOrder = layoutIncrement(),
color = theme.Checkbox.Active.BackgroundColor, visible = Settings:getBinding("confirmationBehavior"):map(function(value)
return value == "Large Changes"
end),
input = e(TextInput, {
size = UDim2.new(0, 40, 0, 28),
text = Settings:getBinding("largeChangesConfirmationThreshold"):map(function(value)
return tostring(value)
end),
transparency = self.props.transparency, transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0), enabled = true,
onEntered = function(text)
local number = tonumber(string.match(text, "%d+"))
if number then
Settings:set("largeChangesConfirmationThreshold", math.clamp(number, 1, 999))
else
-- Force text back to last valid value
Settings:set(
"largeChangesConfirmationThreshold",
Settings:get("largeChangesConfirmationThreshold")
)
end
end,
}),
}),
PlaySounds = e(Setting, {
id = "playSounds",
name = "Play Sounds",
description = "Toggle sound effects",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
AutoConnectPlaytestServer = e(Setting, {
id = "autoConnectPlaytestServer",
name = "Auto Connect Playtest Server",
description = "Automatically connect game server to Rojo when playtesting while connected in Edit",
experimental = true,
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
Permissions = e(Setting, {
name = "Third Party Permissions",
description = "Manage permissions for third party plugins",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
input = e("TextButton", {
Text = "",
BackgroundTransparency = 1,
Size = UDim2.fromOffset(28, 28),
Position = UDim2.fromScale(1, 0.5),
AnchorPoint = Vector2.new(1, 0.5),
[Roact.Event.Activated] = function()
self.props.onNavigatePermissions()
end,
}, { }, {
Icon = e("ImageLabel", { Button = e(SlicedImage, {
Image = Assets.Images.Icons.Expand, slice = Assets.Slices.RoundedBackground,
ImageColor3 = theme.Checkbox.Active.IconColor, color = theme.Checkbox.Active.BackgroundColor,
ImageTransparency = self.props.transparency, transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0),
}, {
Icon = e("ImageLabel", {
Image = Assets.Images.Icons.Expand,
ImageColor3 = theme.Checkbox.Active.IconColor,
ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 16, 0, 16), Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(0.5, 0, 0.5, 0), Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5), AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}),
}), }),
}), }),
}), }),
}),
OpenScriptsExternally = e(Setting, { OpenScriptsExternally = e(Setting, {
id = "openScriptsExternally", id = "openScriptsExternally",
name = "Open Scripts Externally", name = "Open Scripts Externally",
description = "Attempt to open scripts in an external editor", description = "Attempt to open scripts in an external editor",
locked = self.props.syncActive, locked = self.props.syncActive,
experimental = true, experimental = true,
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = layoutIncrement(), layoutOrder = layoutIncrement(),
}), }),
TwoWaySync = e(Setting, { TwoWaySync = e(Setting, {
id = "twoWaySync", id = "twoWaySync",
name = "Two-Way Sync", name = "Two-Way Sync",
description = "Editing files in Studio will sync them into the filesystem", description = "Editing files in Studio will sync them into the filesystem",
locked = self.props.syncActive, locked = self.props.syncActive,
experimental = true, experimental = true,
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = layoutIncrement(), layoutOrder = layoutIncrement(),
}), }),
LogLevel = e(Setting, { LogLevel = e(Setting, {
id = "logLevel", id = "logLevel",
name = "Log Level", name = "Log Level",
description = "Plugin output verbosity level", description = "Plugin output verbosity level",
transparency = self.props.transparency, developerDebug = true,
layoutOrder = layoutIncrement(), transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
options = invertedLevels, options = invertedLevels,
showReset = Settings:getBinding("logLevel"):map(function(value) showReset = Settings:getBinding("logLevel"):map(function(value)
return value ~= "Info" return value ~= "Info"
end), end),
onReset = function() onReset = function()
Settings:set("logLevel", "Info") Settings:set("logLevel", "Info")
end, end,
}), }),
TypecheckingEnabled = e(Setting, { TypecheckingEnabled = e(Setting, {
id = "typecheckingEnabled", id = "typecheckingEnabled",
name = "Typechecking", name = "Typechecking",
description = "Toggle typechecking on the API surface", description = "Toggle typechecking on the API surface",
transparency = self.props.transparency, developerDebug = true,
layoutOrder = layoutIncrement(), transparency = self.props.transparency,
}), layoutOrder = layoutIncrement(),
}),
Layout = e("UIListLayout", { TimingLogsEnabled = e(Setting, {
FillDirection = Enum.FillDirection.Vertical, id = "timingLogsEnabled",
SortOrder = Enum.SortOrder.LayoutOrder, name = "Timing Logs",
description = "Toggle logging timing of internal actions for benchmarking Rojo performance",
developerDebug = true,
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
[Roact.Change.AbsoluteContentSize] = function(object) Layout = e("UIListLayout", {
self.setContentSize(object.AbsoluteContentSize) FillDirection = Enum.FillDirection.Vertical,
end, SortOrder = Enum.SortOrder.LayoutOrder,
}),
Padding = e("UIPadding", { [Roact.Change.AbsoluteContentSize] = function(object)
PaddingLeft = UDim.new(0, 20), self.setContentSize(object.AbsoluteContentSize)
PaddingRight = UDim.new(0, 20), end,
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
}), }),
}) })
end) end)

View file

@ -11,6 +11,7 @@ local Packages = Rojo.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Timer = require(Plugin.Timer)
local Types = require(Plugin.Types) local Types = require(Plugin.Types)
local decodeValue = require(Plugin.Reconciler.decodeValue) local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty) local getProperty = require(Plugin.Reconciler.getProperty)
@ -122,6 +123,7 @@ end
-- props must contain id, and cannot contain children or parentId -- props must contain id, and cannot contain children or parentId
-- other than those three, it can hold anything -- other than those three, it can hold anything
function Tree:addNode(parent, props) function Tree:addNode(parent, props)
Timer.start("Tree:addNode")
assert(props.id, "props must contain id") assert(props.id, "props must contain id")
parent = parent or "ROOT" parent = parent or "ROOT"
@ -132,6 +134,7 @@ function Tree:addNode(parent, props)
for k, v in props do for k, v in props do
node[k] = v node[k] = v
end end
Timer.stop()
return node return node
end end
@ -142,22 +145,26 @@ function Tree:addNode(parent, props)
local parentNode = self:getNode(parent) local parentNode = self:getNode(parent)
if not parentNode then if not parentNode then
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props) Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
Timer.stop()
return return
end end
parentNode.children[node.id] = node parentNode.children[node.id] = node
self.idToNode[node.id] = node self.idToNode[node.id] = node
Timer.stop()
return node return node
end end
-- Given a list of ancestor ids in descending order, builds the nodes for them -- Given a list of ancestor ids in descending order, builds the nodes for them
-- using the patch and instanceMap info -- using the patch and instanceMap info
function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap) function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap)
Timer.start("Tree:buildAncestryNodes")
-- Build nodes for ancestry by going up the tree -- Build nodes for ancestry by going up the tree
previousId = previousId or "ROOT" previousId = previousId or "ROOT"
for _, ancestorId in ancestryIds do for i = #ancestryIds, 1, -1 do
local ancestorId = ancestryIds[i]
local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId] local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId]
if not value then if not value then
Log.warn("Failed to find ancestor object for " .. ancestorId) Log.warn("Failed to find ancestor object for " .. ancestorId)
@ -171,6 +178,8 @@ function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, p
}) })
previousId = ancestorId previousId = ancestorId
end end
Timer.stop()
end end
local PatchTree = {} local PatchTree = {}
@ -178,10 +187,12 @@ local PatchTree = {}
-- Builds a new tree from a patch and instanceMap -- Builds a new tree from a patch and instanceMap
-- uses changeListHeaders in node.changeList -- uses changeListHeaders in node.changeList
function PatchTree.build(patch, instanceMap, changeListHeaders) function PatchTree.build(patch, instanceMap, changeListHeaders)
Timer.start("PatchTree.build")
local tree = Tree.new() local tree = Tree.new()
local knownAncestors = {} local knownAncestors = {}
Timer.start("patch.updated")
for _, change in patch.updated do for _, change in patch.updated do
local instance = instanceMap.fromIds[change.id] local instance = instanceMap.fromIds[change.id]
if not instance then if not instance then
@ -189,7 +200,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
end end
-- Gather ancestors from existing DOM -- Gather ancestors from existing DOM
local ancestryIds = {} local ancestryIds, ancestryIndex = {}, 0
local parentObject = instance.Parent local parentObject = instance.Parent
local parentId = instanceMap.fromInstances[parentObject] local parentId = instanceMap.fromInstances[parentObject]
local previousId = nil local previousId = nil
@ -200,7 +211,8 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
break break
end end
table.insert(ancestryIds, 1, parentId) ancestryIndex += 1
ancestryIds[ancestryIndex] = parentId
knownAncestors[parentId] = true knownAncestors[parentId] = true
parentObject = parentObject.Parent parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject] parentId = instanceMap.fromInstances[parentObject]
@ -213,11 +225,38 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
if next(change.changedProperties) or change.changedName then if next(change.changedProperties) or change.changedName then
changeList = {} changeList = {}
local hintBuffer, i = {}, 0 local hintBuffer, hintBufferSize, hintOverflow = table.create(3), 0, 0
local changeIndex = 0
local function addProp(prop: string, current: any?, incoming: any?, metadata: any?) local function addProp(prop: string, current: any?, incoming: any?, metadata: any?)
i += 1 changeIndex += 1
hintBuffer[i] = prop changeList[changeIndex] = { prop, current, incoming, metadata }
changeList[i] = { prop, current, incoming, metadata }
if hintBufferSize < 3 then
hintBufferSize += 1
hintBuffer[hintBufferSize] = prop
return
end
-- We only want to have 3 hints
-- to keep it deterministic, we sort them alphabetically
-- Either this prop overflows, or it makes another one move to overflow
hintOverflow += 1
-- Shortcut for the common case
if hintBuffer[3] <= prop then
-- This prop is below the last hint, no need to insert
return
end
-- Find the first available spot
for i, hintItem in hintBuffer do
if prop < hintItem then
-- This prop is before the currently selected hint,
-- so take its place and then continue to find a spot for the old hint
hintBuffer[i], prop = prop, hintBuffer[i]
end
end
end end
-- Gather the changes -- Gather the changes
@ -238,18 +277,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
end end
-- Finalize detail values -- Finalize detail values
hint = table.concat(hintBuffer, ", ") .. (if hintOverflow == 0 then "" else ", " .. hintOverflow .. " more")
-- Trim hint to top 3
table.sort(hintBuffer)
if #hintBuffer > 3 then
hintBuffer = {
hintBuffer[1],
hintBuffer[2],
hintBuffer[3],
i - 3 .. " more",
}
end
hint = table.concat(hintBuffer, ", ")
-- Sort changes and add header -- Sort changes and add header
table.sort(changeList, function(a, b) table.sort(changeList, function(a, b)
@ -269,7 +297,9 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
changeList = changeList, changeList = changeList,
}) })
end end
Timer.stop()
Timer.start("patch.removed")
for _, idOrInstance in patch.removed do for _, idOrInstance in patch.removed do
local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance
if not instance then if not instance then
@ -311,7 +341,9 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
instance = instance, instance = instance,
}) })
end end
Timer.stop()
Timer.start("patch.added")
for id, change in patch.added do for id, change in patch.added do
-- Gather ancestors from existing DOM or future additions -- Gather ancestors from existing DOM or future additions
local ancestryIds = {} local ancestryIds = {}
@ -350,32 +382,47 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
if next(change.Properties) then if next(change.Properties) then
changeList = {} changeList = {}
local hintBuffer, i = {}, 0 local hintBuffer, hintBufferSize, hintOverflow = table.create(3), 0, 0
for prop, incoming in change.Properties do local changeIndex = 0
i += 1 local function addProp(prop: string, incoming: any)
hintBuffer[i] = prop changeIndex += 1
changeList[changeIndex] = { prop, "N/A", incoming }
local success, incomingValue = decodeValue(incoming, instanceMap) if hintBufferSize < 3 then
if success then hintBufferSize += 1
table.insert(changeList, { prop, "N/A", incomingValue }) hintBuffer[hintBufferSize] = prop
else return
table.insert(changeList, { prop, "N/A", select(2, next(incoming)) }) end
-- We only want to have 3 hints
-- to keep it deterministic, we sort them alphabetically
-- Either this prop overflows, or it makes another one move to overflow
hintOverflow += 1
-- Shortcut for the common case
if hintBuffer[3] <= prop then
-- This prop is below the last hint, no need to insert
return
end
-- Find the first available spot
for i, hintItem in hintBuffer do
if prop < hintItem then
-- This prop is before the currently selected hint,
-- so take its place and then continue to find a spot for the old hint
hintBuffer[i], prop = prop, hintBuffer[i]
end
end end
end end
-- Finalize detail values for prop, incoming in change.Properties do
local success, incomingValue = decodeValue(incoming, instanceMap)
-- Trim hint to top 3 addProp(prop, if success then incomingValue else select(2, next(incoming)))
table.sort(hintBuffer)
if #hintBuffer > 3 then
hintBuffer = {
hintBuffer[1],
hintBuffer[2],
hintBuffer[3],
i - 3 .. " more",
}
end end
hint = table.concat(hintBuffer, ", ")
-- Finalize detail values
hint = table.concat(hintBuffer, ", ") .. (if hintOverflow == 0 then "" else ", " .. hintOverflow .. " more")
-- Sort changes and add header -- Sort changes and add header
table.sort(changeList, function(a, b) table.sort(changeList, function(a, b)
@ -395,35 +442,27 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
instance = instanceMap.fromIds[id], instance = instanceMap.fromIds[id],
}) })
end end
Timer.stop()
Timer.stop()
return tree return tree
end end
-- Creates a deep copy of a tree for immutability purposes in Roact
function PatchTree.clone(tree)
if not tree then
return
end
local newTree = Tree.new()
tree:forEach(function(node)
newTree:addNode(node.parentId, table.clone(node))
end)
return newTree
end
-- Updates the metadata of a tree with the unapplied patch and currently existing instances -- Updates the metadata of a tree with the unapplied patch and currently existing instances
-- Builds a new tree from the data if one isn't provided -- Builds a new tree from the data if one isn't provided
-- Always returns a new tree for immutability purposes in Roact -- Always returns a new tree for immutability purposes in Roact
function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
Timer.start("PatchTree.updateMetadata")
if tree then if tree then
tree = PatchTree.clone(tree) -- A shallow copy is enough for our purposes here since we really only need a new top-level object
-- for immutable comparison checks in Roact
tree = table.clone(tree)
else else
tree = PatchTree.build(patch, instanceMap) tree = PatchTree.build(patch, instanceMap)
end end
-- Update isWarning metadata -- Update isWarning metadata
Timer.start("isWarning")
for _, failedChange in unappliedPatch.updated do for _, failedChange in unappliedPatch.updated do
local node = tree:getNode(failedChange.id) local node = tree:getNode(failedChange.id)
if not node then if not node then
@ -492,8 +531,10 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
node.isWarning = true node.isWarning = true
Log.trace("Marked node as warning: {} {}", node.id, node.name) Log.trace("Marked node as warning: {} {}", node.id, node.name)
end end
Timer.stop()
-- Update if instances exist -- Update if instances exist
Timer.start("instanceAncestry")
tree:forEach(function(node) tree:forEach(function(node)
if node.instance then if node.instance then
if node.instance.Parent == nil and node.instance ~= game then if node.instance.Parent == nil and node.instance ~= game then
@ -509,7 +550,9 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
end end
end end
end) end)
Timer.stop()
Timer.stop()
return tree return tree
end end

View file

@ -177,10 +177,8 @@ local function diff(instanceMap, virtualInstances, rootId)
if err.kind == Error.UnknownProperty then if err.kind == Error.UnknownProperty then
Log.trace("Skipping unknown property {}.{}", err.details.className, err.details.propertyName) Log.trace("Skipping unknown property {}.{}", err.details.className, err.details.propertyName)
elseif err.kind == Error.UnreadableProperty then
Log.trace("Skipping unreadable property {}.{}", err.details.className, err.details.propertyName)
else else
return false, err Log.trace("Skipping unreadable property {}.{}", err.details.className, err.details.propertyName)
end end
end end
end end

View file

@ -3,9 +3,14 @@
and mutating the Roblox DOM. and mutating the Roblox DOM.
]] ]]
local Packages = script.Parent.Parent.Packages local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Timer = require(Plugin.Timer)
local applyPatch = require(script.applyPatch) local applyPatch = require(script.applyPatch)
local hydrate = require(script.hydrate) local hydrate = require(script.hydrate)
local diff = require(script.diff) local diff = require(script.diff)
@ -57,31 +62,55 @@ function Reconciler:hookPostcommit(callback: (patch: any, instanceMap: any, unap
end end
function Reconciler:applyPatch(patch) function Reconciler:applyPatch(patch)
Timer.start("Reconciler:applyPatch")
Timer.start("precommitCallbacks")
-- Precommit callbacks must be serial in order to obey the contract that
-- they execute before commit
for _, callback in self.__precommitCallbacks do for _, callback in self.__precommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap) local success, err = pcall(callback, patch, self.__instanceMap)
if not success then if not success then
Log.warn("Precommit hook errored: {}", err) Log.warn("Precommit hook errored: {}", err)
end end
end end
Timer.stop()
Timer.start("apply")
local unappliedPatch = applyPatch(self.__instanceMap, patch) local unappliedPatch = applyPatch(self.__instanceMap, patch)
Timer.stop()
Timer.start("postcommitCallbacks")
-- Postcommit callbacks can be called with spawn since regardless of firing order, they are
-- guaranteed to be called after the commit
for _, callback in self.__postcommitCallbacks do for _, callback in self.__postcommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch) task.spawn(function()
if not success then local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch)
Log.warn("Postcommit hook errored: {}", err) if not success then
end Log.warn("Postcommit hook errored: {}", err)
end
end)
end end
Timer.stop()
Timer.stop()
return unappliedPatch return unappliedPatch
end end
function Reconciler:hydrate(virtualInstances, rootId, rootInstance) function Reconciler:hydrate(virtualInstances, rootId, rootInstance)
return hydrate(self.__instanceMap, virtualInstances, rootId, rootInstance) Timer.start("Reconciler:hydrate")
local result = hydrate(self.__instanceMap, virtualInstances, rootId, rootInstance)
Timer.stop()
return result
end end
function Reconciler:diff(virtualInstances, rootId) function Reconciler:diff(virtualInstances, rootId)
return diff(self.__instanceMap, virtualInstances, rootId) Timer.start("Reconciler:diff")
local success, result = diff(self.__instanceMap, virtualInstances, rootId)
Timer.stop()
return success, result
end end
return Reconciler return Reconciler

View file

@ -20,6 +20,7 @@ local defaultSettings = {
playSounds = true, playSounds = true,
typecheckingEnabled = false, typecheckingEnabled = false,
logLevel = "Info", logLevel = "Info",
timingLogsEnabled = false,
priorEndpoints = {}, priorEndpoints = {},
apiPermissions = {}, apiPermissions = {},
} }

57
plugin/src/Timer.lua Normal file
View file

@ -0,0 +1,57 @@
local Settings = require(script.Parent.Settings)
local clock = os.clock
local Timer = {
_entries = {},
}
function Timer._start(label)
local start = clock()
if not label then
error("[Rojo-Timer] Timer.start: label is required", 2)
return
end
table.insert(Timer._entries, { label, start })
end
function Timer._stop()
local stop = clock()
local entry = table.remove(Timer._entries)
if not entry then
error("[Rojo-Timer] Timer.stop: no label to stop", 2)
return
end
local label = entry[1]
if #Timer._entries > 0 then
local priorLabels = {}
for _, priorEntry in ipairs(Timer._entries) do
table.insert(priorLabels, priorEntry[1])
end
label = table.concat(priorLabels, "/") .. "/" .. label
end
local start = entry[2]
local duration = stop - start
print(string.format("[Rojo-Timer] %s took %.3f ms", label, duration * 1000))
end
-- Replace functions with no-op if not in debug mode
local function no_op() end
local function setFunctions(enabled)
if enabled then
Timer.start = Timer._start
Timer.stop = Timer._stop
else
Timer.start = no_op
Timer.stop = no_op
end
end
Settings:onChanged("timingLogsEnabled", setFunctions)
setFunctions(Settings:get("timingLogsEnabled"))
return Timer

View file

@ -1,6 +1,5 @@
use std::{collections::HashMap, path::Path, str}; use std::{collections::HashMap, path::Path, str};
use anyhow::Context;
use memofs::{IoResultExt, Vfs}; use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::types::Enum; use rbx_dom_weak::types::Enum;
@ -40,10 +39,8 @@ pub fn snapshot_lua(
(_, ScriptType::Module) => ("ModuleScript", None), (_, ScriptType::Module) => ("ModuleScript", None),
}; };
let contents = vfs.read(path)?; let contents = vfs.read_to_string_lf_normalized(path)?;
let contents_str = str::from_utf8(&contents) let contents_str = contents.as_str();
.with_context(|| format!("File was not valid UTF-8: {}", path.display()))?
.to_owned();
let mut properties = HashMap::with_capacity(2); let mut properties = HashMap::with_capacity(2);
properties.insert("Source".to_owned(), contents_str.into()); properties.insert("Source".to_owned(), contents_str.into());

View file

@ -1,6 +1,5 @@
use std::{path::Path, str}; use std::{path::Path, str};
use anyhow::Context;
use maplit::hashmap; use maplit::hashmap;
use memofs::{IoResultExt, Vfs}; use memofs::{IoResultExt, Vfs};
@ -14,10 +13,8 @@ pub fn snapshot_txt(
path: &Path, path: &Path,
name: &str, name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> { ) -> anyhow::Result<Option<InstanceSnapshot>> {
let contents = vfs.read(path)?; let contents = vfs.read_to_string(path)?;
let contents_str = str::from_utf8(&contents) let contents_str = contents.as_str();
.with_context(|| format!("File was not valid UTF-8: {}", path.display()))?
.to_owned();
let properties = hashmap! { let properties = hashmap! {
"Value".to_owned() => contents_str.into(), "Value".to_owned() => contents_str.into(),