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

This commit is contained in:
boatbomber 2023-07-04 13:45:36 -07:00
commit 1ff018a2ce
35 changed files with 571 additions and 212 deletions

View file

@ -116,7 +116,7 @@ jobs:
version: 'v0.2.6' version: 'v0.2.6'
- name: Build Release - name: Build Release
run: cargo build --release --locked --verbose run: cargo build --release --locked --verbose --target ${{ matrix.target }}
env: env:
# Build into a known directory so we can find our build artifact more # Build into a known directory so we can find our build artifact more
# easily. # easily.
@ -132,11 +132,11 @@ jobs:
mkdir staging mkdir staging
if [ "${{ matrix.host }}" = "windows" ]; then if [ "${{ matrix.host }}" = "windows" ]; then
cp "output/release/$BIN.exe" staging/ cp "output/${{ matrix.target }}/release/$BIN.exe" staging/
cd staging cd staging
7z a ../release.zip * 7z a ../release.zip *
else else
cp "output/release/$BIN" staging/ cp "output/${{ matrix.target }}/release/$BIN" staging/
cd staging cd staging
zip ../release.zip * zip ../release.zip *
fi fi

10
.gitmodules vendored
View file

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

View file

@ -3,9 +3,19 @@
## Unreleased Changes ## Unreleased Changes
* Added headless API for Studio companion plugins. ([#631]) * Added headless API for Studio companion plugins. ([#631])
* Significantly improved performance of `rojo sourcemap`. ([#668]) * Significantly improved performance of `rojo sourcemap`. ([#668])
* Fixed the diff visualizer of connected sessions. ([#674])
* Fixed disconnected session activity. ([#675])
* Skip confirming patches that contain only a datamodel name change. ([#688])
* Added sync reminder notification. ([#689])
* Added protection against syncing a model to a place. ([#691])
[#631]: https://github.com/rojo-rbx/rojo/pull/631 [#631]: https://github.com/rojo-rbx/rojo/pull/631
[#668]: https://github.com/rojo-rbx/rojo/pull/668 [#668]: https://github.com/rojo-rbx/rojo/pull/668
[#674]: https://github.com/rojo-rbx/rojo/pull/674
[#675]: https://github.com/rojo-rbx/rojo/pull/675
[#688]: https://github.com/rojo-rbx/rojo/pull/688
[#689]: https://github.com/rojo-rbx/rojo/pull/689
[#691]: https://github.com/rojo-rbx/rojo/pull/691
## [7.3.0] - April 22, 2023 ## [7.3.0] - April 22, 2023
* Added `$attributes` to project format. ([#574]) * Added `$attributes` to project format. ([#574])

View file

@ -54,7 +54,7 @@ fn main() -> Result<(), anyhow::Error> {
}); });
let out_path = Path::new(&out_dir).join("plugin.bincode"); let out_path = Path::new(&out_dir).join("plugin.bincode");
let out_file = File::create(&out_path)?; let out_file = File::create(out_path)?;
bincode::serialize_into(out_file, &snapshot)?; bincode::serialize_into(out_file, &snapshot)?;

View file

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

View file

@ -194,11 +194,8 @@ impl VfsInner {
} }
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> { fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
match event { if let VfsEvent::Remove(path) = event {
VfsEvent::Remove(path) => { let _ = self.backend.unwatch(path);
let _ = self.backend.unwatch(&path);
}
_ => {}
} }
Ok(()) Ok(())

View file

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

View file

@ -108,3 +108,9 @@ impl VfsBackend for StdBackend {
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner)) .map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
} }
} }
impl Default for StdBackend {
fn default() -> Self {
Self::new()
}
}

View file

@ -93,6 +93,7 @@ function ApiContext.new(baseUrl)
__sessionId = nil, __sessionId = nil,
__messageCursor = -1, __messageCursor = -1,
__connected = true, __connected = true,
__activeRequests = {},
} }
return setmetatable(self, ApiContext) return setmetatable(self, ApiContext)
@ -113,6 +114,11 @@ end
function ApiContext:disconnect() function ApiContext:disconnect()
self.__connected = false self.__connected = false
for request in self.__activeRequests do
Log.trace("Cancelling request {}", request)
request:cancel()
end
self.__activeRequests = {}
end end
function ApiContext:setMessageCursor(index) function ApiContext:setMessageCursor(index)
@ -204,7 +210,7 @@ function ApiContext:retrieveMessages()
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor) local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
local function sendRequest() local function sendRequest()
return Http.get(url) local request = Http.get(url)
:catch(function(err) :catch(function(err)
if err.type == Http.Error.Kind.Timeout then if err.type == Http.Error.Kind.Timeout then
if self.__connected then if self.__connected then
@ -216,6 +222,15 @@ function ApiContext:retrieveMessages()
return Promise.reject(err) return Promise.reject(err)
end) end)
Log.trace("Tracking request {}", request)
self.__activeRequests[request] = true
return request:finally(function(...)
Log.trace("Cleaning up request {}", request)
self.__activeRequests[request] = nil
return ...
end)
end end
return sendRequest() return sendRequest()

View file

@ -40,11 +40,6 @@ end
local DomLabel = Roact.Component:extend("DomLabel") local DomLabel = Roact.Component:extend("DomLabel")
function DomLabel:init() function DomLabel:init()
self.maxElementHeight = 0
if self.props.changeList then
self.maxElementHeight = math.clamp(#self.props.changeList * 30, 30, 30 * 6)
end
local initHeight = self.props.elementHeight:getValue() local initHeight = self.props.elementHeight:getValue()
self.expanded = initHeight > 30 self.expanded = initHeight > 30
@ -118,7 +113,8 @@ function DomLabel:render()
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
[Roact.Event.Activated] = function() [Roact.Event.Activated] = function()
self.expanded = not self.expanded self.expanded = not self.expanded
self.motor:setGoal(Flipper.Spring.new((self.expanded and self.maxElementHeight or 0) + 30, { local goalHeight = 30 + (if self.expanded then math.clamp(#self.props.changeList * 30, 30, 30 * 6) else 0)
self.motor:setGoal(Flipper.Spring.new(goalHeight, {
frequency = 5, frequency = 5,
dampingRatio = 1, dampingRatio = 1,
})) }))

View file

@ -99,18 +99,30 @@ function VirtualScroller:refresh()
}) })
end end
function VirtualScroller:didUpdate(previousProps)
if self.props.count ~= previousProps.count then
-- Items have changed, so we need to refresh
self:refresh()
end
end
function VirtualScroller:render() function VirtualScroller:render()
local props, state = self.props, self.state local props, state = self.props, self.state
local items = {} local items = {}
for i = state.Start, state.End do for i = state.Start, state.End do
local content = props.render(i)
if content == nil then
continue
end
items["Item" .. i] = e("Frame", { items["Item" .. i] = e("Frame", {
LayoutOrder = i, LayoutOrder = i,
Size = props.getHeightBinding(i):map(function(height) Size = props.getHeightBinding(i):map(function(height)
return UDim2.new(1, 0, 0, height) return UDim2.new(1, 0, 0, height)
end), end),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, props.render(i)) }, content)
end end
return Theme.with(function(theme) return Theme.with(function(theme)

View file

@ -7,6 +7,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper) local Flipper = require(Packages.Flipper)
local Log = require(Packages.Log)
local bindingUtil = require(script.Parent.bindingUtil) local bindingUtil = require(script.Parent.bindingUtil)
@ -14,6 +15,7 @@ local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton)
local baseClock = DateTime.now().UnixTimestampMillis local baseClock = DateTime.now().UnixTimestampMillis
@ -28,11 +30,9 @@ function Notification:init()
self.lifetime = self.props.timeout self.lifetime = self.props.timeout
self.motor:onStep(function(value) self.motor:onStep(function(value)
if value <= 0 then if value <= 0 and self.props.onClose then
if self.props.onClose then
self.props.onClose() self.props.onClose()
end end
end
end) end)
end end
@ -86,10 +86,9 @@ function Notification:willUnmount()
end end
function Notification:render() function Notification:render()
local time = DateTime.fromUnixTimestampMillis(self.props.timestamp) local transparency = self.binding:map(function(value)
local infoText = return 1 - value
time:FormatLocalTime("LTS", "en-us") .. end)
(if self.props.source then " • From: " .. self.props.source else "")
local textBounds = TextService:GetTextSize( local textBounds = TextService:GetTextSize(
self.props.text, self.props.text,
@ -98,23 +97,43 @@ function Notification:render()
Vector2.new(350, 700) Vector2.new(350, 700)
) )
local infoTextBounds = TextService:GetTextSize( local actionButtons = {}
infoText, local buttonsX = 0
12, if self.props.actions then
Enum.Font.Gotham, local count = 0
Vector2.new(350, 14) for key, action in self.props.actions do
) actionButtons[key] = e(TextButton, {
text = action.text,
style = action.style,
onClick = function()
local success, err = pcall(action.onClick, self)
if not success then
Log.warn("Error in notification action: " .. tostring(err))
end
end,
layoutOrder = -action.layoutOrder,
transparency = transparency,
})
local textWidth = math.max(infoTextBounds.X, textBounds.X) buttonsX += TextService:GetTextSize(
action.text, 18, Enum.Font.GothamMedium,
Vector2.new(math.huge, math.huge)
).X + 30
local transparency = self.binding:map(function(value) count += 1
return 1 - value end
end)
buttonsX += (count - 1) * 5
end
local paddingY, logoSize = 20, 32
local actionsY = if self.props.actions then 35 else 0
local contentX = math.max(textBounds.X, buttonsX)
local size = self.binding:map(function(value) local size = self.binding:map(function(value)
return UDim2.fromOffset( return UDim2.fromOffset(
(35+40+textWidth)*value, (35 + 40 + contentX) * value,
math.max(16+20+textBounds.Y, 54) 5 + actionsY + paddingY + math.max(logoSize, textBounds.Y)
) )
end) end)
@ -134,18 +153,18 @@ function Notification:render()
transparency = transparency, transparency = transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
}, { }, {
TextContainer = e("Frame", { Contents = e("Frame", {
Size = UDim2.new(1, 0, 1, -20), Size = UDim2.new(0, 35 + contentX, 1, -paddingY),
Position = UDim2.new(0, 0, 0, 10), Position = UDim2.new(0, 0, 0, paddingY / 2),
BackgroundTransparency = 1 BackgroundTransparency = 1
}, { }, {
Logo = e("ImageLabel", { Logo = e("ImageLabel", {
ImageTransparency = transparency, ImageTransparency = transparency,
Image = if self.props.thirdParty then Assets.Images.ThirdPartyPlugin else Assets.Images.PluginButton, Image = if self.props.thirdParty then Assets.Images.ThirdPartyPlugin else Assets.Images.PluginButton,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(0, 32, 0, 32), Size = UDim2.new(0, logoSize, 0, logoSize),
Position = UDim2.new(0, 0, 0.5, 0), Position = UDim2.new(0, 0, 0, 0),
AnchorPoint = Vector2.new(0, 0.5), AnchorPoint = Vector2.new(0, 0),
}), }),
Message = e("TextLabel", { Message = e("TextLabel", {
Text = self.props.text, Text = self.props.text,
@ -162,20 +181,21 @@ function Notification:render()
LayoutOrder = 1, LayoutOrder = 1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
Info = e("TextLabel", { Actions = if self.props.actions then e("Frame", {
Text = infoText, Size = UDim2.new(1, -40, 0, 35),
Font = Enum.Font.Gotham, Position = UDim2.new(1, 0, 1, 0),
TextSize = 12, AnchorPoint = Vector2.new(1, 1),
TextColor3 = theme.Notification.InfoColor,
TextTransparency = transparency,
TextXAlignment = Enum.TextXAlignment.Left,
Size = UDim2.new(1, -38, 0, 14),
Position = UDim2.new(0, 38, 1, -14),
LayoutOrder = 1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 5),
}), }),
Buttons = Roact.createFragment(actionButtons),
}) else nil,
}), }),
Padding = e("UIPadding", { Padding = e("UIPadding", {
@ -192,19 +212,20 @@ local Notifications = Roact.Component:extend("Notifications")
function Notifications:render() function Notifications:render()
local notifs = {} local notifs = {}
for index, notif in ipairs(self.props.notifications) do for id, notif in self.props.notifications do
local props = { notifs["NotifID_" .. id] = e(Notification, {
soundPlayer = self.props.soundPlayer, soundPlayer = self.props.soundPlayer,
text = notif.text,
timestamp = notif.timestamp,
timeout = notif.timeout,
actions = notif.actions,
source = notif.source,
thirdParty = notif.thirdParty,
layoutOrder = (notif.timestamp - baseClock), layoutOrder = (notif.timestamp - baseClock),
onClose = function() onClose = function()
self.props.onClose(index) self.props.onClose(id)
end, end,
} })
for key, value in notif do
props[key] = value
end
notifs[notif] = e(Notification, props)
end end
return Roact.createFragment(notifs) return Roact.createFragment(notifs)

View file

@ -26,11 +26,11 @@ function timeSinceText(elapsed: number): string
local ageText = string.format("%d seconds ago", elapsed) local ageText = string.format("%d seconds ago", elapsed)
for _,UnitData in ipairs(AGE_UNITS) do for _, UnitData in ipairs(AGE_UNITS) do
local UnitSeconds, UnitName = UnitData[1], UnitData[2] local UnitSeconds, UnitName = UnitData[1], UnitData[2]
if elapsed > UnitSeconds then if elapsed > UnitSeconds then
local c = math.floor(elapsed/UnitSeconds) local c = math.floor(elapsed / UnitSeconds)
ageText = string.format("%d %s%s ago", c, UnitName, c>1 and "s" or "") ageText = string.format("%d %s%s ago", c, UnitName, c > 1 and "s" or "")
break break
end end
end end
@ -38,45 +38,53 @@ function timeSinceText(elapsed: number): string
return ageText return ageText
end end
local function ChangesDrawer(props) local ChangesDrawer = Roact.Component:extend("ConnectedPage")
if props.rendered == false then
function ChangesDrawer:init()
-- Hold onto the serve session during the lifecycle of this component
-- so that it can still render during the fade out after disconnecting
self.serveSession = self.props.serveSession
end
function ChangesDrawer:render()
if self.props.rendered == false or self.serveSession == nil then
return nil return nil
end end
return Theme.with(function(theme) return Theme.with(function(theme)
return e(BorderedContainer, { return e(BorderedContainer, {
transparency = props.transparency, transparency = self.props.transparency,
size = props.height:map(function(y) size = self.props.height:map(function(y)
return UDim2.new(1, 0, y, -180 * y) return UDim2.new(1, 0, y, -180 * y)
end), end),
position = UDim2.new(0, 0, 1, 0), position = UDim2.new(0, 0, 1, 0),
anchorPoint = Vector2.new(0, 1), anchorPoint = Vector2.new(0, 1),
layoutOrder = props.layoutOrder, layoutOrder = self.props.layoutOrder,
}, { }, {
Close = e(IconButton, { Close = e(IconButton, {
icon = Assets.Images.Icons.Close, icon = Assets.Images.Icons.Close,
iconSize = 24, iconSize = 24,
color = theme.ConnectionDetails.DisconnectColor, color = theme.ConnectionDetails.DisconnectColor,
transparency = props.transparency, transparency = self.props.transparency,
position = UDim2.new(1, 0, 0, 0), position = UDim2.new(1, 0, 0, 0),
anchorPoint = Vector2.new(1, 0), anchorPoint = Vector2.new(1, 0),
onClick = props.onClose, onClick = self.props.onClose,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "Close the patch visualizer" text = "Close the patch visualizer",
}), }),
}), }),
PatchVisualizer = e(PatchVisualizer, { PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
transparency = props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 3,
columnVisibility = {true, false, true}, columnVisibility = { true, false, true },
patch = props.patchInfo:getValue().patch, patch = self.props.patch,
instanceMap = props.serveSession.__instanceMap, instanceMap = self.serveSession.__instanceMap,
}), }),
}) })
end) end)
@ -91,7 +99,7 @@ local function ConnectionDetails(props)
}, { }, {
TextContainer = e("Frame", { TextContainer = e("Frame", {
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1 BackgroundTransparency = 1,
}, { }, {
ProjectName = e("TextLabel", { ProjectName = e("TextLabel", {
Text = props.projectName, Text = props.projectName,
@ -141,7 +149,7 @@ local function ConnectionDetails(props)
onClick = props.onDisconnect, onClick = props.onDisconnect,
}, { }, {
Tip = e(Tooltip.Trigger, { Tip = e(Tooltip.Trigger, {
text = "Disconnect from the Rojo sync server" text = "Disconnect from the Rojo sync server",
}), }),
}), }),
@ -155,6 +163,18 @@ end
local ConnectedPage = Roact.Component:extend("ConnectedPage") local ConnectedPage = Roact.Component:extend("ConnectedPage")
function ConnectedPage:getChangeInfoText()
local patchData = self.props.patchData
if patchData == nil then
return ""
end
local elapsed = os.time() - patchData.timestamp
local changes = PatchSet.countChanges(patchData.patch)
return string.format("<i>Synced %d change%s %s</i>", changes, changes == 1 and "" or "s", timeSinceText(elapsed))
end
function ConnectedPage:init() function ConnectedPage:init()
self.changeDrawerMotor = Flipper.SingleMotor.new(0) self.changeDrawerMotor = Flipper.SingleMotor.new(0)
self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor) self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor)
@ -176,6 +196,34 @@ function ConnectedPage:init()
self:setState({ self:setState({
renderChanges = false, renderChanges = false,
}) })
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
self.changeInfoTextUpdater = task.defer(function()
while true do
self.setChangeInfoText(self:getChangeInfoText())
local elapsed = os.time() - self.props.patchData.timestamp
local updateInterval = 1
-- Update timestamp text as frequently as currently needed
for _, UnitData in ipairs(AGE_UNITS) do
local UnitSeconds = UnitData[1]
if elapsed >= UnitSeconds then
updateInterval = UnitSeconds
break
end
end
task.wait(updateInterval)
end
end)
end
function ConnectedPage:willUnmount()
if self.changeInfoTextUpdater then
task.cancel(self.changeInfoTextUpdater)
end
end end
function ConnectedPage:render() function ConnectedPage:render()
@ -208,15 +256,7 @@ function ConnectedPage:render()
}), }),
ChangeInfo = e("TextButton", { ChangeInfo = e("TextButton", {
Text = self.props.patchInfo:map(function(info) Text = self.changeInfoText,
local changes = PatchSet.countChanges(info.patch)
return string.format(
"<i>Synced %d change%s %s</i>",
changes,
changes == 1 and "" or "s",
timeSinceText(os.time() - info.timestamp)
)
end),
Font = Enum.Font.Gotham, Font = Enum.Font.Gotham,
TextSize = 14, TextSize = 14,
TextWrapped = true, TextWrapped = true,
@ -249,7 +289,7 @@ function ConnectedPage:render()
ChangesDrawer = e(ChangesDrawer, { ChangesDrawer = e(ChangesDrawer, {
rendered = self.state.renderChanges, rendered = self.state.renderChanges,
transparency = self.props.transparency, transparency = self.props.transparency,
patchInfo = self.props.patchInfo, patch = self.props.patchData.patch,
serveSession = self.props.serveSession, serveSession = self.props.serveSession,
height = self.changeDrawerHeight, height = self.changeDrawerHeight,
layoutOrder = 4, layoutOrder = 4,

View file

@ -62,6 +62,7 @@ function Setting:render()
LayoutOrder = self.props.layoutOrder, LayoutOrder = self.props.layoutOrder,
ZIndex = -self.props.layoutOrder, ZIndex = -self.props.layoutOrder,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Visible = self.props.visible,
[Roact.Change.AbsoluteSize] = function(object) [Roact.Change.AbsoluteSize] = function(object)
self.setContainerSize(object.AbsoluteSize) self.setContainerSize(object.AbsoluteSize)

View file

@ -88,19 +88,36 @@ function SettingsPage:render()
layoutOrder = 0, layoutOrder = 0,
}), }),
OpenScriptsExternally = e(Setting, { ShowNotifications = e(Setting, {
id = "openScriptsExternally", id = "showNotifications",
name = "Open Scripts Externally", name = "Show Notifications",
description = "Attempt to open scripts in an external editor", description = "Popup notifications in viewport",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 1, layoutOrder = 1,
}), }),
SyncReminder = e(Setting, {
id = "syncReminder",
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 = 2,
}),
PlaySounds = e(Setting, {
id = "playSounds",
name = "Play Sounds",
description = "Toggle sound effects",
transparency = self.props.transparency,
layoutOrder = 3,
}),
Permissions = e(Setting, { Permissions = e(Setting, {
name = "Third Party Permissions", name = "Third Party Permissions",
description = "Manage permissions for third party plugins", description = "Manage permissions for third party plugins",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 2, layoutOrder = 4,
customInput = e("TextButton", { customInput = e("TextButton", {
Text = "", Text = "",
BackgroundTransparency = 1, BackgroundTransparency = 1,
@ -136,20 +153,12 @@ function SettingsPage:render()
}), }),
}), }),
ShowNotifications = e(Setting, { OpenScriptsExternally = e(Setting, {
id = "showNotifications", id = "openScriptsExternally",
name = "Show Notifications", name = "Open Scripts Externally",
description = "Popup notifications in viewport", description = "EXPERIMENTAL! Attempt to open scripts in an external editor",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 5,
}),
PlaySounds = e(Setting, {
id = "playSounds",
name = "Play Sounds",
description = "Toggle sound effects",
transparency = self.props.transparency,
layoutOrder = 4,
}), }),
TwoWaySync = e(Setting, { TwoWaySync = e(Setting, {
@ -157,7 +166,7 @@ function SettingsPage:render()
name = "Two-Way Sync", name = "Two-Way Sync",
description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem", description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 5, layoutOrder = 6,
}), }),
LogLevel = e(Setting, { LogLevel = e(Setting, {
@ -165,7 +174,7 @@ function SettingsPage:render()
name = "Log Level", name = "Log Level",
description = "Plugin output verbosity level", description = "Plugin output verbosity level",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 6, layoutOrder = 100,
options = invertedLevels, options = invertedLevels,
showReset = Settings:getBinding("logLevel"):map(function(value) showReset = Settings:getBinding("logLevel"):map(function(value)
@ -181,7 +190,7 @@ function SettingsPage:render()
name = "Typechecking", name = "Typechecking",
description = "Toggle typechecking on the API surface", description = "Toggle typechecking on the API surface",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 7, layoutOrder = 101,
}), }),
Layout = e("UIListLayout", { Layout = e("UIListLayout", {

View file

@ -1,5 +1,6 @@
local Players = game:GetService("Players") local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage") local ServerStorage = game:GetService("ServerStorage")
local RunService = game:GetService("RunService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
@ -20,6 +21,7 @@ local HeadlessAPI = require(Plugin.HeadlessAPI)
local PatchSet = require(Plugin.PatchSet) local PatchSet = require(Plugin.PatchSet)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer) local soundPlayer = require(Plugin.soundPlayer)
local ignorePlaceIds = require(Plugin.ignorePlaceIds)
local Theme = require(script.Theme) local Theme = require(script.Theme)
local Page = require(script.Page) local Page = require(script.Page)
@ -55,17 +57,18 @@ function App:init()
self.host, self.setHost = Roact.createBinding(priorHost or "") self.host, self.setHost = Roact.createBinding(priorHost or "")
self.port, self.setPort = Roact.createBinding(priorPort or "") self.port, self.setPort = Roact.createBinding(priorPort or "")
self.patchInfo, self.setPatchInfo = Roact.createBinding({
patch = PatchSet.newEmpty(),
timestamp = os.time(),
})
self.confirmationBindable = Instance.new("BindableEvent") self.confirmationBindable = Instance.new("BindableEvent")
self.confirmationEvent = self.confirmationBindable.Event self.confirmationEvent = self.confirmationBindable.Event
self.notifId = 0
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
guiEnabled = false, guiEnabled = false,
confirmData = {}, confirmData = {},
patchData = {
patch = PatchSet.newEmpty(),
timestamp = os.time(),
},
notifications = {}, notifications = {},
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
popups = {}, popups = {},
@ -114,47 +117,90 @@ function App:init()
-- selene: allow(global_usage) -- selene: allow(global_usage)
_G.Rojo = self.readOnlyHeadlessAPI -- Expose headless to other plugins and command bar _G.Rojo = self.readOnlyHeadlessAPI -- Expose headless to other plugins and command bar
end end
if
RunService:IsEdit()
and self.serveSession == nil
and Settings:get("syncReminder")
and self:getLastSyncTimestamp()
then
self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, {
Connect = {
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
notification:dismiss()
self:startSession()
end
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
},
})
end
end end
function App:addNotification(text: string, timeout: number?) function App:addNotification(text: string, timeout: number?, actions: { [string]: {text: string, style: string, layoutOrder: number, onClick: (any) -> ()} }?)
if not Settings:get("showNotifications") then if not Settings:get("showNotifications") then
return return
end end
self.notifId += 1
local id = self.notifId
local notifications = table.clone(self.state.notifications) local notifications = table.clone(self.state.notifications)
table.insert(notifications, { notifications[id] = {
text = text, text = text,
timestamp = DateTime.now().UnixTimestampMillis, timestamp = DateTime.now().UnixTimestampMillis,
timeout = timeout or 3, timeout = timeout or 3,
}) actions = actions,
}
self:setState({ self:setState({
notifications = notifications, notifications = notifications,
}) })
return function()
self:closeNotification(id)
end
end end
function App:addThirdPartyNotification(source: string, text: string, timeout: number?) function App:addThirdPartyNotification(source: string, text: string, timeout: number?, actions: { [string]: {text: string, style: string, layoutOrder: number, onClick: (any) -> ()} }?)
if not Settings:get("showNotifications") then if not Settings:get("showNotifications") then
return return
end end
self.notifId += 1
local id = self.notifId
local notifications = table.clone(self.state.notifications) local notifications = table.clone(self.state.notifications)
table.insert(notifications, { notifications[id] = {
text = text, text = text,
timestamp = DateTime.now().UnixTimestampMillis, timestamp = DateTime.now().UnixTimestampMillis,
timeout = timeout or 3, timeout = timeout or 3,
actions = actions,
thirdParty = true, thirdParty = true,
source = source, source = source,
}) }
self:setState({ self:setState({
notifications = notifications, notifications = notifications,
}) })
return function()
self:closeNotification(id)
end
end end
function App:closeNotification(index: number) function App:closeNotification(id: number)
local notifications = table.clone(self.state.notifications) local notifications = table.clone(self.state.notifications)
table.remove(notifications, index) notifications[id] = nil
self:setState({ self:setState({
notifications = notifications, notifications = notifications,
@ -165,12 +211,28 @@ function App:getPriorEndpoint()
local priorEndpoints = Settings:get("priorEndpoints") local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end if not priorEndpoints then return end
local place = priorEndpoints[tostring(game.PlaceId)] local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then return end
local place = priorEndpoints[id]
if not place then return end if not place then return end
return place.host, place.port return place.host, place.port
end end
function App:getLastSyncTimestamp()
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then return end
local place = priorEndpoints[id]
if not place then return end
return place.timestamp
end
function App:setPriorEndpoint(host: string, port: string) function App:setPriorEndpoint(host: string, port: string)
local priorEndpoints = Settings:get("priorEndpoints") local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then if not priorEndpoints then
@ -185,17 +247,16 @@ function App:setPriorEndpoint(host: string, port: string)
end end
end end
if host == Config.defaultHost and port == Config.defaultPort then local id = tostring(game.PlaceId)
-- Don't save default if ignorePlaceIds[id] then return end
priorEndpoints[tostring(game.PlaceId)] = nil
else priorEndpoints[id] = {
priorEndpoints[tostring(game.PlaceId)] = { host = if host ~= Config.defaultHost then host else nil,
host = host ~= Config.defaultHost and host or nil, port = if port ~= Config.defaultPort then port else nil,
port = port ~= Config.defaultPort and port or nil,
timestamp = os.time(), timestamp = os.time(),
} }
Log.trace("Saved last used endpoint for {}", game.PlaceId) Log.trace("Saved last used endpoint for {}", game.PlaceId)
end
Settings:set("priorEndpoints", priorEndpoints) Settings:set("priorEndpoints", priorEndpoints)
end end
@ -330,21 +391,25 @@ function App:startSession(host: string?, port: string?)
local now = os.time() local now = os.time()
local old = self.patchInfo:getValue() local old = self.state.patchData
if now - old.timestamp < 2 then if now - old.timestamp < 2 then
-- Patches that apply in the same second are -- Patches that apply in the same second are
-- considered to be part of the same change for human clarity -- considered to be part of the same change for human clarity
local merged = PatchSet.newEmpty() local merged = PatchSet.newEmpty()
PatchSet.assign(merged, old.patch, patch) PatchSet.assign(merged, old.patch, patch)
self.setPatchInfo({ self:setState({
patchData = {
patch = merged, patch = merged,
timestamp = now, timestamp = now,
},
}) })
else else
self.setPatchInfo({ self:setState({
patchData = {
patch = patch, patch = patch,
timestamp = now, timestamp = now,
},
}) })
end end
end) end)
@ -404,9 +469,28 @@ function App:startSession(host: string?, port: string?)
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
Log.trace("Accepting patch without confirmation because it is empty")
return "Accept" return "Accept"
end end
-- The datamodel name gets overwritten by Studio, making confirmation of it intrusive
-- and unnecessary. This special case allows it to be accepted without confirmation.
if
PatchSet.hasAdditions(patch) == false
and PatchSet.hasRemoves(patch) == false
and PatchSet.containsOnlyInstance(patch, instanceMap, game)
then
local datamodelUpdates = PatchSet.getUpdateForInstance(patch, instanceMap, game)
if
datamodelUpdates ~= nil
and next(datamodelUpdates.changedProperties) == nil
and datamodelUpdates.changedClassName == nil
then
Log.trace("Accepting patch without confirmation because it only contains a datamodel name change")
return "Accept"
end
end
self:setState({ self:setState({
appStatus = AppStatus.Confirming, appStatus = AppStatus.Confirming,
confirmData = { confirmData = {
@ -431,16 +515,6 @@ function App:startSession(host: string?, port: string?)
serveSession:start() serveSession:start()
self.serveSession = serveSession self.serveSession = serveSession
task.defer(function()
while self.serveSession == serveSession do
-- Trigger rerender to update timestamp text
local patchInfo = table.clone(self.patchInfo:getValue())
self.setPatchInfo(patchInfo)
local elapsed = os.time() - patchInfo.timestamp
task.wait(elapsed < 60 and 1 or elapsed / 5)
end
end)
end end
function App:endSession() function App:endSession()
@ -574,7 +648,7 @@ function App:render()
Connected = createPageElement(AppStatus.Connected, { Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName, projectName = self.state.projectName,
address = self.state.address, address = self.state.address,
patchInfo = self.patchInfo, patchData = self.state.patchData,
serveSession = self.serveSession, serveSession = self.serveSession,
onDisconnect = function() onDisconnect = function()
@ -631,7 +705,11 @@ function App:render()
}), }),
}), }),
RojoNotifications = e("ScreenGui", {}, { RojoNotifications = e("ScreenGui", {
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
ResetOnSpawn = false,
DisplayOrder = 100,
}, {
layout = e("UIListLayout", { layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder, SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right, HorizontalAlignment = Enum.HorizontalAlignment.Right,
@ -647,8 +725,8 @@ function App:render()
notifs = e(Notifications, { notifs = e(Notifications, {
soundPlayer = self.props.soundPlayer, soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications, notifications = self.state.notifications,
onClose = function(index) onClose = function(id)
self:closeNotification(index) self:closeNotification(id)
end, end,
}), }),
}), }),

View file

@ -91,6 +91,113 @@ function PatchSet.hasUpdates(patchSet)
return next(patchSet.updated) ~= nil return next(patchSet.updated) ~= nil
end end
--[[
Tells whether the given PatchSet contains changes to the given instance id
]]
function PatchSet.containsId(patchSet, instanceMap, id)
if patchSet.added[id] ~= nil then
return true
end
for _, idOrInstance in patchSet.removed do
local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance]
if removedId == id then
return true
end
end
for _, update in patchSet.updated do
if update.id == id then
return true
end
end
return false
end
--[[
Tells whether the given PatchSet contains changes to the given instance.
If the given InstanceMap does not contain the instance, this function always returns false.
]]
function PatchSet.containsInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return false
end
return PatchSet.containsId(patchSet, instanceMap, id)
end
--[[
Tells whether the given PatchSet contains changes to nothing but the given instance id
]]
function PatchSet.containsOnlyId(patchSet, instanceMap, id)
if not PatchSet.containsId(patchSet, instanceMap, id) then
-- Patch doesn't contain the id at all
return false
end
for addedId in patchSet.added do
if addedId ~= id then
return false
end
end
for _, idOrInstance in patchSet.removed do
local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance]
if removedId ~= id then
return false
end
end
for _, update in patchSet.updated do
if update.id ~= id then
return false
end
end
return true
end
--[[
Tells whether the given PatchSet contains changes to nothing but the given instance.
If the given InstanceMap does not contain the instance, this function always returns false.
]]
function PatchSet.containsOnlyInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return false
end
return PatchSet.containsOnlyId(patchSet, instanceMap, id)
end
--[[
Returns the update to the given instance id, or nil if there aren't any
]]
function PatchSet.getUpdateForId(patchSet, id)
for _, update in patchSet.updated do
if update.id == id then
return update
end
end
return nil
end
--[[
Returns the update to the given instance, or nil if there aren't any.
If the given InstanceMap does not contain the instance, this function always returns nil.
]]
function PatchSet.getUpdateForInstance(patchSet, instanceMap, instance)
local id = instanceMap.fromInstances[instance]
if id == nil then
return nil
end
return PatchSet.getUpdateForId(patchSet, id)
end
--[[ --[[
Tells whether the given PatchSets are equal. Tells whether the given PatchSets are equal.
]] ]]
@ -150,7 +257,7 @@ function PatchSet.humanSummary(instanceMap, patchSet)
for _, idOrInstance in ipairs(patchSet.removed) do for _, idOrInstance in ipairs(patchSet.removed) do
local instance, id local instance, id
if type(idOrInstance) == "string" then if Types.RbxId(idOrInstance) then
id = idOrInstance id = idOrInstance
instance = instanceMap.fromIds[id] instance = instanceMap.fromIds[id]
else else

View file

@ -232,6 +232,22 @@ function ServeSession:__initialSync(serverInfo)
Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch) Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch)
end end
for _, update in catchUpPatch.updated do
if
update.id == self.__instanceMap.fromInstances[game]
and update.changedClassName ~= nil
then
-- Non-place projects will try to update the classname of game from DataModel to
-- something like Folder, ModuleScript, etc. This would fail, so we exit with a clear
-- message instead of crashing.
return Promise.reject(
"Cannot sync a model as a place."
.. "\nEnsure Rojo is serving a project file that has a DataModel at the root of its tree and try again."
.. "\nSee project file docs: https://rojo.space/docs/v7/project-format/"
)
end
end
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch)) Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
local userDecision = "Accept" local userDecision = "Accept"
@ -285,6 +301,8 @@ end
function ServeSession:__mainSyncLoop() function ServeSession:__mainSyncLoop()
return self.__apiContext:retrieveMessages() return self.__apiContext:retrieveMessages()
:andThen(function(messages) :andThen(function(messages)
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
for _, message in ipairs(messages) do for _, message in ipairs(messages) do
local unappliedPatch = self.__reconciler:applyPatch(message) local unappliedPatch = self.__reconciler:applyPatch(message)

View file

@ -13,6 +13,7 @@ local defaultSettings = {
openScriptsExternally = false, openScriptsExternally = false,
twoWaySync = false, twoWaySync = false,
showNotifications = true, showNotifications = true,
syncReminder = true,
playSounds = true, playSounds = true,
typecheckingEnabled = false, typecheckingEnabled = false,
logLevel = "Info", logLevel = "Info",

View file

@ -0,0 +1,27 @@
--[[
These are place ids that will not have metadata saved for them,
such as last sync address or time. This is because they are not unique
so storing metadata for them does not make sense as these ids are reused.
--]]
return {
["0"] = true, -- Local file
["95206881"] = true, -- Baseplate
["6560363541"] = true, -- Classic Baseplate
["95206192"] = true, -- Flat Terrain
["13165709401"] = true, -- Modern City
["520390648"] = true, -- Village
["203810088"] = true, -- Castle
["366130569"] = true, -- Suburban
["215383192"] = true, -- Racing
["264719325"] = true, -- Pirate Island
["203812057"] = true, -- Obby
["379736082"] = true, -- Starting Place
["301530843"] = true, -- Line Runner
["92721754"] = true, -- Capture The Flag
["301529772"] = true, -- Team/FFA Arena
["203885589"] = true, -- Combat
["10275826693"] = true, -- Concert
["5353920686"] = true, -- Move It Simulator
["6936227200"] = true, -- Mansion Of Wonder
}

View file

@ -251,7 +251,7 @@ fn on_vfs_event(
let mut current_path = path.as_path(); let mut current_path = path.as_path();
let affected_ids = loop { let affected_ids = loop {
let ids = tree.get_ids_at_path(&current_path); let ids = tree.get_ids_at_path(current_path);
log::trace!("Path {} affects IDs {:?}", current_path.display(), ids); log::trace!("Path {} affects IDs {:?}", current_path.display(), ids);
@ -267,7 +267,7 @@ fn on_vfs_event(
}; };
for id in affected_ids { for id in affected_ids {
if let Some(patch) = compute_and_apply_changes(&mut tree, &vfs, id) { if let Some(patch) = compute_and_apply_changes(&mut tree, vfs, id) {
if !patch.is_empty() { if !patch.is_empty() {
applied_patches.push(patch); applied_patches.push(patch);
} }
@ -301,7 +301,7 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<
// path still exists. We can generate a snapshot starting at // path still exists. We can generate a snapshot starting at
// that path and use it as the source for our patch. // that path and use it as the source for our patch.
let snapshot = match snapshot_from_vfs(&metadata.context, &vfs, &path) { let snapshot = match snapshot_from_vfs(&metadata.context, vfs, path) {
Ok(snapshot) => snapshot, Ok(snapshot) => snapshot,
Err(err) => { Err(err) => {
log::error!("Snapshot error: {:?}", err); log::error!("Snapshot error: {:?}", err);
@ -309,7 +309,7 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<
} }
}; };
let patch_set = compute_patch_set(snapshot, &tree, id); let patch_set = compute_patch_set(snapshot, tree, id);
apply_patch_set(tree, patch_set) apply_patch_set(tree, patch_set)
} }
Ok(None) => { Ok(None) => {
@ -337,10 +337,10 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<
let snapshot_result = snapshot_project_node( let snapshot_result = snapshot_project_node(
&metadata.context, &metadata.context,
&project_path, project_path,
instance_name, instance_name,
project_node, project_node,
&vfs, vfs,
parent_class.as_ref().map(|name| name.as_str()), parent_class.as_ref().map(|name| name.as_str()),
); );
@ -352,7 +352,7 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<
} }
}; };
let patch_set = compute_patch_set(snapshot, &tree, id); let patch_set = compute_patch_set(snapshot, tree, id);
apply_patch_set(tree, patch_set) apply_patch_set(tree, patch_set)
} }
}; };

View file

@ -24,7 +24,7 @@ impl FmtProjectCommand {
let serialized = serde_json::to_string_pretty(&project) let serialized = serde_json::to_string_pretty(&project)
.context("could not re-encode project file as JSON")?; .context("could not re-encode project file as JSON")?;
fs_err::write(&project.file_location, &serialized) fs_err::write(&project.file_location, serialized)
.context("could not write back to project file")?; .context("could not write back to project file")?;
Ok(()) Ok(())

View file

@ -184,7 +184,7 @@ fn try_git_init(path: &Path, git_ignore: &str) -> Result<(), anyhow::Error> {
/// already inside a Git repository. /// already inside a Git repository.
fn should_git_init(path: &Path) -> bool { fn should_git_init(path: &Path) -> bool {
let result = Command::new("git") let result = Command::new("git")
.args(&["rev-parse", "--is-inside-work-tree"]) .args(["rev-parse", "--is-inside-work-tree"])
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.current_dir(path) .current_dir(path)

View file

@ -39,7 +39,7 @@ impl ServeCommand {
let vfs = Vfs::new_default(); let vfs = Vfs::new_default();
let session = Arc::new(ServeSession::new(vfs, &project_path)?); let session = Arc::new(ServeSession::new(vfs, project_path)?);
let ip = self let ip = self
.address .address

View file

@ -176,7 +176,7 @@ fn do_upload_open_cloud(
log::debug!("Uploading to Roblox..."); log::debug!("Uploading to Roblox...");
let response = client let response = client
.post(&url) .post(url)
.header("x-api-key", api_key) .header("x-api-key", api_key)
.header(CONTENT_TYPE, "application/xml") .header(CONTENT_TYPE, "application/xml")
.header(ACCEPT, "application/json") .header(ACCEPT, "application/json")

View file

@ -219,12 +219,30 @@ fn is_valid_ident_char(value: char) -> bool {
} }
fn is_keyword(value: &str) -> bool { fn is_keyword(value: &str) -> bool {
match value { matches!(
"and" | "break" | "do" | "else" | "elseif" | "end" | "false" | "for" | "function" value,
| "if" | "in" | "local" | "nil" | "not" | "or" | "repeat" | "return" | "then" | "true" "and"
| "until" | "while" => true, | "break"
_ => false, | "do"
} | "else"
| "elseif"
| "end"
| "false"
| "for"
| "function"
| "if"
| "in"
| "local"
| "nil"
| "not"
| "or"
| "repeat"
| "return"
| "then"
| "true"
| "until"
| "while"
)
} }
/// Tells whether the given string is a valid Lua identifier. /// Tells whether the given string is a valid Lua identifier.

View file

@ -15,7 +15,7 @@ where
.as_os_str() .as_os_str()
.to_str() .to_str()
.expect("Invalid Unicode in file path, cannot serialize"); .expect("Invalid Unicode in file path, cannot serialize");
let replaced = as_str.replace("\\", "/"); let replaced = as_str.replace('\\', "/");
serializer.serialize_str(&replaced) serializer.serialize_str(&replaced)
} }

View file

@ -127,8 +127,7 @@ impl Project {
contents: &[u8], contents: &[u8],
project_file_location: &Path, project_file_location: &Path,
) -> Result<Self, ProjectError> { ) -> Result<Self, ProjectError> {
let mut project: Self = let mut project: Self = serde_json::from_slice(contents).map_err(|source| Error::Json {
serde_json::from_slice(&contents).map_err(|source| Error::Json {
source, source,
path: project_file_location.to_owned(), path: project_file_location.to_owned(),
})?; })?;
@ -197,8 +196,8 @@ pub enum PathNode {
impl PathNode { impl PathNode {
pub fn path(&self) -> &Path { pub fn path(&self) -> &Path {
match self { match self {
PathNode::Required(pathbuf) => &pathbuf, PathNode::Required(pathbuf) => pathbuf,
PathNode::Optional(OptionalPathNode { optional }) => &optional, PathNode::Optional(OptionalPathNode { optional }) => optional,
} }
} }
} }

View file

@ -102,12 +102,11 @@ impl ServeSession {
log::trace!("Starting new ServeSession at path {}", start_path.display()); log::trace!("Starting new ServeSession at path {}", start_path.display());
let project_path; let project_path = if Project::is_project_file(start_path) {
if Project::is_project_file(start_path) { Cow::Borrowed(start_path)
project_path = Cow::Borrowed(start_path);
} else { } else {
project_path = Cow::Owned(start_path.join("default.project.json")); Cow::Owned(start_path.join("default.project.json"))
} };
log::debug!("Loading project file from {}", project_path.display()); log::debug!("Loading project file from {}", project_path.display());
@ -127,7 +126,7 @@ impl ServeSession {
let instance_context = InstanceContext::default(); let instance_context = InstanceContext::default();
log::trace!("Generating snapshot of instances from VFS"); log::trace!("Generating snapshot of instances from VFS");
let snapshot = snapshot_from_vfs(&instance_context, &vfs, &start_path)?; let snapshot = snapshot_from_vfs(&instance_context, &vfs, start_path)?;
log::trace!("Computing initial patch set"); log::trace!("Computing initial patch set");
let patch_set = compute_patch_set(snapshot, &tree, root_id); let patch_set = compute_patch_set(snapshot, &tree, root_id);

View file

@ -97,8 +97,7 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
for value in instance.properties_mut().values_mut() { for value in instance.properties_mut().values_mut() {
if let Variant::Ref(referent) = value { if let Variant::Ref(referent) = value {
if let Some(&instance_referent) = context.snapshot_id_to_instance_id.get(&referent) if let Some(&instance_referent) = context.snapshot_id_to_instance_id.get(referent) {
{
*value = Variant::Ref(instance_referent); *value = Variant::Ref(instance_referent);
} }
} }

View file

@ -26,11 +26,9 @@ pub fn compute_patch_set(snapshot: Option<InstanceSnapshot>, tree: &RojoTree, id
// for all of the IDs that we know about so far. // for all of the IDs that we know about so far.
rewrite_refs_in_updates(&context, &mut patch_set.updated_instances); rewrite_refs_in_updates(&context, &mut patch_set.updated_instances);
rewrite_refs_in_additions(&context, &mut patch_set.added_instances); rewrite_refs_in_additions(&context, &mut patch_set.added_instances);
} else { } else if id != tree.get_root_id() {
if id != tree.get_root_id() {
patch_set.removed_instances.push(id); patch_set.removed_instances.push(id);
} }
}
patch_set patch_set
} }

View file

@ -37,8 +37,8 @@ pub struct RojoTree {
impl RojoTree { impl RojoTree {
pub fn new(snapshot: InstanceSnapshot) -> RojoTree { pub fn new(snapshot: InstanceSnapshot) -> RojoTree {
let root_builder = InstanceBuilder::new(snapshot.class_name.to_owned()) let root_builder = InstanceBuilder::new(snapshot.class_name)
.with_name(snapshot.name.to_owned()) .with_name(snapshot.name)
.with_properties(snapshot.properties); .with_properties(snapshot.properties);
let mut tree = RojoTree { let mut tree = RojoTree {
@ -245,7 +245,7 @@ impl<'a> InstanceWithMeta<'a> {
} }
pub fn metadata(&self) -> &'a InstanceMetadata { pub fn metadata(&self) -> &'a InstanceMetadata {
&self.metadata self.metadata
} }
} }
@ -294,6 +294,6 @@ impl InstanceWithMetaMut<'_> {
} }
pub fn metadata(&self) -> &InstanceMetadata { pub fn metadata(&self) -> &InstanceMetadata {
&self.metadata self.metadata
} }
} }

View file

@ -77,7 +77,7 @@ pub fn snapshot_dir_no_meta(
let relevant_paths = vec![ let relevant_paths = vec![
path.to_path_buf(), path.to_path_buf(),
meta_path.clone(), meta_path,
// TODO: We shouldn't need to know about Lua existing in this // TODO: We shouldn't need to know about Lua existing in this
// middleware. Should we figure out a way for that function to add // middleware. Should we figure out a way for that function to add
// relevant paths to this middleware? // relevant paths to this middleware?

View file

@ -37,7 +37,7 @@ where
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
.with_context(|| format!("Path did not have a file name: {}", path.display()))?; .with_context(|| format!("Path did not have a file name: {}", path.display()))?;
match_trailing(&file_name, suffix) match_trailing(file_name, suffix)
.with_context(|| format!("Path did not end in {}: {}", suffix, path.display())) .with_context(|| format!("Path did not end in {}: {}", suffix, path.display()))
} }
} }

View file

@ -145,11 +145,7 @@ fn property_filter(value: Option<&Variant>) -> bool {
// Lua can't do anything with SharedString values. They also can't be // Lua can't do anything with SharedString values. They also can't be
// serialized directly by Serde! // serialized directly by Serde!
if ty == Some(VariantType::SharedString) { ty != Some(VariantType::SharedString)
return false;
}
return true;
} }
/// Response body from /api/rojo /// Response body from /api/rojo