Merge branch 'master' into headless-api

This commit is contained in:
boatbomber 2024-01-29 21:30:29 -08:00 • committed by GitHub
commit 3a28b03565
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 1832 additions and 564 deletions

View file

@ -2,8 +2,59 @@
## Unreleased Changes
* Added headless API for Studio companion plugins. ([#631])
* Added popout diff visualizer for table properties like Attributes and Tags ([#834])
* Updated Theme to use Studio colors ([#838])
* Projects may now specify rules for syncing files as if they had a different file extension. ([#813])
This is specified via a new field on project files, `syncRules`:
```json
{
"syncRules": [
{
"pattern": "*.foo",
"use": "text",
"exclude": "*.exclude.foo",
},
{
"pattern": "*.bar.baz",
"use": "json",
"suffix": ".bar.baz",
},
],
"name": "SyncRulesAreCool",
"tree": {
"$path": "src"
}
}
```
The `pattern` field is a glob used to match the sync rule to files. If present, the `suffix` field allows you to specify parts of a file's name get cut off by Rojo to name the Instance, including the file extension. If it isn't specified, Rojo will only cut off the first part of the file extension, up to the first dot.
Additionally, the `exclude` field allows files to be excluded from the sync rule if they match a pattern specified by it. If it's not present, all files that match `pattern` will be modified using the sync rule.
The `use` field corresponds to one of the potential file type that Rojo will currently include in a project. Files that match the provided pattern will be treated as if they had the file extension for that file type. A full list is below:
| `use` value | file extension |
|:---------------|:----------------|
| `serverScript` | `.server.lua` |
| `clientScript` | `.client.lua` |
| `moduleScript` | `.lua` |
| `json` | `.json` |
| `toml` | `.toml` |
| `csv` | `.csv` |
| `text` | `.txt` |
| `jsonModel` | `.model.json` |
| `rbxm` | `.rbxm` |
| `rbxmx` | `.rbxmx` |
| `project` | `.project.json` |
| `ignore` | None! |
**All** sync rules are reset between project files, so they must be specified in each one when nesting them. This is to ensure that nothing can break other projects by changing how files are synced!
[#631]: https://github.com/rojo-rbx/rojo/pull/631
[#813]: https://github.com/rojo-rbx/rojo/pull/813
[#834]: https://github.com/rojo-rbx/rojo/pull/834
[#838]: https://github.com/rojo-rbx/rojo/pull/838
## [7.4.0] - January 16, 2024
* Improved the visualization for array properties like Tags ([#829])

View file

@ -109,9 +109,7 @@ function Dropdown:render()
}, {
DropArrow = e("ImageLabel", {
Image = if self.props.locked then Assets.Images.Dropdown.Locked else Assets.Images.Dropdown.Arrow,
ImageColor3 = self.openBinding:map(function(a)
return theme.Closed.IconColor:Lerp(theme.Open.IconColor, a)
end),
ImageColor3 = theme.IconColor,
ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 18, 0, 18),

View file

@ -14,6 +14,123 @@ local EMPTY_TABLE = {}
local e = Roact.createElement
local function ViewDiffButton(props)
return Theme.with(function(theme)
return e("TextButton", {
Text = "",
Size = UDim2.new(0.7, 0, 1, -4),
LayoutOrder = 2,
BackgroundTransparency = 1,
[Roact.Event.Activated] = props.onClick,
}, {
e(BorderedContainer, {
size = UDim2.new(1, 0, 1, 0),
transparency = props.transparency:map(function(t)
return 0.5 + (0.5 * t)
end),
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 5),
}),
Label = e("TextLabel", {
Text = "View Diff",
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0, 65, 1, 0),
LayoutOrder = 1,
}),
Icon = e("ImageLabel", {
Image = Assets.Images.Icons.Expand,
ImageColor3 = theme.Settings.Setting.DescriptionColor,
ImageTransparency = props.transparency,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
}),
})
end)
end
local function RowContent(props)
local values = props.values
local metadata = props.metadata
if props.showStringDiff and values[1] == "Source" then
-- Special case for .Source updates
return e(ViewDiffButton, {
transparency = props.transparency,
onClick = function()
if not props.showStringDiff then
return
end
props.showStringDiff(tostring(values[2]), tostring(values[3]))
end,
})
end
if props.showTableDiff and (type(values[2]) == "table" or type(values[3]) == "table") then
-- Special case for table properties (like Attributes/Tags)
return e(ViewDiffButton, {
transparency = props.transparency,
onClick = function()
if not props.showTableDiff then
return
end
props.showTableDiff(values[2], values[3])
end,
})
end
return Theme.with(function(theme)
return Roact.createFragment({
ColumnB = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
},
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
textColor = if metadata.isWarning
then theme.Diff.Warning
else theme.Settings.Setting.DescriptionColor,
})
),
ColumnC = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
},
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
textColor = if metadata.isWarning
then theme.Diff.Warning
else theme.Settings.Setting.DescriptionColor,
})
),
})
end)
end
local ChangeList = Roact.Component:extend("ChangeList")
function ChangeList:init()
@ -36,6 +153,7 @@ function ChangeList:render()
PaddingRight = UDim.new(0, 5),
}
local headerRow = changes[1]
local headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = rowTransparency,
@ -49,36 +167,36 @@ function ChangeList:render()
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Text = tostring(changes[1][1]),
ColumnA = e("TextLabel", {
Text = tostring(headerRow[1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
B = e("TextLabel", {
Text = tostring(changes[1][2]),
ColumnB = e("TextLabel", {
Text = tostring(headerRow[2]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
}),
C = e("TextLabel", {
Text = tostring(changes[1][3]),
ColumnC = e("TextLabel", {
Text = tostring(headerRow[3]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
@ -95,89 +213,6 @@ function ChangeList:render()
local metadata = values[4] or EMPTY_TABLE
local isWarning = metadata.isWarning
-- Special case for .Source updates
-- because we want to display a syntax highlighted diff for better UX
if self.props.showSourceDiff and tostring(values[1]) == "Source" then
rows[row] = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
BackgroundColor3 = theme.Diff.Row,
BorderSizePixel = 0,
LayoutOrder = row,
}, {
Padding = e("UIPadding", pad),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Text = (if isWarning then "⚠ " else "") .. tostring(values[1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
Button = e("TextButton", {
Text = "",
Size = UDim2.new(0.7, 0, 1, -4),
LayoutOrder = 2,
BackgroundTransparency = 1,
[Roact.Event.Activated] = function()
if props.showSourceDiff then
props.showSourceDiff(tostring(values[2]), tostring(values[3]))
end
end,
}, {
e(BorderedContainer, {
size = UDim2.new(1, 0, 1, 0),
transparency = self.props.transparency:map(function(t)
return 0.5 + (0.5 * t)
end),
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 5),
}),
Label = e("TextLabel", {
Text = "View Diff",
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0, 65, 1, 0),
LayoutOrder = 1,
}),
Icon = e("ImageLabel", {
Image = Assets.Images.Icons.Expand,
ImageColor3 = theme.Settings.Setting.DescriptionColor,
ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
}),
}),
})
continue
end
rows[row] = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
@ -192,44 +227,25 @@ function ChangeList:render()
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
ColumnA = e("TextLabel", {
Text = (if isWarning then "⚠ " else "") .. tostring(values[1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
TextColor3 = if isWarning then theme.Diff.Warning else theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
B = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
},
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
})
),
C = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
},
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
})
),
Content = e(RowContent, {
values = values,
metadata = metadata,
transparency = props.transparency,
showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
}),
})
end

View file

@ -30,7 +30,7 @@ local function DisplayValue(props)
}),
}),
Label = e("TextLabel", {
Text = string.format("%d,%d,%d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
Text = string.format("%d, %d, %d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
@ -104,8 +104,13 @@ local function DisplayValue(props)
-- Or special text handling tostring for some?
-- Will add as needed, let's see what cases arise.
local textRepresentation = string.gsub(tostring(props.value), "%s", " ")
if t == "string" then
textRepresentation = '"' .. textRepresentation .. '"'
end
return e("TextLabel", {
Text = string.gsub(tostring(props.value), "%s", " "),
Text = textRepresentation,
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,

View file

@ -34,7 +34,8 @@ function Expansion:render()
ChangeList = e(ChangeList, {
changes = props.changeList,
transparency = props.transparency,
showSourceDiff = props.showSourceDiff,
showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
}),
})
end
@ -171,7 +172,8 @@ function DomLabel:render()
indent = indent,
transparency = props.transparency,
changeList = props.changeList,
showSourceDiff = props.showSourceDiff,
showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
})
else nil,
DiffIcon = if props.patchType
@ -205,7 +207,7 @@ function DomLabel:render()
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = if props.isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
TextColor3 = if props.isWarning then theme.Diff.Warning else theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,

View file

@ -76,7 +76,8 @@ function PatchVisualizer:render()
changeList = node.changeList,
depth = depth,
transparency = self.props.transparency,
showSourceDiff = self.props.showSourceDiff,
showStringDiff = self.props.showStringDiff,
showTableDiff = self.props.showTableDiff,
})
)
end
@ -98,7 +99,7 @@ function PatchVisualizer:render()
Text = "No changes to sync, project is up to date.",
Font = Enum.Font.GothamMedium,
TextSize = 15,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = theme.TextColor,
TextWrapped = true,
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,

View file

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

View file

@ -52,7 +52,7 @@ function StringDiffVisualizer:updateScriptBackground()
end
function StringDiffVisualizer:didUpdate(previousProps)
if previousProps.oldText ~= self.props.oldText or previousProps.newText ~= self.props.newText then
if previousProps.oldString ~= self.props.oldString or previousProps.newString ~= self.props.newString then
self:calculateContentSize()
local add, remove = self:calculateDiffLines()
self:setState({
@ -63,28 +63,28 @@ function StringDiffVisualizer:didUpdate(previousProps)
end
function StringDiffVisualizer:calculateContentSize()
local oldText, newText = self.props.oldText, self.props.newText
local oldString, newString = self.props.oldString, self.props.newString
local oldTextBounds = TextService:GetTextSize(oldText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
local newTextBounds = TextService:GetTextSize(newText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
local oldStringBounds = TextService:GetTextSize(oldString, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
local newStringBounds = TextService:GetTextSize(newString, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
self.setContentSize(
Vector2.new(math.max(oldTextBounds.X, newTextBounds.X), math.max(oldTextBounds.Y, newTextBounds.Y))
Vector2.new(math.max(oldStringBounds.X, newStringBounds.X), math.max(oldStringBounds.Y, newStringBounds.Y))
)
end
function StringDiffVisualizer:calculateDiffLines()
local oldText, newText = self.props.oldText, self.props.newText
local oldString, newString = self.props.oldString, self.props.newString
-- Diff the two texts
local startClock = os.clock()
local diffs = StringDiff.findDiffs(oldText, newText)
local diffs = StringDiff.findDiffs(oldString, newString)
local stopClock = os.clock()
Log.trace(
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
#oldText,
#newText,
#oldString,
#newString,
math.round((stopClock - startClock) * 1000 * 1000),
#diffs
)
@ -137,7 +137,7 @@ function StringDiffVisualizer:calculateDiffLines()
end
function StringDiffVisualizer:render()
local oldText, newText = self.props.oldText, self.props.newText
local oldString, newString = self.props.oldString, self.props.newString
return Theme.with(function(theme)
return e(BorderedContainer, {
@ -175,7 +175,7 @@ function StringDiffVisualizer:render()
Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0),
text = oldText,
text = oldString,
lineBackground = theme.Diff.Remove,
markedLines = self.state.remove,
}),
@ -190,7 +190,7 @@ function StringDiffVisualizer:render()
Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0),
text = newText,
text = newString,
lineBackground = theme.Diff.Add,
markedLines = self.state.add,
}),

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer)
local e = Roact.createElement
@ -24,9 +25,12 @@ function ConfirmingPage:init()
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({
showingSourceDiff = false,
oldSource = "",
newSource = "",
showingStringDiff = false,
oldString = "",
newString = "",
showingTableDiff = false,
oldTable = {},
newTable = {},
})
end
@ -47,7 +51,7 @@ function ConfirmingPage:render()
Font = Enum.Font.Gotham,
LineHeight = 1.2,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 20),
@ -63,11 +67,18 @@ function ConfirmingPage:render()
patch = self.props.confirmData.patch,
instanceMap = self.props.confirmData.instanceMap,
showSourceDiff = function(oldSource: string, newSource: string)
showStringDiff = function(oldString: string, newString: string)
self:setState({
showingSourceDiff = true,
oldSource = oldSource,
newSource = newSource,
showingStringDiff = true,
oldString = oldString,
newString = newString,
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
self:setState({
showingTableDiff = true,
oldTable = oldTable,
newTable = newTable,
})
end,
}),
@ -136,10 +147,10 @@ function ConfirmingPage:render()
PaddingRight = UDim.new(0, 20),
}),
SourceDiff = e(StudioPluginGui, {
id = "Rojo_ConfirmingSourceDiff",
title = "Source diff",
active = self.state.showingSourceDiff,
StringDiff = e(StudioPluginGui, {
id = "Rojo_ConfirmingStringDiff",
title = "String diff",
active = self.state.showingStringDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
@ -151,7 +162,7 @@ function ConfirmingPage:render()
onClose = function()
self:setState({
showingSourceDiff = false,
showingStringDiff = false,
})
end,
}, {
@ -167,8 +178,46 @@ function ConfirmingPage:render()
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldText = self.state.oldSource,
newText = self.state.newSource,
oldString = self.state.oldString,
newString = self.state.newString,
}),
}),
}),
}),
TableDiff = e(StudioPluginGui, {
id = "Rojo_ConfirmingTableDiff",
title = "Table diff",
active = self.state.showingTableDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({
showingTableDiff = false,
})
end,
}, {
TooltipsProvider = e(Tooltip.Provider, nil, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
e(TableDiffVisualizer, {
size = UDim2.new(1, -10, 1, -10),
position = UDim2.new(0, 5, 0, 5),
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldTable = self.state.oldTable,
newTable = self.state.newTable,
}),
}),
}),

