diff --git a/plugin/src/App/StatusPages/NotConnected.lua b/plugin/src/App/StatusPages/NotConnected.lua index cdc4188a..719a99b7 100644 --- a/plugin/src/App/StatusPages/NotConnected.lua +++ b/plugin/src/App/StatusPages/NotConnected.lua @@ -112,41 +112,41 @@ function NotConnectedPage:render() BackgroundTransparency = 1, ZIndex = 2, }, { - Settings = e(TextButton, { - text = "Settings", - style = "Bordered", - transparency = self.props.transparency, - layoutOrder = 1, - onClick = self.props.onNavigateSettings, - }, { - Tip = e(Tooltip.Trigger, { - text = "View and modify plugin settings", + Settings = e(TextButton, { + text = "Settings", + style = "Bordered", + transparency = self.props.transparency, + layoutOrder = 1, + onClick = self.props.onNavigateSettings, + }, { + Tip = e(Tooltip.Trigger, { + text = "View and modify plugin settings", + }), }), - }), - Syncback = e(TextButton, { - text = "Syncback", - style = "Danger", - transparency = self.props.transparency, - layoutOrder = 2, - onClick = self.props.onSyncback, - }, { - Tip = e(Tooltip.Trigger, { - text = "Overwrite project files with current Studio state", + Syncback = e(TextButton, { + text = "Syncback", + style = "Danger", + transparency = self.props.transparency, + layoutOrder = 2, + onClick = self.props.onSyncback, + }, { + Tip = e(Tooltip.Trigger, { + text = "Overwrite project files with current Studio state", + }), }), - }), - Connect = e(TextButton, { - text = "Sync", - style = "Primary", - transparency = self.props.transparency, - layoutOrder = 3, - onClick = self.props.onConnect, - }, { - Tip = e(Tooltip.Trigger, { - text = "Connect to a Rojo sync server", + Connect = e(TextButton, { + text = "Sync", + style = "Primary", + transparency = self.props.transparency, + layoutOrder = 3, + onClick = self.props.onConnect, + }, { + Tip = e(Tooltip.Trigger, { + text = "Connect to a Rojo sync server", + }), }), - }), Layout = e("UIListLayout", { HorizontalAlignment = Enum.HorizontalAlignment.Right, diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index 857dd1cf..047e6415 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -5,10 +5,20 @@ local ServerStorage = game:GetService("ServerStorage") local RunService = game:GetService("RunService") local SYNCBACK_SERVICES = { - "Lighting", "MaterialService", "ReplicatedFirst", "ReplicatedStorage", - "ServerScriptService", "ServerStorage", "SoundService", "StarterGui", - "StarterPack", "StarterPlayer", "Teams", "TextChatService", - "VoiceChatService", "Workspace", + "Lighting", + "MaterialService", + "ReplicatedFirst", + "ReplicatedStorage", + "ServerScriptService", + "ServerStorage", + "SoundService", + "StarterGui", + "StarterPack", + "StarterPlayer", + "Teams", + "TextChatService", + "VoiceChatService", + "Workspace", } local Rojo = script:FindFirstAncestor("Rojo") @@ -628,39 +638,27 @@ function App:performSyncback() port = Config.defaultPort end - Promise.new(function(resolve, reject) - local services = {} - for _, className in SYNCBACK_SERVICES do - local ok, service = pcall(game.FindService, game, className) - if ok and service and #service:GetChildren() > 0 then - local buf = SerializationService:SerializeInstancesAsync(service:GetChildren()) - table.insert(services, { - className = className, - data = buf, - }) - end + local services = {} + for _, className in SYNCBACK_SERVICES do + local ok, service = pcall(game.FindService, game, className) + if ok and service and #service:GetChildren() > 0 then + local buf = SerializationService:SerializeInstancesAsync(service:GetChildren()) + table.insert(services, { + className = className, + data = buf, + }) end + end - local url = ("http://%s:%s/api/syncback"):format(host, port) - local body = Http.msgpackEncode({ - protocolVersion = Config.protocolVersion, - serverVersion = Config.expectedServerVersionString, - placeId = game.PlaceId, - services = services, - }) + local url = ("http://%s:%s/api/syncback"):format(host, port) + local body = Http.msgpackEncode({ + protocolVersion = Config.protocolVersion, + serverVersion = Config.expectedServerVersionString, + placeId = game.PlaceId, + services = services, + }) - Http.post(url, body) - :andThen(function(response) - if response.StatusCode >= 200 and response.StatusCode < 300 then - resolve() - else - reject( - `Syncback request failed ({response.StatusCode}): {response.Body or "unknown error"}` - ) - end - end) - :catch(reject) - end) + Http.post(url, body) :andThen(function() Log.info("Syncback data sent to server.") self:addNotification({ @@ -1100,29 +1098,29 @@ function App:render() }, { Tooltips = e(Tooltip.Container, nil), - NotConnectedPage = createPageElement(AppStatus.NotConnected, { - host = self.host, - onHostChange = self.setHost, - port = self.port, - onPortChange = self.setPort, + NotConnectedPage = createPageElement(AppStatus.NotConnected, { + host = self.host, + onHostChange = self.setHost, + port = self.port, + onPortChange = self.setPort, - onConnect = function() - self:startSession() - end, + onConnect = function() + self:startSession() + end, - onSyncback = function() - self:setState({ - showingSyncbackConfirm = true, - }) - end, + onSyncback = function() + self:setState({ + showingSyncbackConfirm = true, + }) + end, - onNavigateSettings = function() - self.backPage = AppStatus.NotConnected - self:setState({ - appStatus = AppStatus.Settings, - }) - end, - }), + onNavigateSettings = function() + self.backPage = AppStatus.NotConnected + self:setState({ + appStatus = AppStatus.Settings, + }) + end, + }), ConfirmingPage = createPageElement(AppStatus.Confirming, { confirmData = self.state.confirmData, @@ -1196,106 +1194,106 @@ function App:render() }), }), - RojoNotifications = e("ScreenGui", { - ZIndexBehavior = Enum.ZIndexBehavior.Sibling, - ResetOnSpawn = false, - DisplayOrder = 100, - }, { - Notifications = e(Notifications, { - soundPlayer = self.props.soundPlayer, - notifications = self.state.notifications, - onClose = function(id) - self:closeNotification(id) - end, + RojoNotifications = e("ScreenGui", { + ZIndexBehavior = Enum.ZIndexBehavior.Sibling, + ResetOnSpawn = false, + DisplayOrder = 100, + }, { + Notifications = e(Notifications, { + soundPlayer = self.props.soundPlayer, + notifications = self.state.notifications, + onClose = function(id) + self:closeNotification(id) + end, + }), }), }), - }), - SyncbackConfirm = e(Theme.StudioProvider, nil, { - e(StudioPluginGui, { - id = "Atlas_SyncbackConfirm", - title = "Full Syncback", - active = self.state.showingSyncbackConfirm == true, - isEphemeral = true, + SyncbackConfirm = e(Theme.StudioProvider, nil, { + e(StudioPluginGui, { + id = "Atlas_SyncbackConfirm", + title = "Full Syncback", + active = self.state.showingSyncbackConfirm == true, + isEphemeral = true, - initDockState = Enum.InitialDockState.Float, - overridePreviousState = true, - floatingSize = Vector2.new(400, 150), - minimumSize = Vector2.new(300, 120), + initDockState = Enum.InitialDockState.Float, + overridePreviousState = true, + floatingSize = Vector2.new(400, 150), + minimumSize = Vector2.new(300, 120), - zIndexBehavior = Enum.ZIndexBehavior.Sibling, + zIndexBehavior = Enum.ZIndexBehavior.Sibling, - onClose = function() - self:setState({ showingSyncbackConfirm = false }) - end, - }, { - Content = Theme.with(function(theme) - local noTransparency = Roact.createBinding(0) - - return e("Frame", { - Size = UDim2.fromScale(1, 1), - BackgroundColor3 = theme.BackgroundColor, - BorderSizePixel = 0, + onClose = function() + self:setState({ showingSyncbackConfirm = false }) + end, }, { - Padding = e("UIPadding", { - PaddingLeft = UDim.new(0, 16), - PaddingRight = UDim.new(0, 16), - PaddingTop = UDim.new(0, 16), - PaddingBottom = UDim.new(0, 16), - }), - Layout = e("UIListLayout", { - FillDirection = Enum.FillDirection.Vertical, - SortOrder = Enum.SortOrder.LayoutOrder, - Padding = UDim.new(0, 16), - VerticalAlignment = Enum.VerticalAlignment.Center, - }), - Message = e("TextLabel", { - Text = "This will overwrite your project files with the current Studio state. This cannot be undone.", - TextWrapped = true, - FontFace = theme.Font.Main, - TextSize = theme.TextSize.Body, - TextColor3 = theme.TextColor, - Size = UDim2.new(1, 0, 0, 0), - AutomaticSize = Enum.AutomaticSize.Y, - BackgroundTransparency = 1, - LayoutOrder = 1, - }), - Buttons = e("Frame", { - Size = UDim2.new(1, 0, 0, 34), - BackgroundTransparency = 1, - LayoutOrder = 2, - }, { - Layout = e("UIListLayout", { - HorizontalAlignment = Enum.HorizontalAlignment.Right, - FillDirection = Enum.FillDirection.Horizontal, - SortOrder = Enum.SortOrder.LayoutOrder, - Padding = UDim.new(0, 10), - }), - Cancel = e(TextButton, { - text = "Cancel", - style = "Bordered", - transparency = noTransparency, - layoutOrder = 1, - onClick = function() - self:setState({ showingSyncbackConfirm = false }) - end, - }), - Confirm = e(TextButton, { - text = "Syncback", - style = "Danger", - transparency = noTransparency, - layoutOrder = 2, - onClick = function() - self:performSyncback() - end, - }), - }), - }) - end), - }), - }), + Content = Theme.with(function(theme) + local noTransparency = Roact.createBinding(0) - toggleAction = e(StudioPluginAction, { + return e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundColor3 = theme.BackgroundColor, + BorderSizePixel = 0, + }, { + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 16), + PaddingRight = UDim.new(0, 16), + PaddingTop = UDim.new(0, 16), + PaddingBottom = UDim.new(0, 16), + }), + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 16), + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + Message = e("TextLabel", { + Text = "This will overwrite your project files with the current Studio state. This cannot be undone.", + TextWrapped = true, + FontFace = theme.Font.Main, + TextSize = theme.TextSize.Body, + TextColor3 = theme.TextColor, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + LayoutOrder = 1, + }), + Buttons = e("Frame", { + Size = UDim2.new(1, 0, 0, 34), + BackgroundTransparency = 1, + LayoutOrder = 2, + }, { + Layout = e("UIListLayout", { + HorizontalAlignment = Enum.HorizontalAlignment.Right, + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 10), + }), + Cancel = e(TextButton, { + text = "Cancel", + style = "Bordered", + transparency = noTransparency, + layoutOrder = 1, + onClick = function() + self:setState({ showingSyncbackConfirm = false }) + end, + }), + Confirm = e(TextButton, { + text = "Syncback", + style = "Danger", + transparency = noTransparency, + layoutOrder = 2, + onClick = function() + self:performSyncback() + end, + }), + }), + }) + end), + }), + }), + + toggleAction = e(StudioPluginAction, { name = "AtlasConnection", title = "Atlas: Connect/Disconnect", description = "Toggles the server for an Atlas sync session", diff --git a/plugin/src/Reconciler/hydrate.lua b/plugin/src/Reconciler/hydrate.lua index 6ffd9702..0521930f 100644 --- a/plugin/src/Reconciler/hydrate.lua +++ b/plugin/src/Reconciler/hydrate.lua @@ -40,10 +40,8 @@ local function hydrate(instanceMap, virtualInstances, rootId, rootInstance, sess end end - local result = Matching.matchChildren( - session, validVirtualIds, existingChildren, virtualInstances, - rootId, rootInstance - ) + local result = + Matching.matchChildren(session, validVirtualIds, existingChildren, virtualInstances, rootId, rootInstance) -- Recursively hydrate matched pairs for _, pair in result.matched do diff --git a/plugin/src/Reconciler/matching.lua b/plugin/src/Reconciler/matching.lua index ea5fd386..a52178a4 100644 --- a/plugin/src/Reconciler/matching.lua +++ b/plugin/src/Reconciler/matching.lua @@ -24,7 +24,7 @@ local RbxDom = require(Packages.RbxDom) local trueEquals = require(script.Parent.trueEquals) -local UNMATCHED_PENALTY: number = 10000 +local UNMATCHED_PENALTY = 10000 -- ================================================================ -- Types @@ -49,21 +49,30 @@ type MatchingSession = { type VCache = { props: { [string]: any }, - extraProps: { string }?, - tags: { [string]: boolean }?, - attrs: { [string]: any }?, + extraProps: { string }, + tags: { [string]: boolean }, + attrs: { [string]: any }, childCount: number, } type SCache = { instance: Instance, props: { [string]: any }, - tags: { [string]: boolean }?, - attrs: { [string]: any }?, + tags: { [string]: boolean }, + attrs: { [string]: any }, children: { Instance }, childCount: number, } +type VirtualInstance = { + Name: string, + ClassName: string, + Children: { string }, + Properties: { [string]: any }?, +} + +type VirtualInstances = { [string]: VirtualInstance } + type ScoredPair = { vi: number, si: number, @@ -82,17 +91,29 @@ local function newSession(): MatchingSession } end +-- ================================================================ +-- Utilities +-- ================================================================ + +local function rawIndex(inst: any, key: string): any + return inst[key] +end + +local function safeGet(inst: Instance, key: string): (boolean, any) + return pcall(rawIndex, inst :: any, key) +end + -- ================================================================ -- Pre-computation helpers (called once per instance per group) -- ================================================================ -local function cacheVirtual(vInst: any, classKeys: any): VCache +local function cacheVirtual(vInst: VirtualInstance, classKeys: any): VCache local decoded: { [string]: any } = {} - local extraProps: { string }? = nil + local extraProps: { string } = {} local vProps = vInst.Properties if vProps then - for propName, encodedValue in vProps do + for propName, encodedValue in pairs(vProps) do if propName == "Tags" or propName == "Attributes" then continue end @@ -106,27 +127,23 @@ local function cacheVirtual(vInst: any, classKeys: any): VCache if pcallOk and decodeOk and value ~= nil then decoded[propName] = value if not classKeys.propNameSet[propName] then - if not extraProps then - extraProps = {} - end table.insert(extraProps, propName) end end end end - local decodedTags: { [string]: boolean }? = nil + local decodedTags: { [string]: boolean } = {} if vProps and vProps.Tags then local ok, tags = RbxDom.EncodedValue.decode(vProps.Tags) if ok and type(tags) == "table" then - decodedTags = {} - for _, tag in tags do + for _, tag in ipairs(tags) do decodedTags[tag] = true end end end - local decodedAttrs: { [string]: any }? = nil + local decodedAttrs: { [string]: any } = {} if vProps and vProps.Attributes then local ok, attrs = RbxDom.EncodedValue.decode(vProps.Attributes) if ok and type(attrs) == "table" then @@ -143,68 +160,40 @@ local function cacheVirtual(vInst: any, classKeys: any): VCache } end -local function cacheStudio(studioInstance: Instance, classKeys: any, extraPropNames: { string }?): SCache +local function cacheStudio(studioInstance: Instance, classKeys: any, extraPropNames: { string }): SCache local props: { [string]: any } = {} - for _, propName in classKeys.propNames do - local ok, value = pcall(function() - return (studioInstance :: any)[propName] - end) - if ok and value ~= nil then + for _, propName in ipairs(classKeys.propNames) do + local ok, value = safeGet(studioInstance, propName) + if ok then props[propName] = value end end - if extraPropNames then - for _, propName in extraPropNames do - if props[propName] ~= nil then - continue - end - local ok, value = pcall(function() - return (studioInstance :: any)[propName] - end) - if ok and value ~= nil then - props[propName] = value - end + for _, propName in ipairs(extraPropNames) do + if props[propName] ~= nil then + continue + end + local ok, value = safeGet(studioInstance, propName) + if ok then + props[propName] = value end end - local tags: { [string]: boolean }? = nil - local tagsOk, studioTags = pcall(function() - return studioInstance:GetTags() - end) - if tagsOk then - tags = {} - for _, tag in studioTags do - tags[tag] = true - end + local tags: { [string]: boolean } = {} + for _, tag in ipairs(studioInstance:GetTags()) do + tags[tag] = true end - local attrs: { [string]: any }? = nil - local attrsOk, studioAttrs = pcall(function() - return studioInstance:GetAttributes() - end) - if attrsOk then - attrs = studioAttrs - end - - local children: { Instance } = {} - local childCount: number = 0 - local childOk, studioKids = pcall(function() - return studioInstance:GetChildren() - end) - if childOk then - children = studioKids - childCount = #studioKids - end + local children = studioInstance:GetChildren() return { instance = studioInstance, props = props, tags = tags, - attrs = attrs, + attrs = studioInstance:GetAttributes(), children = children, - childCount = childCount, + childCount = #children, } end @@ -213,12 +202,12 @@ end -- ================================================================ local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: any): number - local cost: number = 0 + local cost = 0 local vProps = vCache.props local sProps = sCache.props local defaults = classKeys.defaults - for _, propName in classKeys.propNames do + for _, propName in ipairs(classKeys.propNames) do local vVal = vProps[propName] if vVal == nil then vVal = defaults[propName] @@ -229,41 +218,35 @@ local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: any): nu end end - if vCache.extraProps then - for _, propName in vCache.extraProps do - if not trueEquals(vProps[propName], sProps[propName]) then - cost += 1 - end + for _, propName in ipairs(vCache.extraProps) do + if not trueEquals(vProps[propName], sProps[propName]) then + cost += 1 end end local vTags = vCache.tags local sTags = sCache.tags - if vTags and sTags then - for tag in vTags do - if not sTags[tag] then - cost += 1 - end + for tag, _ in pairs(vTags) do + if not sTags[tag] then + cost += 1 end - for tag in sTags do - if not vTags[tag] then - cost += 1 - end + end + for tag, _ in pairs(sTags) do + if not vTags[tag] then + cost += 1 end end local vAttrs = vCache.attrs local sAttrs = sCache.attrs - if vAttrs and sAttrs then - for key, vVal in vAttrs do - if not trueEquals(vVal, sAttrs[key]) then - cost += 1 - end + for key, vVal in pairs(vAttrs) do + if not trueEquals(vVal, sAttrs[key]) then + cost += 1 end - for key in sAttrs do - if vAttrs[key] == nil then - cost += 1 - end + end + for key, _ in pairs(sAttrs) do + if vAttrs[key] == nil then + cost += 1 end end @@ -279,7 +262,7 @@ end -- ================================================================ local function removeMatched(arr: { any }, matchedIndices: { [number]: boolean }): () - local write: number = 1 + local write = 1 for read = 1, #arr do if not matchedIndices[read] then arr[write] = arr[read] @@ -301,7 +284,7 @@ local function computePairCost( session: MatchingSession, virtualId: string, studioInstance: Instance, - virtualInstances: { [string]: any }, + virtualInstances: VirtualInstances, bestSoFar: number ): number local vc = session.costCache[virtualId] @@ -321,7 +304,7 @@ local function computePairCost( local vCache = cacheVirtual(vInst, classKeys) local sCache = cacheStudio(studioInstance, classKeys, vCache.extraProps) - local cost: number = countOwnDiffs(vCache, sCache, classKeys) + local cost = countOwnDiffs(vCache, sCache, classKeys) if cost >= bestSoFar then return cost end @@ -334,23 +317,21 @@ local function computePairCost( elseif not vChildren or #vChildren == 0 then cost += #studioKids * UNMATCHED_PENALTY elseif #studioKids == 0 then - for _, childId in vChildren do + for _, childId in ipairs(vChildren) do if virtualInstances[childId] then cost += UNMATCHED_PENALTY end end else local validVChildren: { string } = {} - for _, childId in vChildren do + for _, childId in ipairs(vChildren) do if virtualInstances[childId] then table.insert(validVChildren, childId) end end if #validVChildren > 0 then - local childResult: MatchResult = matchChildren( - session, validVChildren, studioKids, virtualInstances, - virtualId, studioInstance - ) + local childResult = + matchChildren(session, validVChildren, studioKids, virtualInstances, virtualId, studioInstance) cost += childResult.totalCost else cost += #studioKids * UNMATCHED_PENALTY @@ -371,7 +352,7 @@ matchChildren = function( session: MatchingSession, virtualChildren: { string }, studioChildren: { Instance }, - virtualInstances: { [string]: any }, + virtualInstances: VirtualInstances, parentVirtualId: string?, parentStudioInstance: Instance? ): MatchResult @@ -394,10 +375,10 @@ matchChildren = function( -- Group by (Name, ClassName) -- direct property access, no pcall -- ============================================================ local vByKey: { [string]: { number } } = {} - for i, id in remainingVirtual do + for i, id in ipairs(remainingVirtual) do local vInst = virtualInstances[id] if vInst then - local key: string = vInst.Name .. "\0" .. vInst.ClassName + local key = vInst.Name .. "\0" .. vInst.ClassName local group = vByKey[key] if not group then group = {} @@ -408,8 +389,8 @@ matchChildren = function( end local sByKey: { [string]: { number } } = {} - for i, inst in remainingStudio do - local key: string = inst.Name .. "\0" .. inst.ClassName + for i, inst in ipairs(remainingStudio) do + local key = inst.Name .. "\0" .. inst.ClassName local group = sByKey[key] if not group then group = {} @@ -424,7 +405,7 @@ matchChildren = function( local matchedV: { [number]: boolean } = {} local matchedS: { [number]: boolean } = {} - for key, vIndices in vByKey do + for key, vIndices in pairs(vByKey) do local sIndices = sByKey[key] if sIndices and #vIndices == 1 and #sIndices == 1 then local vi, si = vIndices[1], sIndices[1] @@ -447,10 +428,10 @@ matchChildren = function( -- ============================================================ if #remainingVirtual > 0 and #remainingStudio > 0 then local vByKey2: { [string]: { number } } = {} - for i, id in remainingVirtual do + for i, id in ipairs(remainingVirtual) do local vInst = virtualInstances[id] if vInst then - local key: string = vInst.Name .. "\0" .. vInst.ClassName + local key = vInst.Name .. "\0" .. vInst.ClassName local group = vByKey2[key] if not group then group = {} @@ -461,8 +442,8 @@ matchChildren = function( end local sByKey2: { [string]: { number } } = {} - for i, inst in remainingStudio do - local key: string = inst.Name .. "\0" .. inst.ClassName + for i, inst in ipairs(remainingStudio) do + local key = inst.Name .. "\0" .. inst.ClassName local group = sByKey2[key] if not group then group = {} @@ -474,7 +455,7 @@ matchChildren = function( local matchedV2: { [number]: boolean } = {} local matchedS2: { [number]: boolean } = {} - for key, vIndices in vByKey2 do + for key, vIndices in pairs(vByKey2) do local sIndices = sByKey2[key] if not sIndices then continue @@ -487,8 +468,8 @@ matchChildren = function( local classKeys = RbxDom.getClassComparisonKeys(firstVInst.ClassName) local vCaches: { [number]: VCache } = {} - local allExtraProps: { [string]: boolean }? = nil - for _, vi in vIndices do + local allExtraProps: { [string]: boolean } = {} + for _, vi in ipairs(vIndices) do if matchedV2[vi] then continue end @@ -498,26 +479,18 @@ matchChildren = function( end local vCache = cacheVirtual(vInst, classKeys) vCaches[vi] = vCache - if vCache.extraProps then - if not allExtraProps then - allExtraProps = {} - end - for _, propName in vCache.extraProps do - allExtraProps[propName] = true - end + for _, propName in ipairs(vCache.extraProps) do + allExtraProps[propName] = true end end - local extraPropNamesArray: { string }? = nil - if allExtraProps then - extraPropNamesArray = {} - for propName in allExtraProps do - table.insert(extraPropNamesArray, propName) - end + local extraPropNamesArray: { string } = {} + for propName, _ in pairs(allExtraProps) do + table.insert(extraPropNamesArray, propName) end local sCaches: { [number]: SCache } = {} - for _, si in sIndices do + for _, si in ipairs(sIndices) do if not matchedS2[si] then sCaches[si] = cacheStudio(remainingStudio[si], classKeys, extraPropNamesArray) end @@ -525,10 +498,10 @@ matchChildren = function( -- Score all (A, B) pairs local scoredPairs: { ScoredPair } = {} - local pairIdx: number = 0 - local bestSoFar: number = math.huge + local pairIdx = 0 + local bestSoFar = math.huge - for _, vi in vIndices do + for _, vi in ipairs(vIndices) do if matchedV2[vi] then continue end @@ -537,7 +510,7 @@ matchChildren = function( continue end - for _, si in sIndices do + for _, si in ipairs(sIndices) do if matchedS2[si] then continue end @@ -548,7 +521,7 @@ matchChildren = function( pairIdx += 1 - local cost: number = countOwnDiffs(vCache, sCache, classKeys) + local cost = countOwnDiffs(vCache, sCache, classKeys) if cost < bestSoFar then local vInst = virtualInstances[remainingVirtual[vi]] @@ -561,22 +534,26 @@ matchChildren = function( elseif not vChildren or #vChildren == 0 then cost += #studioKids * UNMATCHED_PENALTY elseif #studioKids == 0 then - for _, childId in vChildren do + for _, childId in ipairs(vChildren) do if virtualInstances[childId] then cost += UNMATCHED_PENALTY end end else local validVChildren: { string } = {} - for _, childId in vChildren do + for _, childId in ipairs(vChildren) do if virtualInstances[childId] then table.insert(validVChildren, childId) end end if #validVChildren > 0 then - local childResult: MatchResult = matchChildren( - session, validVChildren, studioKids, virtualInstances, - remainingVirtual[vi], sCache.instance + local childResult = matchChildren( + session, + validVChildren, + studioKids, + virtualInstances, + remainingVirtual[vi], + sCache.instance ) cost += childResult.totalCost else @@ -600,7 +577,7 @@ matchChildren = function( return a.idx < b.idx end) - for _, pair in scoredPairs do + for _, pair in ipairs(scoredPairs) do if matchedV2[pair.vi] or matchedS2[pair.si] then continue end @@ -620,15 +597,13 @@ matchChildren = function( -- ============================================================ -- Compute totalCost for ALL matched pairs (session cache helps) -- ============================================================ - local totalCost: number = 0 - for _, pair in matched do - totalCost += computePairCost( - session, pair.virtualId, pair.studioInstance, virtualInstances, math.huge - ) + local totalCost = 0 + for _, pair in ipairs(matched) do + totalCost += computePairCost(session, pair.virtualId, pair.studioInstance, virtualInstances, math.huge) end totalCost += (#remainingVirtual + #remainingStudio) * UNMATCHED_PENALTY - local result: MatchResult = { + local result = { matched = matched, unmatchedVirtual = remainingVirtual, unmatchedStudio = remainingStudio, diff --git a/plugin/src/Reconciler/trueEquals.lua b/plugin/src/Reconciler/trueEquals.lua index bc736063..59499f63 100644 --- a/plugin/src/Reconciler/trueEquals.lua +++ b/plugin/src/Reconciler/trueEquals.lua @@ -19,9 +19,7 @@ local function fuzzyEq(a: number, b: number): boolean end local function color3Eq(a: Color3, b: Color3): boolean - return math.floor(a.R * 255) == math.floor(b.R * 255) - and math.floor(a.G * 255) == math.floor(b.G * 255) - and math.floor(a.B * 255) == math.floor(b.B * 255) + return fuzzyEq(a.R, b.R) and fuzzyEq(a.G, b.G) and fuzzyEq(a.B, b.B) end local function vector2Eq(a: Vector2, b: Vector2): boolean @@ -35,10 +33,18 @@ end local function cframeEq(a: CFrame, b: CFrame): boolean local ax, ay, az, aR00, aR01, aR02, aR10, aR11, aR12, aR20, aR21, aR22 = a:GetComponents() local bx, by, bz, bR00, bR01, bR02, bR10, bR11, bR12, bR20, bR21, bR22 = b:GetComponents() - return fuzzyEq(ax, bx) and fuzzyEq(ay, by) and fuzzyEq(az, bz) - and fuzzyEq(aR00, bR00) and fuzzyEq(aR01, bR01) and fuzzyEq(aR02, bR02) - and fuzzyEq(aR10, bR10) and fuzzyEq(aR11, bR11) and fuzzyEq(aR12, bR12) - and fuzzyEq(aR20, bR20) and fuzzyEq(aR21, bR21) and fuzzyEq(aR22, bR22) + return fuzzyEq(ax, bx) + and fuzzyEq(ay, by) + and fuzzyEq(az, bz) + and fuzzyEq(aR00, bR00) + and fuzzyEq(aR01, bR01) + and fuzzyEq(aR02, bR02) + and fuzzyEq(aR10, bR10) + and fuzzyEq(aR11, bR11) + and fuzzyEq(aR12, bR12) + and fuzzyEq(aR20, bR20) + and fuzzyEq(aR21, bR21) + and fuzzyEq(aR22, bR22) end local function trueEquals(a: any, b: any): boolean @@ -54,11 +60,12 @@ local function trueEquals(a: any, b: any): boolean end local t = typeof(a) - if t ~= typeof(b) then - if t == "number" and typeof(b) == "EnumItem" then + local tb = typeof(b) + if t ~= tb then + if t == "number" and tb == "EnumItem" then return a == (b :: EnumItem).Value end - if t == "EnumItem" and typeof(b) == "number" then + if t == "EnumItem" and tb == "number" then return (a :: EnumItem).Value == b end return false diff --git a/src/change_processor.rs b/src/change_processor.rs index 2a583f11..6fe5543b 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -1350,6 +1350,13 @@ impl JobThreadContext { Some(new_dir_path.join(file_name)); new_ref_segment = Some(slugified_new_name.clone()); + if patch_set.stage_ids.contains(&id) { + let old_init = dir_path.join(file_name); + pending_stage_paths + .retain(|p| *p != old_init); + pending_stage_paths + .push(new_dir_path.join(file_name)); + } let old_meta = grandparent.join(format!( "{}.meta.json5", dir_name @@ -1511,6 +1518,10 @@ impl JobThreadContext { } else { overridden_source_path = Some(new_path.clone()); new_ref_segment = Some(new_file_name.clone()); + if patch_set.stage_ids.contains(&id) { + pending_stage_paths.retain(|p| *p != *path); + pending_stage_paths.push(new_path.clone()); + } let old_meta = parent .join(format!("{}.meta.json5", old_base)); let new_meta = parent.join(format!( @@ -1689,6 +1700,11 @@ impl JobThreadContext { ); } else { overridden_source_path = Some(new_path.clone()); + if patch_set.stage_ids.contains(&id) { + pending_stage_paths + .retain(|p| *p != actual_file); + pending_stage_paths.push(new_path.clone()); + } // For standalone files, the ref path // segment changes with the extension. // For init files, the directory name diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 810a35a8..2b11375a 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -72,8 +72,11 @@ impl ServeCommand { loop { let (vfs, critical_errors) = Vfs::new_default_with_errors(); - let session = - Arc::new(ServeSession::new(vfs, project_path.clone(), Some(critical_errors))?); + let session = Arc::new(ServeSession::new( + vfs, + project_path.clone(), + Some(critical_errors), + )?); let server = LiveServer::new(session); log::info!("Listening: http://{}:{}", host, port); @@ -94,7 +97,7 @@ fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::R let new_dom = build_dom_from_chunks(payload)?; let vfs = Vfs::new_oneshot(); - let session_old = ServeSession::new_oneshot(vfs, project_path.to_path_buf())?; + let session_old = ServeSession::new_oneshot(vfs, project_path)?; let mut dom_old = session_old.tree(); @@ -149,8 +152,8 @@ fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result { created_services.insert(chunk.class_name.clone()); if !chunk.data.is_empty() { - let chunk_dom = rbx_binary::from_reader(Cursor::new(&chunk.data)) - .with_context(|| { + let chunk_dom = + rbx_binary::from_reader(Cursor::new(&chunk.data)).with_context(|| { format!("Failed to parse rbxm for service {}", chunk.class_name) })?; diff --git a/src/snapshot/matching.rs b/src/snapshot/matching.rs index 7b144431..12783f85 100644 --- a/src/snapshot/matching.rs +++ b/src/snapshot/matching.rs @@ -33,6 +33,12 @@ pub struct MatchingSession { cost_cache: RefCell>, } +impl Default for MatchingSession { + fn default() -> Self { + Self::new() + } +} + impl MatchingSession { pub fn new() -> Self { Self { @@ -116,7 +122,14 @@ pub fn match_forward( let snap_remaining = snap_matched.iter().filter(|&&m| !m).count(); let tree_remaining = tree_available.iter().filter(|&&a| a).count(); if snap_remaining == 0 || tree_remaining == 0 { - return build_result(snap_available, tree_children, &tree_available, matched, tree, session); + return build_result( + snap_available, + tree_children, + &tree_available, + matched, + tree, + session, + ); } // ================================================================ @@ -186,7 +199,14 @@ pub fn match_forward( } } - build_result(snap_available, tree_children, &tree_available, matched, tree, session) + build_result( + snap_available, + tree_children, + &tree_available, + matched, + tree, + session, + ) } /// Build the final result, consuming the snapshot children. @@ -1139,7 +1159,12 @@ mod tests { make_snapshot_with_props("Wall", "Part", tree_props_b), make_snapshot_with_props("Wall", "Part", tree_props_a), ]); - let result = match_forward(vec![snap_a, snap_b], &tree_refs, &tree, &MatchingSession::new()); + let result = match_forward( + vec![snap_a, snap_b], + &tree_refs, + &tree, + &MatchingSession::new(), + ); assert_eq!(result.matched.len(), 2, "Both should match"); diff --git a/src/snapshot/patch_compute.rs b/src/snapshot/patch_compute.rs index 944574cf..c49ebc19 100644 --- a/src/snapshot/patch_compute.rs +++ b/src/snapshot/patch_compute.rs @@ -235,7 +235,14 @@ fn compute_children_patches( // Matched pairs: recursively compute patches. for (snapshot_child, tree_child_id) in match_result.matched { - compute_patch_set_internal(context, snapshot_child, tree, tree_child_id, patch_set, session); + compute_patch_set_internal( + context, + snapshot_child, + tree, + tree_child_id, + patch_set, + session, + ); } // Unmatched snapshots: new instances to be added. diff --git a/src/syncback/matching.rs b/src/syncback/matching.rs index 6429cd50..47a49742 100644 --- a/src/syncback/matching.rs +++ b/src/syncback/matching.rs @@ -30,6 +30,12 @@ pub struct MatchingSession { cost_cache: RefCell>, } +impl Default for MatchingSession { + fn default() -> Self { + Self::new() + } +} + impl MatchingSession { pub fn new() -> Self { Self { @@ -122,12 +128,19 @@ pub fn match_children( let mut total_cost: u32 = 0; for &(new_ref, old_ref) in &matched { total_cost = total_cost.saturating_add(compute_change_count( - new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, u32::MAX, 0, session, + new_ref, + old_ref, + new_dom, + old_dom, + new_hashes, + old_hashes, + u32::MAX, + 0, + session, )); } - total_cost = total_cost.saturating_add( - (remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY, - ); + total_cost = total_cost + .saturating_add((remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY); return MatchResult { matched, unmatched_new: remaining_new, @@ -178,7 +191,14 @@ pub fn match_children( for &new_ref in &avail_new { for &old_ref in &avail_old { let cost = compute_change_count( - new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, best_so_far, 0, + new_ref, + old_ref, + new_dom, + old_dom, + new_hashes, + old_hashes, + best_so_far, + 0, session, ); pairs.push((cost, new_ref, old_ref)); @@ -208,12 +228,19 @@ pub fn match_children( let mut total_cost: u32 = 0; for &(new_ref, old_ref) in &matched { total_cost = total_cost.saturating_add(compute_change_count( - new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, u32::MAX, 0, session, + new_ref, + old_ref, + new_dom, + old_dom, + new_hashes, + old_hashes, + u32::MAX, + 0, + session, )); } - total_cost = total_cost.saturating_add( - (remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY, - ); + total_cost = total_cost + .saturating_add((remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY); MatchResult { matched, @@ -545,7 +572,15 @@ mod tests { let new_children: Vec = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let old_children: Vec = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); - let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); + let result = match_children( + &new_children, + &old_children, + &new_dom, + &old_dom, + None, + None, + &MatchingSession::new(), + ); assert_eq!(result.matched.len(), 2); assert!(result.unmatched_new.is_empty()); @@ -585,7 +620,15 @@ mod tests { let new_children: Vec = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let old_children: Vec = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); - let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); + let result = match_children( + &new_children, + &old_children, + &new_dom, + &old_dom, + None, + None, + &MatchingSession::new(), + ); assert_eq!(result.matched.len(), 2); for (new_ref, old_ref) in &result.matched { @@ -616,7 +659,15 @@ mod tests { let new_children: Vec = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let old_children: Vec = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); - let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); + let result = match_children( + &new_children, + &old_children, + &new_dom, + &old_dom, + None, + None, + &MatchingSession::new(), + ); assert_eq!(result.matched.len(), 1); assert_eq!(result.unmatched_new.len(), 1); @@ -638,7 +689,15 @@ mod tests { let new_children: Vec = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let old_children: Vec = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); - let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); + let result = match_children( + &new_children, + &old_children, + &new_dom, + &old_dom, + None, + None, + &MatchingSession::new(), + ); assert_eq!(result.matched.len(), 2); assert!(result.unmatched_new.is_empty()); assert!(result.unmatched_old.is_empty()); @@ -648,7 +707,15 @@ mod tests { fn empty_children() { let new_dom = WeakDom::new(InstanceBuilder::new("DataModel")); let old_dom = WeakDom::new(InstanceBuilder::new("DataModel")); - let result = match_children(&[], &[], &new_dom, &old_dom, None, None, &MatchingSession::new()); + let result = match_children( + &[], + &[], + &new_dom, + &old_dom, + None, + None, + &MatchingSession::new(), + ); assert!(result.matched.is_empty()); } @@ -667,8 +734,24 @@ mod tests { let new_children: Vec = new_dom.get_by_ref(new_root).unwrap().children().to_vec(); let old_children: Vec = old_dom.get_by_ref(old_root).unwrap().children().to_vec(); - let r1 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); - let r2 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new()); + let r1 = match_children( + &new_children, + &old_children, + &new_dom, + &old_dom, + None, + None, + &MatchingSession::new(), + ); + let r2 = match_children( + &new_children, + &old_children, + &new_dom, + &old_dom, + None, + None, + &MatchingSession::new(), + ); assert_eq!(r1.matched.len(), r2.matched.len()); for (a, b) in r1.matched.iter().zip(r2.matched.iter()) { diff --git a/src/web/api.rs b/src/web/api.rs index 78301414..c4792c62 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -29,9 +29,9 @@ use crate::{ web::{ interface::{ ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse, - ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, - SubscribeMessage, SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse, - PROTOCOL_VERSION, SERVER_VERSION, + ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage, + SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse, PROTOCOL_VERSION, + SERVER_VERSION, }, util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack}, }, @@ -256,7 +256,7 @@ async fn handle_api_syncback( }; syncback_signal.fire(payload); - msgpack_ok(&serde_json::json!({"status": "syncback_initiated"})) + msgpack_ok(serde_json::json!({"status": "syncback_initiated"})) } pub struct ApiService { diff --git a/src/web/mod.rs b/src/web/mod.rs index 3813562a..672a5ab6 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -41,12 +41,15 @@ impl SyncbackSignal { } pub fn fire(&self, payload: SyncbackPayload) { - *self.payload.lock().unwrap() = Some(payload); + *self.payload.lock().unwrap_or_else(|e| e.into_inner()) = Some(payload); self.notify.notify_one(); } pub fn take_payload(&self) -> Option { - self.payload.lock().unwrap().take() + self.payload + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() } } diff --git a/tests/rojo_test/serve_util.rs b/tests/rojo_test/serve_util.rs index e7bbcb2b..b993e8c3 100644 --- a/tests/rojo_test/serve_util.rs +++ b/tests/rojo_test/serve_util.rs @@ -505,8 +505,10 @@ pub fn make_rbxl_from_chunks(chunks: &[librojo::web_api::ServiceChunk]) -> Vec = HashMap::new(); for &child_ref in chunk_dom.root().children() { @@ -554,10 +556,8 @@ pub fn make_rbxl_from_chunks(chunks: &[librojo::web_api::ServiceChunk]) -> Vec = chunks - .iter() - .map(|c| c.class_name.clone()) - .collect(); + let existing: std::collections::HashSet = + chunks.iter().map(|c| c.class_name.clone()).collect(); for &svc in &visible_services { if !existing.contains(svc) { dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new(svc)); diff --git a/tests/tests/git_sync_defaults.rs b/tests/tests/git_sync_defaults.rs index 171db27d..5a7e95ec 100644 --- a/tests/tests/git_sync_defaults.rs +++ b/tests/tests/git_sync_defaults.rs @@ -899,3 +899,53 @@ fn batch_mixed_stage_and_source_write() { ); } } + +// =========================================================================== +// stage_ids survives rename (Fix 2 from audit) +// =========================================================================== + +#[test] +fn stage_ids_stages_new_path_after_rename() { + let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| { + git_commit_all(path, "initial commit"); + }); + let info = session.wait_to_come_online(); + let read = session.get_api_read(info.root_instance_id).unwrap(); + + let (module_id, _) = + find_instance_by_name(&read.instances, "ModuleA").expect("ModuleA should exist"); + + let old_path = session.path().join("src/ModuleA.luau"); + let new_path = session.path().join("src/RenamedModule.luau"); + assert!(old_path.exists(), "ModuleA.luau should exist before rename"); + + let write_request = WriteRequest { + session_id: info.session_id, + removed: vec![], + added: HashMap::new(), + updated: vec![InstanceUpdate { + id: module_id, + changed_name: Some("RenamedModule".to_string()), + changed_class_name: None, + changed_properties: UstrMap::default(), + changed_metadata: None, + }], + stage_ids: vec![module_id], + }; + session.post_api_write(&write_request).unwrap(); + thread::sleep(Duration::from_millis(1000)); + + assert!( + new_path.exists(), + "RenamedModule.luau should exist after rename" + ); + assert!( + !old_path.exists(), + "ModuleA.luau should be gone after rename" + ); + + assert!( + git_is_staged(session.path(), "src/RenamedModule.luau"), + "New file (RenamedModule.luau) should be staged after rename + stage_ids" + ); +} diff --git a/tests/tests/live_syncback.rs b/tests/tests/live_syncback.rs index 16ac702a..5752138b 100644 --- a/tests/tests/live_syncback.rs +++ b/tests/tests/live_syncback.rs @@ -9,11 +9,7 @@ use crate::rojo_test::{ use librojo::web_api::{ServiceChunk, SocketPacketType, PROTOCOL_VERSION}; -fn assert_live_matches_cli( - fixture_name: &str, - chunks: &[ServiceChunk], - place_id: Option, -) { +fn assert_live_matches_cli(fixture_name: &str, chunks: &[ServiceChunk], place_id: Option) { run_serve_test(fixture_name, |session, _| { session.post_api_syncback(place_id, chunks.to_vec()); session.wait_to_come_back_online(); @@ -32,10 +28,16 @@ fn parity_scripts() { vec![ InstanceBuilder::new("ModuleScript") .with_name("Utils") - .with_property("Source", rbx_dom_weak::types::Variant::String("return {}".into())), + .with_property( + "Source", + rbx_dom_weak::types::Variant::String("return {}".into()), + ), InstanceBuilder::new("Script") .with_name("Main") - .with_property("Source", rbx_dom_weak::types::Variant::String("print('hi')".into())) + .with_property( + "Source", + rbx_dom_weak::types::Variant::String("print('hi')".into()), + ) .with_property( "RunContext", rbx_dom_weak::types::Variant::Enum(rbx_dom_weak::types::Enum::from_u32(1)), @@ -117,7 +119,10 @@ fn parity_mixed_file_types() { vec![ InstanceBuilder::new("StringValue") .with_name("MyString") - .with_property("Value", rbx_dom_weak::types::Variant::String("hello world".into())), + .with_property( + "Value", + rbx_dom_weak::types::Variant::String("hello world".into()), + ), InstanceBuilder::new("Folder").with_name("Container"), InstanceBuilder::new("ModuleScript") .with_name("DataModule") diff --git a/tests/tests/matching_fixtures.rs b/tests/tests/matching_fixtures.rs index 02614e6b..c058e67a 100644 --- a/tests/tests/matching_fixtures.rs +++ b/tests/tests/matching_fixtures.rs @@ -309,7 +309,8 @@ fn forward_matching_ufowave_textures() { // Run forward sync matching let session = librojo::snapshot::matching::MatchingSession::new(); - let result = librojo::snapshot::matching::match_forward(snap_textures, &tree_textures, &tree, &session); + let result = + librojo::snapshot::matching::match_forward(snap_textures, &tree_textures, &tree, &session); assert_eq!( result.matched.len(),