From e30545c132404d68c56b1f04289277fba23c3c6f Mon Sep 17 00:00:00 2001 From: Lucien Greathouse Date: Sun, 10 Jun 2018 22:53:22 -0700 Subject: [PATCH] merge impl-v2: plugin --- plugin/.editorconfig | 4 - plugin/.luacheckrc | 4 + plugin/rojo.json | 8 +- plugin/src/Api.lua | 114 -------------- plugin/src/ApiContext.lua | 119 ++++++++++++++ plugin/src/Config.lua | 13 +- plugin/src/Http.lua | 34 ++-- plugin/src/HttpError.lua | 9 +- plugin/src/Main.server.lua | 41 ++--- plugin/src/Plugin.lua | 311 ------------------------------------- plugin/src/Reconciler.lua | 246 ----------------------------- plugin/src/RouteMap.lua | 123 --------------- plugin/src/Session.lua | 76 +++++++++ 13 files changed, 243 insertions(+), 859 deletions(-) delete mode 100644 plugin/.editorconfig delete mode 100644 plugin/src/Api.lua create mode 100644 plugin/src/ApiContext.lua delete mode 100644 plugin/src/Plugin.lua delete mode 100644 plugin/src/Reconciler.lua delete mode 100644 plugin/src/RouteMap.lua create mode 100644 plugin/src/Session.lua diff --git a/plugin/.editorconfig b/plugin/.editorconfig deleted file mode 100644 index da77c5d6..00000000 --- a/plugin/.editorconfig +++ /dev/null @@ -1,4 +0,0 @@ -[*.lua] -indent_style = tab -trim_trailing_whitespace = true -insert_final_newline = true \ No newline at end of file diff --git a/plugin/.luacheckrc b/plugin/.luacheckrc index df243b3b..5e011987 100644 --- a/plugin/.luacheckrc +++ b/plugin/.luacheckrc @@ -39,6 +39,10 @@ stds.testez = { ignore = { "212", -- unused arguments + "421", -- shadowing local variable + "422", -- shadowing argument + "431", -- shadowing upvalue + "432", -- shadowing upvalue argument } std = "lua51+roblox" diff --git a/plugin/rojo.json b/plugin/rojo.json index 6ba55d43..e184e52f 100644 --- a/plugin/rojo.json +++ b/plugin/rojo.json @@ -18,14 +18,14 @@ "path": "modules/roact-rodux/lib", "target": "ReplicatedStorage.Rojo.modules.RoactRodux" }, - "modules/promise": { - "path": "modules/promise/lib", - "target": "ReplicatedStorage.Rojo.modules.Promise" - }, "modules/testez": { "path": "modules/testez/lib", "target": "ReplicatedStorage.TestEZ" }, + "modules/promise": { + "path": "modules/promise/lib", + "target": "ReplicatedStorage.Rojo.modules.Promise" + }, "tests": { "path": "tests", "target": "TestService" diff --git a/plugin/src/Api.lua b/plugin/src/Api.lua deleted file mode 100644 index 317f6282..00000000 --- a/plugin/src/Api.lua +++ /dev/null @@ -1,114 +0,0 @@ -local HttpService = game:GetService("HttpService") - -local Promise = require(script.Parent.Parent.modules.Promise) - -local Config = require(script.Parent.Config) -local Version = require(script.Parent.Version) - -local Api = {} -Api.__index = Api - -Api.Error = { - ServerIdMismatch = "ServerIdMismatch", -} - -setmetatable(Api.Error, { - __index = function(_, key) - error("Invalid API.Error name " .. key, 2) - end -}) - ---[[ - Api.connect(Http) -> Promise - - Create a new Api using the given HTTP implementation. - - Attempting to invoke methods on an invalid conext will throw errors! -]] -function Api.connect(http) - local context = { - http = http, - serverId = nil, - currentTime = 0, - } - - setmetatable(context, Api) - - return context:_start() - :andThen(function() - return context - end) -end - -function Api:_start() - return self.http:get("/") - :andThen(function(response) - response = response:json() - - if response.protocolVersion ~= Config.protocolVersion then - local message = ( - "Found a Rojo dev server, but it's using a different protocol version, and is incompatible." .. - "\nMake sure you have matching versions of both the Rojo plugin and server!" .. - "\n\nYour client is version %s, with protocol version %s. It expects server version %s." .. - "\nYour server is version %s, with protocol version %s." .. - "\n\nGo to https://github.com/LPGhatguy/rojo for more details." - ):format( - Version.display(Config.version), Config.protocolVersion, - Config.expectedApiVersionString, - response.serverVersion, response.protocolVersion - ) - - return Promise.reject(message) - end - - self.serverId = response.serverId - self.currentTime = response.currentTime - end) -end - -function Api:getInfo() - return self.http:get("/") - :andThen(function(response) - response = response:json() - - if response.serverId ~= self.serverId then - return Promise.reject(Api.Error.ServerIdMismatch) - end - - return response - end) -end - -function Api:read(paths) - local body = HttpService:JSONEncode(paths) - - return self.http:post("/read", body) - :andThen(function(response) - response = response:json() - - if response.serverId ~= self.serverId then - return Promise.reject(Api.Error.ServerIdMismatch) - end - - return response.items - end) -end - -function Api:getChanges() - local url = ("/changes/%f"):format(self.currentTime) - - return self.http:get(url) - :andThen(function(response) - response = response:json() - - if response.serverId ~= self.serverId then - return Promise.reject(Api.Error.ServerIdMismatch) - end - - self.currentTime = response.currentTime - - return response.changes - end) -end - -return Api diff --git a/plugin/src/ApiContext.lua b/plugin/src/ApiContext.lua new file mode 100644 index 00000000..999f8cb7 --- /dev/null +++ b/plugin/src/ApiContext.lua @@ -0,0 +1,119 @@ +local Promise = require(script.Parent.Parent.modules.Promise) + +local Config = require(script.Parent.Config) +local Version = require(script.Parent.Version) +local Http = require(script.Parent.Http) +local HttpError = require(script.Parent.HttpError) + +local ApiContext = {} +ApiContext.__index = ApiContext + +-- TODO: Audit cases of errors and create enum values for each of them. +ApiContext.Error = { + ServerIdMismatch = "ServerIdMismatch", +} + +setmetatable(ApiContext.Error, { + __index = function(_, key) + error("Invalid API.Error name " .. key, 2) + end +}) + +function ApiContext.new(url, onMessage) + assert(type(url) == "string") + assert(type(onMessage) == "function") + + local context = { + url = url, + onMessage = onMessage, + serverId = nil, + connected = false, + messageCursor = -1, + } + + setmetatable(context, ApiContext) + + return context +end + +function ApiContext:connect() + return Http.get(self.url) + :andThen(function(response) + local body = response:json() + + if body.protocolVersion ~= Config.protocolVersion then + local message = ( + "Found a Rojo dev server, but it's using a different protocol version, and is incompatible." .. + "\nMake sure you have matching versions of both the Rojo plugin and server!" .. + "\n\nYour client is version %s, with protocol version %s. It expects server version %s." .. + "\nYour server is version %s, with protocol version %s." .. + "\n\nGo to https://github.com/LPGhatguy/rojo for more details." + ):format( + Version.display(Config.version), Config.protocolVersion, + Config.expectedApiContextVersionString, + body.serverVersion, body.protocolVersion + ) + + return Promise.reject(message) + end + + self.serverId = body.serverId + self.connected = true + end) +end + +function ApiContext:readAll() + if not self.connected then + return Promise.reject() + end + + return Http.get(self.url .. "/read_all") + :andThen(function(response) + local body = response:json() + + if body.serverId ~= self.serverId then + return Promise.reject("server changed ID") + end + + self.messageCursor = body.messageCursor + + return body.instances + end, function(err) + self.connected = false + + return Promise.reject(err) + end) +end + +function ApiContext:retrieveMessages() + if not self.connected then + return Promise.reject() + end + + return Http.get(self.url .. "/subscribe/" .. self.messageCursor) + :andThen(function(response) + local body = response:json() + + if body.serverId ~= self.serverId then + return Promise.reject("server changed ID") + end + + for _, message in ipairs(body.messages) do + self.onMessage(message) + end + + self.messageCursor = body.messageCursor + + return self:retrieveMessages() + end, function(err) + if err.type == HttpError.Error.Timeout then + return self:retrieveMessages() + end + + self.connected = false + + return Promise.reject(err) + end) +end + +return ApiContext diff --git a/plugin/src/Config.lua b/plugin/src/Config.lua index c8f2ed91..a3bb163a 100644 --- a/plugin/src/Config.lua +++ b/plugin/src/Config.lua @@ -1,12 +1,7 @@ return { - pollingRate = 0.2, - version = {0, 4, 11}, - expectedServerVersionString = "0.4.x", - protocolVersion = 1, - icons = { - syncIn = "rbxassetid://1820320573", - togglePolling = "rbxassetid://1820320064", - testConnection = "rbxassetid://1820320989", - }, + version = {0, 5, 0}, + expectedServerVersionString = "0.5.x", + protocolVersion = 2, + port = 34872, dev = false, } diff --git a/plugin/src/Http.lua b/plugin/src/Http.lua index 56a538b4..19383b7e 100644 --- a/plugin/src/Http.lua +++ b/plugin/src/Http.lua @@ -13,27 +13,15 @@ local function dprint(...) end end +-- TODO: Factor out into separate library, especially error handling local Http = {} -Http.__index = Http -function Http.new(baseUrl) - assert(type(baseUrl) == "string", "Http.new needs a baseUrl!") - - local http = { - baseUrl = baseUrl - } - - setmetatable(http, Http) - - return http -end - -function Http:get(endpoint) - dprint("\nGET", endpoint) +function Http.get(url) + dprint("\nGET", url) return Promise.new(function(resolve, reject) spawn(function() local ok, result = pcall(function() - return HttpService:GetAsync(self.baseUrl .. endpoint, true) + return HttpService:GetAsync(url, true) end) if ok then @@ -46,13 +34,13 @@ function Http:get(endpoint) end) end -function Http:post(endpoint, body) - dprint("\nPOST", endpoint) +function Http.post(url, body) + dprint("\nPOST", url) dprint(body) return Promise.new(function(resolve, reject) spawn(function() local ok, result = pcall(function() - return HttpService:PostAsync(self.baseUrl .. endpoint, body) + return HttpService:PostAsync(url, body) end) if ok then @@ -65,4 +53,12 @@ function Http:post(endpoint, body) end) end +function Http.jsonEncode(object) + return HttpService:JSONEncode(object) +end + +function Http.jsonDecode(source) + return HttpService:JSONDecode(source) +end + return Http diff --git a/plugin/src/HttpError.lua b/plugin/src/HttpError.lua index 674721cf..ca624037 100644 --- a/plugin/src/HttpError.lua +++ b/plugin/src/HttpError.lua @@ -10,6 +10,9 @@ HttpError.Error = { message = "Rojo plugin couldn't connect to the Rojo server.\n" .. "Make sure the server is running -- use 'Rojo serve' to run it!", }, + Timeout = { + message = "Rojo timed out during a request.", + }, Unknown = { message = "Rojo encountered an unknown error: {{message}}", }, @@ -44,7 +47,11 @@ function HttpError.fromErrorString(err) end if err:find("^curl error") then - return HttpError.new(HttpError.Error.ConnectFailed) + if err:find("couldn't connect to server") then + return HttpError.new(HttpError.Error.ConnectFailed) + elseif err:find("timeout was reached") then + return HttpError.new(HttpError.Error.Timeout) + end end return HttpError.new(HttpError.Error.Unknown, err) diff --git a/plugin/src/Main.server.lua b/plugin/src/Main.server.lua index 0b21c11d..e2c50be3 100644 --- a/plugin/src/Main.server.lua +++ b/plugin/src/Main.server.lua @@ -2,7 +2,7 @@ if not plugin then return end -local Plugin = require(script.Parent.Plugin) +local Session = require(script.Parent.Session) local Config = require(script.Parent.Config) local Version = require(script.Parent.Version) @@ -39,40 +39,25 @@ local function checkUpgrade() end local function main() - local pluginInstance = Plugin.new() - local displayedVersion = Config.dev and "DEV" or Version.display(Config.version) - local toolbar = plugin:CreateToolbar("Rojo Plugin " .. displayedVersion) + local toolbar = plugin:CreateToolbar("Rojo " .. displayedVersion) - toolbar:CreateButton("Test Connection", "Connect to Rojo Server", Config.icons.testConnection) + local currentSession + + -- TODO: More robust session tracking to handle errors + -- TODO: Icon! + toolbar:CreateButton("Connect", "Connect to Rojo Session", "") .Click:Connect(function() checkUpgrade() - pluginInstance:connect() - :catch(function(err) - warn(err) - end) - end) + if currentSession ~= nil then + warn("Rojo: A session is already running!") + return + end - toolbar:CreateButton("Sync In", "Sync into Roblox Studio", Config.icons.syncIn) - .Click:Connect(function() - checkUpgrade() - - pluginInstance:syncIn() - :catch(function(err) - warn(err) - end) - end) - - toolbar:CreateButton("Toggle Polling", "Poll server for changes", Config.icons.togglePolling) - .Click:Connect(function() - checkUpgrade() - - pluginInstance:togglePolling() - :catch(function(err) - warn(err) - end) + print("Rojo: Started session.") + currentSession = Session.new() end) end diff --git a/plugin/src/Plugin.lua b/plugin/src/Plugin.lua deleted file mode 100644 index 04391133..00000000 --- a/plugin/src/Plugin.lua +++ /dev/null @@ -1,311 +0,0 @@ -local CoreGui = game:GetService("CoreGui") - -local Promise = require(script.Parent.Parent.modules.Promise) - -local Config = require(script.Parent.Config) -local Http = require(script.Parent.Http) -local Api = require(script.Parent.Api) -local Reconciler = require(script.Parent.Reconciler) -local Version = require(script.Parent.Version) - -local MESSAGE_SERVER_CHANGED = "Rojo: The server has changed since the last request, reloading plugin..." -local MESSAGE_PLUGIN_CHANGED = "Rojo: Another instance of Rojo came online, unloading..." - -local function collectMatch(source, pattern) - local result = {} - - for match in source:gmatch(pattern) do - table.insert(result, match) - end - - return result -end - -local Plugin = {} -Plugin.__index = Plugin - -function Plugin.new() - local address = "localhost" - local port = Config.dev and 8001 or 8000 - - local remote = ("http://%s:%d"):format(address, port) - - local self = { - _http = Http.new(remote), - _reconciler = Reconciler.new(), - _api = nil, - _polling = false, - _syncInProgress = false, - } - - setmetatable(self, Plugin) - - do - local uiName = ("Rojo %s UI"):format(Version.display(Config.version)) - - if Config.dev then - uiName = "Rojo Dev UI" - end - - -- If there's an existing Rojo UI, like from a Roblox plugin upgrade - -- that wasn't Rojo, make sure we clean it up. - local existingUi = CoreGui:FindFirstChild(uiName) - - if existingUi ~= nil then - existingUi:Destroy() - end - - local screenGui = Instance.new("ScreenGui") - screenGui.Name = uiName - screenGui.Parent = CoreGui - screenGui.DisplayOrder = -1 - screenGui.Enabled = false - - local label = Instance.new("TextLabel") - label.Font = Enum.Font.SourceSans - label.TextSize = 20 - label.Text = "Rojo polling..." - label.BackgroundColor3 = Color3.fromRGB(31, 31, 31) - label.BackgroundTransparency = 0.5 - label.BorderSizePixel = 0 - label.TextColor3 = Color3.new(1, 1, 1) - label.Size = UDim2.new(0, 120, 0, 28) - label.Position = UDim2.new(0, 0, 0, 0) - label.Parent = screenGui - - self._label = screenGui - - -- If our UI was destroyed, we assume it was from another instance of - -- the Rojo plugin coming online. - -- - -- Roblox doesn't notify plugins when they get unloaded, so this is the - -- best trigger we have right now unless we create a dedicated event - -- object. - screenGui.AncestryChanged:Connect(function(_, parent) - if parent == nil then - warn(MESSAGE_PLUGIN_CHANGED) - self:restart() - end - end) - end - - return self -end - ---[[ - Clears all state and issues a notice to the user that the plugin has - restarted. -]] -function Plugin:restart() - self:stopPolling() - - self._reconciler:destruct() - self._reconciler = Reconciler.new() - - self._api = nil - self._polling = false - self._syncInProgress = false -end - -function Plugin:getApi() - if self._api == nil then - return Api.connect(self._http) - :andThen(function(api) - self._api = api - - return api - end, function(err) - return Promise.reject(err) - end) - end - - return Promise.resolve(self._api) -end - -function Plugin:connect() - print("Rojo: Testing connection...") - - return self:getApi() - :andThen(function(api) - local ok, info = api:getInfo():await() - - if not ok then - return Promise.reject(info) - end - - print("Rojo: Server found!") - print("Rojo: Protocol version:", info.protocolVersion) - print("Rojo: Server version:", info.serverVersion) - end) - :catch(function(err) - if err == Api.Error.ServerIdMismatch then - warn(MESSAGE_SERVER_CHANGED) - self:restart() - return self:connect() - else - return Promise.reject(err) - end - end) -end - -function Plugin:togglePolling() - if self._polling then - return self:stopPolling() - else - return self:startPolling() - end -end - -function Plugin:stopPolling() - if not self._polling then - return Promise.resolve(false) - end - - print("Rojo: Stopped polling server for changes.") - - self._polling = false - self._label.Enabled = false - - return Promise.resolve(true) -end - -function Plugin:_pull(api, project, fileRoutes) - return api:read(fileRoutes) - :andThen(function(items) - for index = 1, #fileRoutes do - local fileRoute = fileRoutes[index] - local partitionName = fileRoute[1] - local partition = project.partitions[partitionName] - local item = items[index] - - local partitionTargetRbxRoute = collectMatch(partition.target, "[^.]+") - - -- If the item route's length was 1, we need to rename the instance to - -- line up with the partition's root object name. - if item ~= nil and #fileRoute == 1 then - local objectName = partition.target:match("[^.]+$") - item.Name = objectName - end - - local itemRbxRoute = {} - for _, piece in ipairs(partitionTargetRbxRoute) do - table.insert(itemRbxRoute, piece) - end - - for i = 2, #fileRoute do - table.insert(itemRbxRoute, fileRoute[i]) - end - - self._reconciler:reconcileRoute(itemRbxRoute, item, fileRoute) - end - end) -end - -function Plugin:startPolling() - if self._polling then - return - end - - print("Rojo: Starting to poll server for changes...") - - self._polling = true - self._label.Enabled = true - - return self:getApi() - :andThen(function(api) - local infoOk, info = api:getInfo():await() - - if not infoOk then - return Promise.reject(info) - end - - local syncOk, result = self:syncIn():await() - - if not syncOk then - return Promise.reject(result) - end - - while self._polling do - local changesOk, changes = api:getChanges():await() - - if not changesOk then - return Promise.reject(changes) - end - - if #changes > 0 then - local routes = {} - - for _, change in ipairs(changes) do - table.insert(routes, change.route) - end - - local pullOk, pullResult = self:_pull(api, info.project, routes):await() - - if not pullOk then - return Promise.reject(pullResult) - end - end - - wait(Config.pollingRate) - end - end) - :catch(function(err) - if err == Api.Error.ServerIdMismatch then - warn(MESSAGE_SERVER_CHANGED) - self:restart() - return self:startPolling() - else - self:stopPolling() - return Promise.reject(err) - end - end) -end - -function Plugin:syncIn() - if self._syncInProgress then - warn("Rojo: Can't sync right now, because a sync is already in progress.") - - return Promise.resolve() - end - - self._syncInProgress = true - print("Rojo: Syncing from server...") - - return self:getApi() - :andThen(function(api) - local ok, info = api:getInfo():await() - - if not ok then - return Promise.reject(info) - end - - local fileRoutes = {} - - for name in pairs(info.project.partitions) do - table.insert(fileRoutes, {name}) - end - - local pullSuccess, pullResult = self:_pull(api, info.project, fileRoutes):await() - - self._syncInProgress = false - - if not pullSuccess then - return Promise.reject(pullResult) - end - - print("Rojo: Sync successful!") - end) - :catch(function(err) - self._syncInProgress = false - - if err == Api.Error.ServerIdMismatch then - warn(MESSAGE_SERVER_CHANGED) - self:restart() - return self:syncIn() - else - return Promise.reject(err) - end - end) -end - -return Plugin diff --git a/plugin/src/Reconciler.lua b/plugin/src/Reconciler.lua deleted file mode 100644 index b0ba0b0f..00000000 --- a/plugin/src/Reconciler.lua +++ /dev/null @@ -1,246 +0,0 @@ -local RouteMap = require(script.Parent.RouteMap) - -local function classEqual(a, b) - assert(typeof(a) == "string") - assert(typeof(b) == "string") - - if a == "*" or b == "*" then - return true - end - - return a == b -end - -local function applyProperties(target, properties) - assert(typeof(target) == "Instance") - assert(typeof(properties) == "table") - - for key, property in pairs(properties) do - -- TODO: Transform property value based on property.Type - -- Right now, we assume that 'value' is primitive! - target[key] = property.Value - end -end - ---[[ - Attempt to parent `rbx` to `parent`, doing nothing if: - * parent is already `parent` - * Changing parent threw an error -]] -local function reparent(rbx, parent) - assert(typeof(rbx) == "Instance") - assert(typeof(parent) == "Instance") - - if rbx.Parent == parent then - return - end - - -- Setting `Parent` can fail if: - -- * The object has been destroyed - -- * The object is a service and cannot be reparented - pcall(function() - rbx.Parent = parent - end) -end - ---[[ - Attempts to match up Roblox instances and object specifiers for - reconciliation. - - An object is considered a match if they have the same Name and ClassName. - - primaryChildren and secondaryChildren can each be either a list of Roblox - instances or object specifiers. Since they share a common shape, switching - the two around isn't problematic! - - visited is expected to be an empty table initially. It will be filled with - the set of children that have been visited so far. -]] -local function findNextChildPair(primaryChildren, secondaryChildren, visited) - for _, primaryChild in ipairs(primaryChildren) do - if not visited[primaryChild] then - visited[primaryChild] = true - - for _, secondaryChild in ipairs(secondaryChildren) do - if classEqual(primaryChild.ClassName, secondaryChild.ClassName) and primaryChild.Name == secondaryChild.Name then - visited[secondaryChild] = true - - return primaryChild, secondaryChild - end - end - - return primaryChild, nil - end - end - - return nil, nil -end - -local Reconciler = {} -Reconciler.__index = Reconciler - -function Reconciler.new() - local reconciler = { - _routeMap = RouteMap.new(), - } - - setmetatable(reconciler, Reconciler) - - return reconciler -end - ---[[ - A semi-smart algorithm that attempts to apply the given item's children to - an existing Roblox object. -]] -function Reconciler:_reconcileChildren(rbx, item) - local visited = {} - local rbxChildren = rbx:GetChildren() - - -- Reconcile any children that were added or updated - while true do - local itemChild, rbxChild = findNextChildPair(item.Children, rbxChildren, visited) - - if itemChild == nil then - break - end - - local newRbxChild = self:reconcile(rbxChild, itemChild) - - if newRbxChild ~= nil then - newRbxChild.Parent = rbx - end - end - - -- Reconcile any children that were deleted - while true do - local rbxChild, itemChild = findNextChildPair(rbxChildren, item.Children, visited) - - if rbxChild == nil then - break - end - - local newRbxChild = self:reconcile(rbxChild, itemChild) - - if newRbxChild ~= nil then - newRbxChild.Parent = rbx - end - end -end - ---[[ - Construct a new Roblox object from the given item. -]] -function Reconciler:_reify(item) - local className = item.ClassName - - -- "*" represents a match of any class. It reifies as a folder! - if className == "*" then - className = "Folder" - end - - local rbx = Instance.new(className) - rbx.Name = item.Name - - applyProperties(rbx, item.Properties) - - for _, child in ipairs(item.Children) do - reparent(self:_reify(child), rbx) - end - - if item.Route ~= nil then - self._routeMap:insert(item.Route, rbx) - end - - return rbx -end - ---[[ - Clears any state that the Reconciler has, stopping it completely. -]] -function Reconciler:destruct() - self._routeMap:destruct() -end - ---[[ - Apply the changes represented by the given item to a Roblox object that's a - child of the given instance. -]] -function Reconciler:reconcile(rbx, item) - -- Item was deleted - if item == nil then - if rbx ~= nil then - self._routeMap:removeByRbx(rbx) - rbx:Destroy() - end - - return nil - end - - -- Item was created! - if rbx == nil then - return self:_reify(item) - end - - -- Item changed type! - if not classEqual(rbx.ClassName, item.ClassName) then - self._routeMap:removeByRbx(rbx) - rbx:Destroy() - - return self:_reify(item) - end - - -- It's possible that the instance we're associating with this item hasn't - -- been inserted into the RouteMap yet. - if item.Route ~= nil then - self._routeMap:insert(item.Route, rbx) - end - - applyProperties(rbx, item.Properties) - self:_reconcileChildren(rbx, item) - - return rbx -end - -function Reconciler:reconcileRoute(rbxRoute, item, fileRoute) - local parent - local rbx = game - - for i = 1, #rbxRoute do - local piece = rbxRoute[i] - - local child = rbx:FindFirstChild(piece) - - -- We should get services instead of making folders here. - if rbx == game and child == nil then - local success - success, child = pcall(game.GetService, game, piece) - - -- That isn't a valid service! - if not success then - child = nil - end - end - - -- We don't want to create a folder if we're reaching our target item! - if child == nil and i ~= #rbxRoute then - child = Instance.new("Folder") - child.Parent = rbx - child.Name = piece - end - - parent = rbx - rbx = child - end - - -- Let's check the route map! - if rbx == nil then - rbx = self._routeMap:get(fileRoute) - end - - rbx = self:reconcile(rbx, item) - - reparent(rbx, parent) -end - -return Reconciler diff --git a/plugin/src/RouteMap.lua b/plugin/src/RouteMap.lua deleted file mode 100644 index 8dc92fc7..00000000 --- a/plugin/src/RouteMap.lua +++ /dev/null @@ -1,123 +0,0 @@ ---[[ - A map from Route objects (given by the server) to Roblox instances (created - by the plugin). -]] - -local function hashRoute(route) - return table.concat(route, "/") -end - -local RouteMap = {} -RouteMap.__index = RouteMap - -function RouteMap.new() - local self = { - _map = {}, - _reverseMap = {}, - _connectionsByRbx = {}, - } - - setmetatable(self, RouteMap) - - return self -end - -function RouteMap:insert(route, rbx) - local hashed = hashRoute(route) - - -- Make sure that each route and instance are only present in RouteMap once. - self:removeByRoute(route) - self:removeByRbx(rbx) - - self._map[hashed] = rbx - self._reverseMap[rbx] = hashed - self._connectionsByRbx[rbx] = rbx.AncestryChanged:Connect(function(_, parent) - if parent == nil then - self:removeByRbx(rbx) - end - end) -end - -function RouteMap:get(route) - return self._map[hashRoute(route)] -end - -function RouteMap:removeByRoute(route) - local hashedRoute = hashRoute(route) - local rbx = self._map[hashedRoute] - - if rbx ~= nil then - self:_removeInternal(rbx, hashedRoute) - end -end - -function RouteMap:removeByRbx(rbx) - local hashedRoute = self._reverseMap[rbx] - - if hashedRoute ~= nil then - self:_removeInternal(rbx, hashedRoute) - end -end - ---[[ - Correcly removes the given Roblox Instance/Route pair from the RouteMap. -]] -function RouteMap:_removeInternal(rbx, hashedRoute) - self._map[hashedRoute] = nil - self._reverseMap[rbx] = nil - self._connectionsByRbx[rbx]:Disconnect() - self._connectionsByRbx[rbx] = nil - - self:_removeRbxDescendants(rbx) -end - ---[[ - Ensure that there are no descendants of the given Roblox Instance still - present in the map, guaranteeing that it has been cleaned out. -]] -function RouteMap:_removeRbxDescendants(parentRbx) - for rbx in pairs(self._reverseMap) do - if rbx:IsDescendantOf(parentRbx) then - self:removeByRbx(rbx) - end - end -end - ---[[ - Remove all items from the map and disconnect all connections, cleaning up - the RouteMap. -]] -function RouteMap:destruct() - self._map = {} - self._reverseMap = {} - - for _, connection in pairs(self._connectionsByRbx) do - connection:Disconnect() - end - - self._connectionsByRbx = {} -end - -function RouteMap:visualize() - -- Log all of our keys so that the visualization has a stable order. - local keys = {} - - for key in pairs(self._map) do - table.insert(keys, key) - end - - table.sort(keys) - - local buffer = {} - for _, key in ipairs(keys) do - local visualized = ("- %s: %s"):format( - key, - self._map[key]:GetFullName() - ) - table.insert(buffer, visualized) - end - - return table.concat(buffer, "\n") -end - -return RouteMap diff --git a/plugin/src/Session.lua b/plugin/src/Session.lua new file mode 100644 index 00000000..1d0e6d30 --- /dev/null +++ b/plugin/src/Session.lua @@ -0,0 +1,76 @@ +local Config = require(script.Parent.Config) +local ApiContext = require(script.Parent.ApiContext) + +local REMOTE_URL = ("http://localhost:%d"):format(Config.port) + +local Session = {} +Session.__index = Session + +function Session.new() + local self = {} + + setmetatable(self, Session) + + -- TODO: Rewrite all instance tracking logic and implement a real reconciler + local created = {} + created["0"] = game:GetService("ReplicatedFirst") + + local api + local function readAll() + print("Reading all...") + + return api:readAll() + :andThen(function(instances) + local visited = {} + for id, instance in pairs(instances) do + visited[id] = true + if id ~= "0" then + local existing = created[id] + if existing ~= nil then + pcall(existing.Destroy, existing) + end + + local real = Instance.new(instance.className) + real.Name = instance.name + + for key, value in pairs(instance.properties) do + real[key] = value + end + + created[id] = real + end + end + + for id, instance in pairs(instances) do + if id ~= "0" then + created[id].Parent = created[tostring(instance.parent)] + end + end + + for id, object in pairs(created) do + if not visited[id] then + object:Destroy() + end + end + end) + end + + api = ApiContext.new(REMOTE_URL, function(message) + if message.type == "InstanceChanged" then + print("Instance", message.id, "changed!") + readAll() + else + warn("Unknown message type " .. message.type) + end + end) + + api:connect() + :andThen(readAll) + :andThen(function() + return api:retrieveMessages() + end) + + return self +end + +return Session