View file

@ -18,6 +18,7 @@ local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer)
local e = Roact.createElement
@ -97,7 +98,8 @@ function ChangesDrawer:render()
patchTree = self.props.patchTree,
showSourceDiff = self.props.showSourceDiff,
showStringDiff = self.props.showStringDiff,
showTableDiff = self.props.showTableDiff,
}),
})
end)
@ -239,9 +241,9 @@ function ConnectedPage:init()
self:setState({
renderChanges = false,
hoveringChangeInfo = false,
showingSourceDiff = false,
oldSource = "",
newSource = "",
showingStringDiff = false,
oldString = "",
newString = "",
})
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
@ -258,7 +260,7 @@ function ConnectedPage:didUpdate(previousProps)
-- New patch recieved
self:startChangeInfoTextUpdater()
self:setState({
showingSourceDiff = false,
showingStringDiff = false,
})
end
end
@ -387,11 +389,18 @@ function ConnectedPage:render()
height = self.changeDrawerHeight,
layoutOrder = 5,
showSourceDiff = function(oldSource: string, newSource: string)
showStringDiff = function(oldString: string, newString: string)
self:setState({
showingSourceDiff = true,
oldSource = oldSource,
newSource = newSource,
showingStringDiff = true,
oldString = oldString,
newString = newString,
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
self:setState({
showingTableDiff = true,
oldTable = oldTable,
newTable = newTable,
})
end,
@ -403,10 +412,10 @@ function ConnectedPage:render()
end,
}),
SourceDiff = e(StudioPluginGui, {
id = "Rojo_ConnectedSourceDiff",
title = "Source diff",
active = self.state.showingSourceDiff,
StringDiff = e(StudioPluginGui, {
id = "Rojo_ConnectedStringDiff",
title = "String diff",
active = self.state.showingStringDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
@ -418,7 +427,7 @@ function ConnectedPage:render()
onClose = function()
self:setState({
showingSourceDiff = false,
showingStringDiff = false,
})
end,
}, {
@ -434,8 +443,46 @@ function ConnectedPage:render()
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldText = self.state.oldSource,
newText = self.state.newSource,
oldString = self.state.oldString,
newString = self.state.newString,
}),
}),
}),
}),
TableDiff = e(StudioPluginGui, {
id = "Rojo_ConnectedTableDiff",
title = "Table diff",
active = self.state.showingTableDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = false,
floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({
showingTableDiff = false,
})
end,
}, {
TooltipsProvider = e(Tooltip.Provider, nil, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
e(TableDiffVisualizer, {
size = UDim2.new(1, -10, 1, -10),
position = UDim2.new(0, 5, 0, 5),
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldTable = self.state.oldTable,
newTable = self.state.newTable,
}),
}),
}),

View file

@ -25,227 +25,7 @@ local strict = require(script.Parent.Parent.strict)
local BRAND_COLOR = Color3.fromHex("E13835")
local lightTheme = strict("LightTheme", {
BackgroundColor = Color3.fromHex("FFFFFF"),
Button = {
Solid = {
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = Color3.fromHex("000000"),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = Color3.fromHex("393939"),
BorderColor = Color3.fromHex("ACACAC"),
},
Disabled = {
TextColor = Color3.fromHex("393939"),
BorderColor = Color3.fromHex("ACACAC"),
},
},
},
Checkbox = {
Active = {
IconColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = Color3.fromHex("EEEEEE"),
BorderColor = Color3.fromHex("AFAFAF"),
},
},
Dropdown = {
TextColor = Color3.fromHex("000000"),
BorderColor = Color3.fromHex("AFAFAF"),
BackgroundColor = Color3.fromHex("EEEEEE"),
Open = {
IconColor = BRAND_COLOR,
},
Closed = {
IconColor = Color3.fromHex("EEEEEE"),
},
},
TextInput = {
Enabled = {
TextColor = Color3.fromHex("000000"),
PlaceholderColor = Color3.fromHex("8C8C8C"),
BorderColor = Color3.fromHex("ACACAC"),
},
Disabled = {
TextColor = Color3.fromHex("393939"),
PlaceholderColor = Color3.fromHex("8C8C8C"),
BorderColor = Color3.fromHex("AFAFAF"),
},
ActionFillColor = Color3.fromHex("000000"),
ActionFillTransparency = 0.9,
},
AddressEntry = {
TextColor = Color3.fromHex("000000"),
PlaceholderColor = Color3.fromHex("8C8C8C"),
},
BorderedContainer = {
BorderColor = Color3.fromHex("CBCBCB"),
BackgroundColor = Color3.fromHex("EEEEEE"),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = Color3.fromHex("EEEEEE"),
},
Diff = {
Add = Color3.fromHex("baffbd"),
Remove = Color3.fromHex("ffbdba"),
Edit = Color3.fromHex("bacdff"),
Row = Color3.fromHex("000000"),
Warning = Color3.fromHex("FF8E3C"),
},
ConnectionDetails = {
ProjectNameColor = Color3.fromHex("000000"),
AddressColor = Color3.fromHex("000000"),
DisconnectColor = BRAND_COLOR,
},
Settings = {
DividerColor = Color3.fromHex("CBCBCB"),
Navbar = {
BackButtonColor = Color3.fromHex("000000"),
TextColor = Color3.fromHex("000000"),
},
Setting = {
NameColor = Color3.fromHex("000000"),
DescriptionColor = Color3.fromHex("5F5F5F"),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = Color3.fromHex("727272"),
},
Notification = {
InfoColor = Color3.fromHex("000000"),
CloseColor = BRAND_COLOR,
},
ErrorColor = Color3.fromHex("000000"),
ScrollBarColor = Color3.fromHex("000000"),
})
local darkTheme = strict("DarkTheme", {
BackgroundColor = Color3.fromHex("2E2E2E"),
Button = {
Solid = {
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = Color3.fromHex("DBDBDB"),
BorderColor = Color3.fromHex("535353"),
},
Disabled = {
TextColor = Color3.fromHex("DBDBDB"),
BorderColor = Color3.fromHex("535353"),
},
},
},
Checkbox = {
Active = {
IconColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = Color3.fromHex("484848"),
BorderColor = Color3.fromHex("5A5A5A"),
},
},
Dropdown = {
TextColor = Color3.fromHex("FFFFFF"),
BorderColor = Color3.fromHex("5A5A5A"),
BackgroundColor = Color3.fromHex("2B2B2B"),
Open = {
IconColor = BRAND_COLOR,
},
Closed = {
IconColor = Color3.fromHex("484848"),
},
},
TextInput = {
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
PlaceholderColor = Color3.fromHex("8B8B8B"),
BorderColor = Color3.fromHex("535353"),
},
Disabled = {
TextColor = Color3.fromHex("484848"),
PlaceholderColor = Color3.fromHex("8B8B8B"),
BorderColor = Color3.fromHex("5A5A5A"),
},
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.9,
},
AddressEntry = {
TextColor = Color3.fromHex("FFFFFF"),
PlaceholderColor = Color3.fromHex("8B8B8B"),
},
BorderedContainer = {
BorderColor = Color3.fromHex("535353"),
BackgroundColor = Color3.fromHex("2B2B2B"),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = Color3.fromHex("2B2B2B"),
},
Diff = {
Add = Color3.fromHex("273732"),
Remove = Color3.fromHex("3F2D32"),
Edit = Color3.fromHex("193345"),
Row = Color3.fromHex("FFFFFF"),
Warning = Color3.fromHex("FF8E3C"),
},
ConnectionDetails = {
ProjectNameColor = Color3.fromHex("FFFFFF"),
AddressColor = Color3.fromHex("FFFFFF"),
DisconnectColor = Color3.fromHex("FFFFFF"),
},
Settings = {
DividerColor = Color3.fromHex("535353"),
Navbar = {
BackButtonColor = Color3.fromHex("FFFFFF"),
TextColor = Color3.fromHex("FFFFFF"),
},
Setting = {
NameColor = Color3.fromHex("FFFFFF"),
DescriptionColor = Color3.fromHex("D3D3D3"),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = Color3.fromHex("D3D3D3"),
},
Notification = {
InfoColor = Color3.fromHex("FFFFFF"),
CloseColor = Color3.fromHex("FFFFFF"),
},
ErrorColor = Color3.fromHex("FFFFFF"),
ScrollBarColor = Color3.fromHex("FFFFFF"),
})
local Context = Roact.createContext(lightTheme)
local Context = Roact.createContext({})
local StudioProvider = Roact.Component:extend("StudioProvider")
@ -253,21 +33,153 @@ local StudioProvider = Roact.Component:extend("StudioProvider")
function StudioProvider:updateTheme()
local studioTheme = getStudio().Theme
if studioTheme.Name == "Light" then
self:setState({
theme = lightTheme,
})
elseif studioTheme.Name == "Dark" then
self:setState({
theme = darkTheme,
})
else
Log.warn("Unexpected theme '{}'' -- falling back to light theme!", studioTheme.Name)
local theme = strict(studioTheme.Name .. "Theme", {
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground),
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
Button = {
Solid = {
-- Solid uses brand theming, not Studio theming.
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.ButtonText,
Enum.StudioStyleGuideModifier.Selected
),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.ButtonText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
Disabled = {
TextColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.ButtonText,
Enum.StudioStyleGuideModifier.Disabled
),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
},
},
Checkbox = {
Active = {
-- Active checkboxes use brand theming, not Studio theming.
IconColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldIndicator,
Enum.StudioStyleGuideModifier.Disabled
),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
},
Dropdown = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.ButtonText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground),
IconColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldIndicator,
Enum.StudioStyleGuideModifier.Disabled
),
},
TextInput = {
Enabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
Disabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
ActionFillColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
ActionFillTransparency = 0.9,
},
AddressEntry = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
},
BorderedContainer = {
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
},
Diff = {
Add = studioTheme:GetColor(Enum.StudioStyleGuideColor.DiffTextAdditionBackground),
Remove = studioTheme:GetColor(Enum.StudioStyleGuideColor.DiffTextDeletionBackground),
Edit = studioTheme:GetColor(Enum.StudioStyleGuideColor.DiffLineNumSeparatorBackground),
Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
},
ConnectionDetails = {
ProjectNameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
AddressColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
DisconnectColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Settings = {
DividerColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
Navbar = {
BackButtonColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Setting = {
NameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
DescriptionColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
},
Notification = {
InfoColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
CloseColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
ErrorColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
ScrollBarColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
})
self:setState({
theme = lightTheme,
})
end
self:setState({
theme = theme,
})
end
function StudioProvider:init()

View file

@ -0,0 +1,18 @@
---
source: tests/tests/build.rs
assertion_line: 102
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">sync_rule_alone</string>
</Properties>
<Item class="StringValue" referent="1">
<Properties>
<string name="Name">foo</string>
<string name="Value">Hello, world!</string>
</Properties>
</Item>
</Item>
</roblox>

View file

@ -0,0 +1,43 @@
---
source: tests/tests/build.rs
assertion_line: 104
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">sync_rule_complex</string>
</Properties>
<Item class="Script" referent="1">
<Properties>
<string name="Name">bar</string>
<token name="RunContext">0</token>
<string name="Source">-- Hello, from bar (a Script)!</string>
</Properties>
</Item>
<Item class="LocalScript" referent="2">
<Properties>
<string name="Name">baz</string>
<string name="Source">-- Hello, from baz (a LocalScript)!</string>
</Properties>
</Item>
<Item class="StringValue" referent="3">
<Properties>
<string name="Name">cat</string>
<string name="Value">Hello, from cat (a StringValue)!</string>
</Properties>
</Item>
<Item class="ModuleScript" referent="4">
<Properties>
<string name="Name">foo</string>
<string name="Source">-- Hello, from foo (a ModuleScript)!</string>
</Properties>
</Item>
<Item class="StringValue" referent="5">
<Properties>
<string name="Name">qux</string>
<string name="Value">Hello, from qux (a .rojo file that's turned into a StringValue)!</string>
</Properties>
</Item>
</Item>
</roblox>

View file

@ -0,0 +1,12 @@
---
source: tests/tests/build.rs
assertion_line: 104
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">sync_rule_nested_projects</string>
</Properties>
</Item>
</roblox>

View file

@ -0,0 +1,12 @@
{
"name": "sync_rule_alone",
"tree": {
"$path": "src"
},
"syncRules": [
{
"pattern": "*.nothing",
"use": "text"
}
]
}

View file

@ -0,0 +1 @@
Hello, world!

View file

@ -0,0 +1,30 @@
{
"name": "sync_rule_complex",
"tree": {
"$path": "src"
},
"syncRules": [
{
"pattern": "*.module",
"use": "moduleScript"
},
{
"pattern": "*.server",
"use": "serverScript"
},
{
"pattern": "*.client",
"use": "clientScript"
},
{
"pattern": "*.rojo",
"exclude": "*.ignore.rojo",
"use": "project"
},
{
"pattern": "*.dog.rojo2",
"use": "text",
"suffix": ".dog.rojo2"
}
]
}

View file

@ -0,0 +1 @@
-- Hello, from bar (a Script)!

View file

@ -0,0 +1 @@
-- Hello, from baz (a LocalScript)!

View file

@ -0,0 +1 @@
Hello, from cat (a StringValue)!

View file

@ -0,0 +1 @@
-- Hello, from foo (a ModuleScript)!

View file

@ -0,0 +1,9 @@
{
"name": "qux",
"tree": {
"$className": "StringValue",
"$properties": {
"Value": "Hello, from qux (a .rojo file that's turned into a StringValue)!"
}
}
}

View file

@ -0,0 +1 @@
This file should be ignored!

View file

@ -0,0 +1,12 @@
{
"name": "sync_rule_nested_projects",
"tree": {
"$path": "nested.project.json"
},
"syncRules": [
{
"pattern": "*.rojo",
"use": "text"
}
]
}

View file

@ -0,0 +1,12 @@
{
"name": "nested",
"tree": {
"$path": "src"
},
"syncRules": [
{
"pattern": "*.txt",
"use": "ignore"
}
]
}

View file

@ -0,0 +1 @@
This shouldn't be in the built file. If it is, something is wrong.

View file

@ -0,0 +1 @@
This shouldn't be in the built file. If it is, something is wrong.

View file

@ -0,0 +1,30 @@
---
source: tests/tests/serve.rs
assertion_line: 268
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children:
- id-3
ClassName: Folder
Id: id-2
Metadata:
ignoreUnknownInstances: false
Name: sync_rule_alone
Parent: "00000000000000000000000000000000"
Properties: {}
id-3:
Children: []
ClassName: StringValue
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: foo
Parent: id-2
Properties:
Value:
String: "Hello, world!"
messageCursor: 0
sessionId: id-1

View file

@ -0,0 +1,14 @@
---
source: tests/tests/serve.rs
assertion_line: 265
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: sync_rule_alone
protocolVersion: 4
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View file

@ -0,0 +1,80 @@
---
source: tests/tests/serve.rs
assertion_line: 284
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children:
- id-3
- id-4
- id-5
- id-6
- id-7
ClassName: Folder
Id: id-2
Metadata:
ignoreUnknownInstances: false
Name: sync_rule_complex
Parent: "00000000000000000000000000000000"
Properties: {}
id-3:
Children: []
ClassName: Script
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: bar
Parent: id-2
Properties:
RunContext:
Enum: 0
Source:
String: "-- Hello, from bar (a Script)!"
id-4:
Children: []
ClassName: LocalScript
Id: id-4
Metadata:
ignoreUnknownInstances: false
Name: baz
Parent: id-2
Properties:
Source:
String: "-- Hello, from baz (a LocalScript)!"
id-5:
Children: []
ClassName: StringValue
Id: id-5
Metadata:
ignoreUnknownInstances: false
Name: cat
Parent: id-2
Properties:
Value:
String: "Hello, from cat (a StringValue)!"
id-6:
Children: []
ClassName: ModuleScript
Id: id-6
Metadata:
ignoreUnknownInstances: false
Name: foo
Parent: id-2
Properties:
Source:
String: "-- Hello, from foo (a ModuleScript)!"
id-7:
Children: []
ClassName: StringValue
Id: id-7
Metadata:
ignoreUnknownInstances: true
Name: qux
Parent: id-2
Properties:
Value:
String: "Hello, from qux (a .rojo file that's turned into a StringValue)!"
messageCursor: 0
sessionId: id-1

View file

@ -0,0 +1,14 @@
---
source: tests/tests/serve.rs
assertion_line: 281
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: sync_rule_complex
protocolVersion: 4
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View file

@ -0,0 +1,30 @@
---
source: tests/tests/serve.rs
assertion_line: 303
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children:
- id-3
ClassName: Folder
Id: id-2
Metadata:
ignoreUnknownInstances: false
Name: sync_rule_no_extension
Parent: "00000000000000000000000000000000"
Properties: {}
id-3:
Children: []
ClassName: ModuleScript
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: no_extension
Parent: id-2
Properties:
Source:
String: "return {\"This file has no extension but should be a ModuleScript named `no_extension`\"}"
messageCursor: 0
sessionId: id-1

View file

@ -0,0 +1,14 @@
---
source: tests/tests/serve.rs
assertion_line: 297
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: sync_rule_no_extension
protocolVersion: 4
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View file

@ -0,0 +1,12 @@
{
"name": "sync_rule_alone",
"tree": {
"$path": "src"
},
"syncRules": [
{
"pattern": "*.nothing",
"use": "text"
}
]
}

View file

@ -0,0 +1 @@
Hello, world!

View file

@ -0,0 +1,30 @@
{
"name": "sync_rule_complex",
"tree": {
"$path": "src"
},
"syncRules": [
{
"pattern": "*.module",
"use": "moduleScript"
},
{
"pattern": "*.server",
"use": "serverScript"
},
{
"pattern": "*.client",
"use": "clientScript"
},
{
"pattern": "*.rojo",
"exclude": "*.ignore.rojo",
"use": "project"
},
{
"pattern": "*.dog.rojo2",
"use": "text",
"suffix": ".dog.rojo2"
}
]
}

View file

@ -0,0 +1 @@
-- Hello, from bar (a Script)!

View file

@ -0,0 +1 @@
-- Hello, from baz (a LocalScript)!

View file

@ -0,0 +1 @@
Hello, from cat (a StringValue)!

View file

@ -0,0 +1 @@
-- Hello, from foo (a ModuleScript)!

View file

@ -0,0 +1,9 @@
{
"name": "qux",
"tree": {
"$className": "StringValue",
"$properties": {
"Value": "Hello, from qux (a .rojo file that's turned into a StringValue)!"
}
}
}

View file

@ -0,0 +1 @@
This file should be ignored!

View file

@ -0,0 +1,12 @@
{
"name": "sync_rule_no_extension",
"tree": {
"$path": "src"
},
"syncRules": [
{
"pattern": "src/**",
"use": "json"
}
]
}

View file

@ -0,0 +1 @@
["This file has no extension but should be a ModuleScript named `no_extension`"]

View file

@ -8,7 +8,7 @@ use std::{
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{glob::Glob, resolution::UnresolvedValue};
use crate::{glob::Glob, resolution::UnresolvedValue, snapshot::SyncRule};
static PROJECT_FILENAME: &str = "default.project.json";
@ -84,6 +84,12 @@ pub struct Project {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub glob_ignore_paths: Vec<Glob>,
/// A list of mappings of globs to syncing rules. If a file matches a glob,
/// it will be 'transformed' into an Instance following the rule provided.
/// Globs are relative to the folder the project file is in.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sync_rules: Vec<SyncRule>,
/// The path to the file that this project came from. Relative paths in the
/// project should be considered relative to the parent of this field, also
/// given by `Project::folder_location`.

View file

@ -4,11 +4,14 @@ use std::{
sync::Arc,
};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::{
glob::Glob, path_serializer, project::ProjectNode,
snapshot_middleware::emit_legacy_scripts_default,
glob::Glob,
path_serializer,
project::ProjectNode,
snapshot_middleware::{emit_legacy_scripts_default, Middleware},
};
/// Rojo-specific metadata that can be associated with an instance or a snapshot
@ -107,6 +110,8 @@ pub struct InstanceContext {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub path_ignore_rules: Arc<Vec<PathIgnoreRule>>,
pub emit_legacy_scripts: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sync_rules: Vec<SyncRule>,
}
impl InstanceContext {
@ -114,6 +119,7 @@ impl InstanceContext {
Self {
path_ignore_rules: Arc::new(Vec::new()),
emit_legacy_scripts: emit_legacy_scripts_default().unwrap(),
sync_rules: Vec::new(),
}
}
@ -144,9 +150,28 @@ impl InstanceContext {
rules.extend(new_rules);
}
/// Extend the list of syncing rules in the context with the given new rules.
pub fn add_sync_rules<I>(&mut self, new_rules: I)
where
I: IntoIterator<Item = SyncRule>,
{
self.sync_rules.extend(new_rules);
}
/// Clears all sync rules for this InstanceContext
pub fn clear_sync_rules(&mut self) {
self.sync_rules.clear();
}
pub fn set_emit_legacy_scripts(&mut self, emit_legacy_scripts: bool) {
self.emit_legacy_scripts = emit_legacy_scripts;
}
/// Returns the middleware specified by the first sync rule that
/// matches the provided path. This does not handle default syncing rules.
pub fn get_user_sync_rule(&self, path: &Path) -> Option<&SyncRule> {
self.sync_rules.iter().find(|&rule| rule.matches(path))
}
}
impl Default for InstanceContext {
@ -216,3 +241,64 @@ impl From<&Path> for InstigatingSource {
InstigatingSource::Path(path.to_path_buf())
}
}
/// Represents an user-specified rule for transforming files
/// into Instances using a given middleware.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct SyncRule {
/// A pattern used to determine if a file is included in this SyncRule
#[serde(rename = "pattern")]
pub include: Glob,
/// A pattern used to determine if a file is excluded from this SyncRule.
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude: Option<Glob>,
/// The middleware specified by the user for this SyncRule
#[serde(rename = "use")]
pub middleware: Middleware,
/// A suffix to trim off of file names, including the file extension.
/// If not specified, the file extension is the only thing cut off.
#[serde(skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
/// The 'base' of the glob above, allowing it to be used
/// relative to a path instead of absolute.
#[serde(skip)]
pub base_path: PathBuf,
}
impl SyncRule {
/// Returns whether the given path matches this rule.
pub fn matches(&self, path: &Path) -> bool {
match path.strip_prefix(&self.base_path) {
Ok(suffix) => {
if let Some(pattern) = &self.exclude {
if pattern.is_match(suffix) {
return false;
}
}
self.include.is_match(suffix)
}
Err(_) => false,
}
}
pub fn file_name_for_path<'a>(&self, path: &'a Path) -> anyhow::Result<&'a str> {
if let Some(suffix) = &self.suffix {
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.with_context(|| format!("file name of {} is invalid", path.display()))?;
if file_name.ends_with(suffix) {
let end = file_name.len().saturating_sub(suffix.len());
Ok(&file_name[..end])
} else {
Ok(file_name)
}
} else {
// If the user doesn't specify a suffix, we assume they just want
// the name of the file (the file_stem)
path.file_stem()
.and_then(|s| s.to_str())
.with_context(|| format!("file name of {} is invalid", path.display()))
}
}
}

View file

@ -10,16 +10,14 @@ use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
use super::{
dir::{dir_meta, snapshot_dir_no_meta},
meta_file::AdjacentMetadata,
util::PathExt,
};
pub fn snapshot_csv(
_context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".csv")?;
let meta_path = path.with_file_name(format!("{}.meta.json", name));
let contents = vfs.read(path)?;
@ -74,9 +72,8 @@ pub fn snapshot_csv_init(
);
}
let mut init_snapshot = snapshot_csv(context, vfs, init_path)?.unwrap();
let mut init_snapshot = snapshot_csv(context, vfs, init_path, &dir_snapshot.name)?.unwrap();
init_snapshot.name = dir_snapshot.name;
init_snapshot.children = dir_snapshot.children;
init_snapshot.metadata = dir_snapshot.metadata;
@ -185,10 +182,14 @@ Ack,Ack!,,An exclamation of despair,¡Ay!"#,
let mut vfs = Vfs::new(imfs);
let instance_snapshot =
snapshot_csv(&InstanceContext::default(), &mut vfs, Path::new("/foo.csv"))
.unwrap()
.unwrap();
let instance_snapshot = snapshot_csv(
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.csv"),
"foo",
)
.unwrap()
.unwrap();
insta::assert_yaml_snapshot!(instance_snapshot);
}
@ -213,10 +214,14 @@ Ack,Ack!,,An exclamation of despair,¡Ay!"#,
let mut vfs = Vfs::new(imfs);
let instance_snapshot =
snapshot_csv(&InstanceContext::default(), &mut vfs, Path::new("/foo.csv"))
.unwrap()
.unwrap();
let instance_snapshot = snapshot_csv(
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.csv"),
"foo",
)
.unwrap()
.unwrap();
insta::assert_yaml_snapshot!(instance_snapshot);
}

View file

@ -9,14 +9,14 @@ use crate::{
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
};
use super::{meta_file::AdjacentMetadata, util::PathExt};
use super::meta_file::AdjacentMetadata;
pub fn snapshot_json(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".json")?;
let contents = vfs.read(path)?;
let value: serde_json::Value = serde_json::from_slice(&contents)
@ -107,6 +107,7 @@ mod test {
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.json"),
"foo",
)
.unwrap()
.unwrap();

View file

@ -10,15 +10,12 @@ use crate::{
snapshot::{InstanceContext, InstanceSnapshot},
};
use super::util::PathExt;
pub fn snapshot_json_model(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".model.json")?;
let contents = vfs.read(path)?;
let contents_str = str::from_utf8(&contents)
.with_context(|| format!("File was not valid UTF-8: {}", path.display()))?;
@ -158,6 +155,7 @@ mod test {
&InstanceContext::default(),
&vfs,
Path::new("/foo.model.json"),
"foo",
)
.unwrap()
.unwrap();
@ -195,6 +193,7 @@ mod test {
&InstanceContext::default(),
&vfs,
Path::new("/foo.model.json"),
"foo",
)
.unwrap()
.unwrap();

View file

@ -9,11 +9,10 @@ use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
use super::{
dir::{dir_meta, snapshot_dir_no_meta},
meta_file::AdjacentMetadata,
util::match_trailing,
};
#[derive(Debug)]
enum ScriptType {
pub enum ScriptType {
Server,
Client,
Module,
@ -24,32 +23,15 @@ pub fn snapshot_lua(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
script_type: ScriptType,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let file_name = path.file_name().unwrap().to_string_lossy();
let run_context_enums = &rbx_reflection_database::get()
.enums
.get("RunContext")
.expect("Unable to get RunContext enums!")
.items;
let (script_type, instance_name) = if let Some(name) = match_trailing(&file_name, ".server.lua")
{
(ScriptType::Server, name)
} else if let Some(name) = match_trailing(&file_name, ".client.lua") {
(ScriptType::Client, name)
} else if let Some(name) = match_trailing(&file_name, ".lua") {
(ScriptType::Module, name)
} else if let Some(name) = match_trailing(&file_name, ".server.luau") {
(ScriptType::Server, name)
} else if let Some(name) = match_trailing(&file_name, ".client.luau") {
(ScriptType::Client, name)
} else if let Some(name) = match_trailing(&file_name, ".luau") {
(ScriptType::Module, name)
} else {
return Ok(None);
};
let (class_name, run_context) = match (context.emit_legacy_scripts, script_type) {
(false, ScriptType::Server) => ("Script", run_context_enums.get("Server")),
(false, ScriptType::Client) => ("Script", run_context_enums.get("Client")),
@ -73,10 +55,10 @@ pub fn snapshot_lua(
);
}
let meta_path = path.with_file_name(format!("{}.meta.json", instance_name));
let meta_path = path.with_file_name(format!("{}.meta.json", name));
let mut snapshot = InstanceSnapshot::new()
.name(instance_name)
.name(name)
.class_name(class_name)
.properties(properties)
.metadata(
@ -103,6 +85,7 @@ pub fn snapshot_lua_init(
context: &InstanceContext,
vfs: &Vfs,
init_path: &Path,
script_type: ScriptType,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let folder_path = init_path.parent().unwrap();
let dir_snapshot = snapshot_dir_no_meta(context, vfs, folder_path)?.unwrap();
@ -119,9 +102,9 @@ pub fn snapshot_lua_init(
);
}
let mut init_snapshot = snapshot_lua(context, vfs, init_path)?.unwrap();
let mut init_snapshot =
snapshot_lua(context, vfs, init_path, &dir_snapshot.name, script_type)?.unwrap();
init_snapshot.name = dir_snapshot.name;
init_snapshot.children = dir_snapshot.children;
init_snapshot.metadata = dir_snapshot.metadata;
@ -151,6 +134,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&mut vfs,
Path::new("/foo.lua"),
"foo",
ScriptType::Module,
)
.unwrap()
.unwrap();
@ -172,6 +157,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&mut vfs,
Path::new("/foo.lua"),
"foo",
ScriptType::Module,
)
.unwrap()
.unwrap();
@ -193,6 +180,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&mut vfs,
Path::new("/foo.server.lua"),
"foo",
ScriptType::Server,
)
.unwrap()
.unwrap();
@ -214,6 +203,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&mut vfs,
Path::new("/foo.server.lua"),
"foo",
ScriptType::Server,
)
.unwrap()
.unwrap();
@ -235,6 +226,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&mut vfs,
Path::new("/foo.client.lua"),
"foo",
ScriptType::Client,
)
.unwrap()
.unwrap();
@ -256,6 +249,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&mut vfs,
Path::new("/foo.client.lua"),
"foo",
ScriptType::Client,
)
.unwrap()
.unwrap();
@ -283,6 +278,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&mut vfs,
Path::new("/root"),
"root",
ScriptType::Module,
)
.unwrap()
.unwrap();
@ -315,6 +312,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&mut vfs,
Path::new("/foo.lua"),
"foo",
ScriptType::Module,
)
.unwrap()
.unwrap();
@ -347,6 +346,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&mut vfs,
Path::new("/foo.lua"),
"foo",
ScriptType::Module,
)
.unwrap()
.unwrap();
@ -379,6 +380,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&mut vfs,
Path::new("/foo.server.lua"),
"foo",
ScriptType::Server,
)
.unwrap()
.unwrap();
@ -411,6 +414,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&mut vfs,
Path::new("/foo.server.lua"),
"foo",
ScriptType::Server,
)
.unwrap()
.unwrap();
@ -445,6 +450,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&mut vfs,
Path::new("/bar.server.lua"),
"bar",
ScriptType::Server,
)
.unwrap()
.unwrap();
@ -479,6 +486,8 @@ mod test {
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&mut vfs,
Path::new("/bar.server.lua"),
"bar",
ScriptType::Server,
)
.unwrap()
.unwrap();

View file

@ -18,30 +18,37 @@ mod toml;
mod txt;
mod util;
use std::path::Path;
use std::{
path::{Path, PathBuf},
sync::OnceLock,
};
use anyhow::Context;
use memofs::{IoResultExt, Vfs};
use serde::{Deserialize, Serialize};
use crate::snapshot::{InstanceContext, InstanceSnapshot};
use crate::glob::Glob;
use crate::snapshot::{InstanceContext, InstanceSnapshot, SyncRule};
use self::{
csv::{snapshot_csv, snapshot_csv_init},
dir::snapshot_dir,
json::snapshot_json,
json_model::snapshot_json_model,
lua::{snapshot_lua, snapshot_lua_init},
lua::{snapshot_lua, snapshot_lua_init, ScriptType},
project::snapshot_project,
rbxm::snapshot_rbxm,
rbxmx::snapshot_rbxmx,
toml::snapshot_toml,
txt::snapshot_txt,
util::PathExt,
};
pub use self::{project::snapshot_project_node, util::emit_legacy_scripts_default};
/// The main entrypoint to the snapshot function. This function can be pointed
/// at any path and will return something if Rojo knows how to deal with it.
/// Returns an `InstanceSnapshot` for the provided path.
/// This will inspect the path and find the appropriate middleware for it,
/// taking user-written rules into account. Then, it will attempt to convert
/// the path into an InstanceSnapshot using that middleware.
#[profiling::function]
pub fn snapshot_from_vfs(
context: &InstanceContext,
@ -54,89 +61,234 @@ pub fn snapshot_from_vfs(
};
if meta.is_dir() {
let project_path = path.join("default.project.json");
if vfs.metadata(&project_path).with_not_found()?.is_some() {
return snapshot_project(context, vfs, &project_path);
}
if let Some(init_path) = get_init_path(vfs, path)? {
// TODO: support user-defined init paths
for rule in default_sync_rules() {
if rule.matches(&init_path) {
return match rule.middleware {
Middleware::Project => snapshot_project(context, vfs, &init_path),
let init_path = path.join("init.luau");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return snapshot_lua_init(context, vfs, &init_path);
}
Middleware::ModuleScript => {
snapshot_lua_init(context, vfs, &init_path, ScriptType::Module)
}
Middleware::ServerScript => {
snapshot_lua_init(context, vfs, &init_path, ScriptType::Server)
}
Middleware::ClientScript => {
snapshot_lua_init(context, vfs, &init_path, ScriptType::Client)
}
let init_path = path.join("init.lua");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return snapshot_lua_init(context, vfs, &init_path);
}
Middleware::Csv => snapshot_csv_init(context, vfs, &init_path),
let init_path = path.join("init.server.luau");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return snapshot_lua_init(context, vfs, &init_path);
_ => snapshot_dir(context, vfs, path),
};
}
}
snapshot_dir(context, vfs, path)
} else {
snapshot_dir(context, vfs, path)
}
let init_path = path.join("init.server.lua");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return snapshot_lua_init(context, vfs, &init_path);
}
let init_path = path.join("init.client.luau");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return snapshot_lua_init(context, vfs, &init_path);
}
let init_path = path.join("init.client.lua");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return snapshot_lua_init(context, vfs, &init_path);
}
let init_path = path.join("init.csv");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return snapshot_csv_init(context, vfs, &init_path);
}
snapshot_dir(context, vfs, path)
} else {
let script_name = path
.file_name_trim_end(".lua")
.or_else(|_| path.file_name_trim_end(".luau"));
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.with_context(|| format!("file name of {} is invalid", path.display()))?;
let csv_name = path.file_name_trim_end(".csv");
if let Ok(name) = script_name {
match name {
// init scripts are handled elsewhere and should not turn into
// their own children.
"init" | "init.client" | "init.server" => return Ok(None),
_ => return snapshot_lua(context, vfs, path),
}
} else if path.file_name_ends_with(".project.json") {
return snapshot_project(context, vfs, path);
} else if path.file_name_ends_with(".model.json") {
return snapshot_json_model(context, vfs, path);
} else if path.file_name_ends_with(".meta.json") {
// .meta.json files do not turn into their own instances.
return Ok(None);
} else if path.file_name_ends_with(".json") {
return snapshot_json(context, vfs, path);
} else if path.file_name_ends_with(".toml") {
return snapshot_toml(context, vfs, path);
} else if let Ok(name) = csv_name {
match name {
// init csv are handled elsewhere and should not turn into
// their own children.
"init" => return Ok(None),
_ => return snapshot_csv(context, vfs, path),
}
} else if path.file_name_ends_with(".txt") {
return snapshot_txt(context, vfs, path);
} else if path.file_name_ends_with(".rbxmx") {
return snapshot_rbxmx(context, vfs, path);
} else if path.file_name_ends_with(".rbxm") {
return snapshot_rbxm(context, vfs, path);
// TODO: Is this even necessary anymore?
match file_name {
"init.server.luau" | "init.server.lua" | "init.client.luau" | "init.client.lua"
| "init.luau" | "init.lua" | "init.csv" => return Ok(None),
_ => {}
}
Ok(None)
snapshot_from_path(context, vfs, path)
}
}
/// Gets an `init` path for the given directory.
/// This uses an intrinsic priority list and for compatibility,
/// it should not be changed.
fn get_init_path<P: AsRef<Path>>(vfs: &Vfs, dir: P) -> anyhow::Result<Option<PathBuf>> {
let path = dir.as_ref();
let project_path = path.join("default.project.json");
if vfs.metadata(&project_path).with_not_found()?.is_some() {
return Ok(Some(project_path));
}
let init_path = path.join("init.luau");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return Ok(Some(init_path));
}
let init_path = path.join("init.lua");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return Ok(Some(init_path));
}
let init_path = path.join("init.server.luau");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return Ok(Some(init_path));
}
let init_path = path.join("init.server.lua");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return Ok(Some(init_path));
}
let init_path = path.join("init.client.luau");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return Ok(Some(init_path));
}
let init_path = path.join("init.client.lua");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return Ok(Some(init_path));
}
let init_path = path.join("init.csv");
if vfs.metadata(&init_path).with_not_found()?.is_some() {
return Ok(Some(init_path));
}
Ok(None)
}
/// Gets a snapshot for a path given an InstanceContext and Vfs, taking
/// user specified sync rules into account.
fn snapshot_from_path(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
) -> anyhow::Result<Option<InstanceSnapshot>> {
if let Some(rule) = context.get_user_sync_rule(path) {
return rule
.middleware
.snapshot(context, vfs, path, rule.file_name_for_path(path)?);
} else {
for rule in default_sync_rules() {
if rule.matches(path) {
return rule.middleware.snapshot(
context,
vfs,
path,
rule.file_name_for_path(path)?,
);
}
}
}
Ok(None)
}
/// Represents a possible 'transformer' used by Rojo to turn a file system
/// item into a Roblox Instance. Missing from this list are directories and
/// metadata. This is deliberate, as metadata is not a snapshot middleware
/// and directories do not make sense to turn into files.
#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Middleware {
Csv,
JsonModel,
Json,
ServerScript,
ClientScript,
ModuleScript,
Project,
Rbxm,
Rbxmx,
Toml,
Text,
Ignore,
}
impl Middleware {
/// Creates a snapshot for the given path from the Middleware with
/// the provided name.
fn snapshot(
&self,
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
match self {
Self::Csv => snapshot_csv(context, vfs, path, name),
Self::JsonModel => snapshot_json_model(context, vfs, path, name),
Self::Json => snapshot_json(context, vfs, path, name),
Self::ServerScript => snapshot_lua(context, vfs, path, name, ScriptType::Server),
Self::ClientScript => snapshot_lua(context, vfs, path, name, ScriptType::Client),
Self::ModuleScript => snapshot_lua(context, vfs, path, name, ScriptType::Module),
// At the moment, snapshot_project does not use `name` so we
// don't provide it.
Self::Project => snapshot_project(context, vfs, path),
Self::Rbxm => snapshot_rbxm(context, vfs, path, name),
Self::Rbxmx => snapshot_rbxmx(context, vfs, path, name),
Self::Toml => snapshot_toml(context, vfs, path, name),
Self::Text => snapshot_txt(context, vfs, path, name),
Self::Ignore => Ok(None),
}
}
}
/// A helper for easily defining a SyncRule. Arguments are passed literally
/// to this macro in the order `include`, `middleware`, `suffix`,
/// and `exclude`. Both `suffix` and `exclude` are optional.
///
/// All arguments except `middleware` are expected to be strings.
/// The `middleware` parameter is expected to be a variant of `Middleware`,
/// not including the enum name itself.
macro_rules! sync_rule {
($pattern:expr, $middleware:ident) => {
SyncRule {
middleware: Middleware::$middleware,
include: Glob::new($pattern).unwrap(),
exclude: None,
suffix: None,
base_path: PathBuf::new(),
}
};
($pattern:expr, $middleware:ident, $suffix:expr) => {
SyncRule {
middleware: Middleware::$middleware,
include: Glob::new($pattern).unwrap(),
exclude: None,
suffix: Some($suffix.into()),
base_path: PathBuf::new(),
}
};
($pattern:expr, $middleware:ident, $suffix:expr, $exclude:expr) => {
SyncRule {
middleware: Middleware::$middleware,
include: Glob::new($pattern).unwrap(),
exclude: Some(Glob::new($exclude).unwrap()),
suffix: Some($suffix.into()),
base_path: PathBuf::new(),
}
};
}
/// Defines the 'default' syncing rules that Rojo uses.
/// These do not broadly overlap, but the order matters for some in the case of
/// e.g. JSON models.
fn default_sync_rules() -> &'static [SyncRule] {
static DEFAULT_SYNC_RULES: OnceLock<Vec<SyncRule>> = OnceLock::new();
DEFAULT_SYNC_RULES.get_or_init(|| {
vec![
sync_rule!("*.server.lua", ServerScript, ".server.lua"),
sync_rule!("*.server.luau", ServerScript, ".server.luau"),
sync_rule!("*.client.lua", ClientScript, ".client.lua"),
sync_rule!("*.client.luau", ClientScript, ".client.luau"),
sync_rule!("*.{lua,luau}", ModuleScript),
// Project middleware doesn't use the file name.
sync_rule!("*.project.json", Project),
sync_rule!("*.model.json", JsonModel, ".model.json"),
sync_rule!("*.json", Json, ".json", "*.meta.json"),
sync_rule!("*.toml", Toml),
sync_rule!("*.csv", Csv),
sync_rule!("*.txt", Text),
sync_rule!("*.rbxmx", Rbxmx),
sync_rule!("*.rbxm", Rbxm),
]
})
}

View file

@ -9,6 +9,7 @@ use crate::{
project::{PathNode, Project, ProjectNode},
snapshot::{
InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource, PathIgnoreRule,
SyncRule,
},
};
@ -23,12 +24,19 @@ pub fn snapshot_project(
.with_context(|| format!("File was not a valid Rojo project: {}", path.display()))?;
let mut context = context.clone();
context.clear_sync_rules();
let rules = project.glob_ignore_paths.iter().map(|glob| PathIgnoreRule {
glob: glob.clone(),
base_path: project.folder_location().to_path_buf(),
});
let sync_rules = project.sync_rules.iter().map(|rule| SyncRule {
base_path: project.folder_location().to_path_buf(),
..rule.clone()
});
context.add_sync_rules(sync_rules);
context.add_path_ignore_rules(rules);
context.set_emit_legacy_scripts(
project

View file

@ -5,16 +5,13 @@ use memofs::Vfs;
use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
use super::util::PathExt;
#[profiling::function]
pub fn snapshot_rbxm(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".rbxm")?;
let temp_tree = rbx_binary::from_reader(vfs.read(path)?.as_slice())
.with_context(|| format!("Malformed rbxm file: {}", path.display()))?;
@ -63,6 +60,7 @@ mod test {
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.rbxm"),
"foo",
)
.unwrap()
.unwrap();

View file

@ -5,15 +5,12 @@ use memofs::Vfs;
use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
use super::util::PathExt;
pub fn snapshot_rbxmx(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".rbxmx")?;
let options = rbx_xml::DecodeOptions::new()
.property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown);
@ -75,6 +72,7 @@ mod test {
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.rbxmx"),
"foo",
)
.unwrap()
.unwrap();

View file

@ -9,14 +9,14 @@ use crate::{
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
};
use super::{meta_file::AdjacentMetadata, util::PathExt};
use super::meta_file::AdjacentMetadata;
pub fn snapshot_toml(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".toml")?;
let contents = vfs.read(path)?;
let value: toml::Value = toml::from_slice(&contents)
@ -114,6 +114,7 @@ mod test {
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.toml"),
"foo",
)
.unwrap()
.unwrap();

View file

@ -6,15 +6,14 @@ use memofs::{IoResultExt, Vfs};
use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
use super::{meta_file::AdjacentMetadata, util::PathExt};
use super::meta_file::AdjacentMetadata;
pub fn snapshot_txt(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".txt")?;
let contents = vfs.read(path)?;
let contents_str = str::from_utf8(&contents)
.with_context(|| format!("File was not valid UTF-8: {}", path.display()))?
@ -59,10 +58,14 @@ mod test {
let mut vfs = Vfs::new(imfs.clone());
let instance_snapshot =
snapshot_txt(&InstanceContext::default(), &mut vfs, Path::new("/foo.txt"))
.unwrap()
.unwrap();
let instance_snapshot = snapshot_txt(
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.txt"),
"foo",
)
.unwrap()
.unwrap();
insta::assert_yaml_snapshot!(instance_snapshot);
}

View file

@ -59,6 +59,9 @@ gen_build_tests! {
txt_in_folder,
unresolved_values,
weldconstraint,
sync_rule_alone,
sync_rule_complex,
sync_rule_nested_projects,
}
fn run_build_test(test_name: &str) {

View file

@ -255,3 +255,54 @@ fn add_optional_folder() {
);
});
}
#[test]
fn sync_rule_alone() {
run_serve_test("sync_rule_alone", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!("sync_rule_alone_info", redactions.redacted_yaml(info));
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"sync_rule_alone_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
});
}
#[test]
fn sync_rule_complex() {
run_serve_test("sync_rule_complex", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!("sync_rule_complex_info", redactions.redacted_yaml(info));
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"sync_rule_complex_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
});
}
#[test]
fn sync_rule_no_extension() {
run_serve_test("sync_rule_no_extension", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!(
"sync_rule_no_extension_info",
redactions.redacted_yaml(info)
);
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"sync_rule_no_extension_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
});
}