rojo/plugin/src/App/init.lua
Joe 8b4573b578 Refactor logging level from trace to debug for performance improvement
- Updated logging statements in various functions across the ApiContext, ServeSession, and Reconciler modules to use `Log.debug` instead of `Log.trace`.
- This change aims to reduce the verbosity of logs and improve performance by limiting the amount of logged information during execution, while still retaining essential timing information for debugging purposes.
2026-03-01 16:46:19 -06:00

1966 lines
54 KiB
Lua

local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Players = game:GetService("Players")
local SerializationService = game:GetService("SerializationService")
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",
}
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Promise = require(Packages.Promise)
local Assets = require(Plugin.Assets)
local Version = require(Plugin.Version)
local Config = require(Plugin.Config)
local Settings = require(Plugin.Settings)
local strict = require(Plugin.strict)
local Dictionary = require(Plugin.Dictionary)
local ServeSession = require(Plugin.ServeSession)
local ApiContext = require(Plugin.ApiContext)
local McpStream = require(Plugin.McpStream)
local McpTools = require(Plugin.McpTools)
local PatchSet = require(Plugin.PatchSet)
local PatchTree = require(Plugin.PatchTree)
local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer)
local ignorePlaceIds = require(Plugin.ignorePlaceIds)
local timeUtil = require(Plugin.timeUtil)
local Theme = require(script.Theme)
local Http = require(Packages.Http)
local encodeService = require(Plugin.ChangeBatcher.encodeService)
local encodeProperty = require(Plugin.ChangeBatcher.encodeProperty)
local SHA1 = require(Plugin.SHA1)
local RbxDom = require(Packages.RbxDom)
local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty)
local trueEquals = require(Plugin.Reconciler.trueEquals)
local Page = require(script.Page)
local Notifications = require(script.Components.Notifications)
local TextButton = require(script.Components.TextButton)
local Tooltip = require(script.Components.Tooltip)
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
local StudioToolbar = require(script.Components.Studio.StudioToolbar)
local StudioToggleButton = require(script.Components.Studio.StudioToggleButton)
local StudioPluginGui = require(script.Components.Studio.StudioPluginGui)
local StudioPluginContext = require(script.Components.Studio.StudioPluginContext)
local StatusPages = require(script.StatusPages)
local AppStatus = strict("AppStatus", {
NotConnected = "NotConnected",
Settings = "Settings",
Connecting = "Connecting",
Confirming = "Confirming",
Connected = "Connected",
Error = "Error",
})
local e = Roact.createElement
local App = Roact.Component:extend("App")
function App:init()
preloadAssets()
local priorSyncInfo = self:getPriorSyncInfo()
self.host, self.setHost = Roact.createBinding(priorSyncInfo.host or "")
self.port, self.setPort = Roact.createBinding(priorSyncInfo.port or "")
self.confirmationBindable = Instance.new("BindableEvent")
self.confirmationEvent = self.confirmationBindable.Event
self.knownProjects = {}
self.notifId = 0
self.waypointConnection = ChangeHistoryService.OnUndo:Connect(function(action: string)
if not string.find(action, "^Atlas: Patch") then
return
end
local undoConnection, redoConnection = nil, nil
local function cleanup()
undoConnection:Disconnect()
redoConnection:Disconnect()
end
Log.warn(
string.format(
"You've undone '%s'.\nIf this was not intended, please Redo in the topbar or with Ctrl/⌘+Y.",
action
)
)
local dismissNotif = self:addNotification({
text = string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
timeout = 10,
onClose = function()
cleanup()
end,
actions = {
Restore = {
text = "Restore",
style = "Solid",
layoutOrder = 1,
onClick = function()
ChangeHistoryService:Redo()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
},
},
})
undoConnection = ChangeHistoryService.OnUndo:Once(function()
-- Our notif is now out of date- redoing will not restore the patch
-- since we've undone even further. Dismiss the notif.
cleanup()
if dismissNotif then
dismissNotif()
end
end)
redoConnection = ChangeHistoryService.OnRedo:Once(function(redoneAction: string)
if redoneAction == action then
-- The user has restored the patch, so we can dismiss the notif
cleanup()
if dismissNotif then
dismissNotif()
end
end
end)
end)
self.disconnectUpdatesCheckChanged = Settings:onChanged("checkForUpdates", function()
self:checkForUpdates()
end)
self.disconnectPrereleasesCheckChanged = Settings:onChanged("checkForPrereleases", function()
self:checkForUpdates()
end)
self:setState({
appStatus = AppStatus.NotConnected,
guiEnabled = false,
confirmData = {},
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
notifications = {},
toolbarIcon = Assets.Images.PluginButton,
})
if RunService:IsEdit() then
self:checkForUpdates()
self:startSyncReminderPolling()
self.disconnectSyncReminderPollingChanged = Settings:onChanged("syncReminderPolling", function(enabled)
if enabled then
self:startSyncReminderPolling()
else
self:stopSyncReminderPolling()
end
end)
self:tryAutoReconnect():andThen(function(didReconnect)
if not didReconnect then
self:checkSyncReminder()
end
end)
end
if self:isAutoConnectPlaytestServerAvailable() then
self:useRunningConnectionInfo()
self:startSession()
end
self.autoConnectPlaytestServerListener = Settings:onChanged("autoConnectPlaytestServer", function(enabled)
if enabled then
if self:isAutoConnectPlaytestServerWriteable() and self.serveSession ~= nil then
-- Write the existing session
local baseUrl = self.serveSession.__apiContext.__baseUrl
self:setRunningConnectionInfo(baseUrl)
end
else
self:clearRunningConnectionInfo()
end
end)
if RunService:IsEdit() then
self.mcpStream = McpStream.new({
onSyncCommand = function(requestId, mode, overrides)
return self:startMcpSync(requestId, mode, overrides)
end,
onGetScriptCommand = function(requestId, data)
return self:handleMcpGetScript(requestId, data)
end,
onToolCommand = function(requestId, toolType, args)
return Promise.new(function(resolve)
local ok, result = pcall(McpTools.dispatch, toolType, args)
if ok then
resolve({
requestId = requestId,
status = "success",
response = result or "",
})
else
resolve({
requestId = requestId,
status = "error",
response = tostring(result),
})
end
end)
end,
getPluginConfig = function(key)
return Settings:get(key)
end,
})
self.mcpStream:start()
end
end
function App:willUnmount()
self:endSession()
self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy()
self.disconnectUpdatesCheckChanged()
self.disconnectPrereleasesCheckChanged()
if self.disconnectSyncReminderPollingChanged then
self.disconnectSyncReminderPollingChanged()
end
self:stopSyncReminderPolling()
self.autoConnectPlaytestServerListener()
self:clearRunningConnectionInfo()
if self.mcpStream then
self.mcpStream:stop()
self.mcpStream = nil
self._lastMcpFromIds = nil
end
end
function App:addNotification(notif: {
text: string,
isFullscreen: boolean?,
timeout: number?,
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> ()? } }?,
onClose: (any) -> ()?,
})
if not Settings:get("showNotifications") then
return
end
self.notifId += 1
local id = self.notifId
self:setState(function(prevState)
local notifications = table.clone(prevState.notifications)
notifications[id] = Dictionary.merge({
timeout = notif.timeout or 5,
isFullscreen = notif.isFullscreen or false,
}, notif)
return {
notifications = notifications,
}
end)
return function()
self:closeNotification(id)
end
end
function App:closeNotification(id: number)
if not self.state.notifications[id] then
return
end
self:setState(function(prevState)
local notifications = table.clone(prevState.notifications)
notifications[id] = nil
return {
notifications = notifications,
}
end)
end
function App:checkForUpdates()
local updateMessage = Version.getUpdateMessage()
if updateMessage then
self:addNotification({
text = updateMessage,
timeout = 500,
actions = {
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
},
},
})
end
end
function App:getPriorSyncInfo(): { host: string?, port: string?, projectName: string?, timestamp: number? }
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
return {}
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then
return {}
end
return priorSyncInfos[id] or {}
end
function App:setPriorSyncInfo(host: string, port: string, projectName: string)
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
priorSyncInfos = {}
end
local now = os.time()
-- Clear any stale saves to avoid disc bloat
for placeId, syncInfo in priorSyncInfos do
if now - (syncInfo.timestamp or now) > 12_960_000 then
priorSyncInfos[placeId] = nil
Log.trace("Cleared stale saved endpoint for {}", placeId)
end
end
local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then
return
end
priorSyncInfos[id] = {
host = if host ~= Config.defaultHost then host else nil,
port = if port ~= Config.defaultPort then port else nil,
projectName = projectName,
timestamp = now,
}
Log.trace("Saved last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorSyncInfos)
end
function App:forgetPriorSyncInfo()
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
priorSyncInfos = {}
end
local id = tostring(game.PlaceId)
priorSyncInfos[id] = nil
Log.trace("Erased last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorSyncInfos)
end
function App:getHostAndPort()
local host = self.host:getValue()
local port = self.port:getValue()
return if #host > 0 then host else Config.defaultHost, if #port > 0 then port else Config.defaultPort
end
function App:isSyncLockAvailable()
if #Players:GetPlayers() == 0 then
-- Team Create is not active, so no one can be holding the lock
return true
end
local lock = ServerStorage:FindFirstChild("__Atlas_SessionLock")
if not lock then
-- No lock is made yet, so it is available
return true
end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
-- Someone else is holding the lock
return false, lock.Value
end
-- The lock exists, but is not claimed
return true
end
function App:claimSyncLock()
if #Players:GetPlayers() == 0 then
Log.trace("Skipping sync lock because this isn't in Team Create")
return true
end
local isAvailable, priorOwner = self:isSyncLockAvailable()
if not isAvailable then
Log.trace("Skipping sync lock because it is already claimed")
return false, priorOwner
end
local lock = ServerStorage:FindFirstChild("__Atlas_SessionLock")
if not lock then
lock = Instance.new("ObjectValue")
lock.Name = "__Atlas_SessionLock"
lock.Archivable = false
lock.Value = Players.LocalPlayer
lock.Parent = ServerStorage
Log.trace("Created and claimed sync lock")
return true
end
lock.Value = Players.LocalPlayer
Log.trace("Claimed existing sync lock")
return true
end
function App:releaseSyncLock()
local lock = ServerStorage:FindFirstChild("__Atlas_SessionLock")
if not lock then
Log.trace("No sync lock found, assumed released")
return
end
if lock.Value == Players.LocalPlayer then
lock.Value = nil
Log.trace("Released sync lock")
return
end
Log.trace("Could not relase sync lock because it is owned by {}", lock.Value)
end
function App:findActiveServer()
local host, port = self:getHostAndPort()
local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port)
Log.trace("Checking for active sync server at {}", baseUrl)
local apiContext = ApiContext.new(baseUrl)
return apiContext:connect():andThen(function(serverInfo)
apiContext:disconnect()
return serverInfo, host, port
end)
end
function App:tryAutoReconnect()
if not Settings:get("autoReconnect") then
return Promise.resolve(false)
end
local priorSyncInfo = self:getPriorSyncInfo()
if not priorSyncInfo.projectName then
Log.trace("No prior sync info found, skipping auto-reconnect")
return Promise.resolve(false)
end
return self:findActiveServer()
:andThen(function(serverInfo)
-- change
if serverInfo.projectName == priorSyncInfo.projectName then
Log.trace("Auto-reconnect found matching server, reconnecting...")
self:addNotification({
text = `Auto-reconnect discovered project '{serverInfo.projectName}'...`,
})
self:startSession()
return true
end
Log.trace("Auto-reconnect found different server, not reconnecting")
return false
end)
:catch(function()
Log.trace("Auto-reconnect did not find a server, not reconnecting")
return false
end)
end
function App:checkSyncReminder()
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
if self.serveSession ~= nil or not self:isSyncLockAvailable() then
-- Already syncing or cannot sync, no reason to remind
return
end
local priorSyncInfo = self:getPriorSyncInfo()
self:findActiveServer()
:andThen(function(serverInfo, host, port)
self:sendSyncReminder(
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`,
{ "Connect", "Dismiss" }
)
end)
:catch(function()
if priorSyncInfo.timestamp and priorSyncInfo.projectName then
-- We didn't find an active server,
-- but this place has a prior sync
-- so we should remind the user to serve
local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp)
self:sendSyncReminder(
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'atlas serve' and then connect?`,
{ "Connect", "Forget", "Dismiss" }
)
end
end)
end
function App:startSyncReminderPolling()
if
self.syncReminderPollingThread ~= nil
or Settings:get("syncReminderMode") == "None"
or not Settings:get("syncReminderPolling")
then
return
end
Log.trace("Starting sync reminder polling thread")
self.syncReminderPollingThread = task.spawn(function()
while task.wait(30) do
if self.syncReminderPollingThread == nil then
-- The polling thread was stopped, so exit
return
end
if self.dismissSyncReminder then
-- There is already a sync reminder being shown
task.wait(5)
continue
end
self:checkSyncReminder()
end
end)
end
function App:stopSyncReminderPolling()
if self.syncReminderPollingThread then
Log.trace("Stopping sync reminder polling thread")
task.cancel(self.syncReminderPollingThread)
self.syncReminderPollingThread = nil
end
end
function App:sendSyncReminder(message: string, shownActions: { string })
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
local connectIndex = table.find(shownActions, "Connect")
local forgetIndex = table.find(shownActions, "Forget")
local dismissIndex = table.find(shownActions, "Dismiss")
self.dismissSyncReminder = self:addNotification({
text = message,
timeout = 120,
isFullscreen = Settings:get("syncReminderMode") == "Fullscreen",
onClose = function()
self.dismissSyncReminder = nil
end,
actions = {
Connect = if connectIndex
then {
text = "Connect",
style = "Solid",
layoutOrder = connectIndex,
onClick = function()
self:startSession()
end,
}
else nil,
Forget = if forgetIndex
then {
text = "Forget",
style = "Bordered",
layoutOrder = forgetIndex,
onClick = function()
-- The user doesn't want to be reminded again about this sync
self:forgetPriorSyncInfo()
end,
}
else nil,
Dismiss = if dismissIndex
then {
text = "Dismiss",
style = "Bordered",
layoutOrder = dismissIndex,
onClick = function()
-- If the user dismisses the reminder,
-- then we don't need to remind them again
self:stopSyncReminderPolling()
end,
}
else nil,
},
})
end
function App:isAutoConnectPlaytestServerAvailable()
return RunService:IsRunning()
and RunService:IsStudio()
and RunService:IsServer()
and Settings:get("autoConnectPlaytestServer")
and workspace:GetAttribute("__Atlas_ConnectionUrl")
end
function App:isAutoConnectPlaytestServerWriteable()
return RunService:IsEdit() and Settings:get("autoConnectPlaytestServer")
end
function App:setRunningConnectionInfo(baseUrl: string)
if not self:isAutoConnectPlaytestServerWriteable() then
return
end
Log.trace("Setting connection info for play solo auto-connect")
workspace:SetAttribute("__Atlas_ConnectionUrl", baseUrl)
end
function App:clearRunningConnectionInfo()
if not RunService:IsEdit() then
-- Only write connection info from edit mode
return
end
Log.trace("Clearing connection info for play solo auto-connect")
workspace:SetAttribute("__Atlas_ConnectionUrl", nil)
end
function App:useRunningConnectionInfo()
local connectionInfo = workspace:GetAttribute("__Atlas_ConnectionUrl")
if not connectionInfo then
return
end
Log.trace("Using connection info for play solo auto-connect")
local host, port = string.match(connectionInfo, "^(.+):(.-)$")
self.setHost(host)
self.setPort(port)
end
function App:performSyncback()
self:setState({ showingSyncbackConfirm = false })
local host = self.host:getValue()
local port = self.port:getValue()
if host == "" then
host = Config.defaultHost
end
if port == "" then
port = Config.defaultPort
end
local services = {}
local allChildren = {}
local allCarriers = {}
for _, className in SYNCBACK_SERVICES do
local ok, service = pcall(game.FindService, game, className)
if ok and service then
local chunk, refTargets = encodeService(service)
for _, child in service:GetChildren() do
table.insert(allChildren, child)
end
for _, carrier in refTargets do
table.insert(allChildren, carrier)
table.insert(allCarriers, carrier)
end
table.insert(services, chunk)
end
end
local data = buffer.create(0)
if #allChildren > 0 then
data = SerializationService:SerializeInstancesAsync(allChildren)
end
for _, carrier in allCarriers do
carrier:Destroy()
end
local url = ("http://%s:%s/api/syncback"):format(host, port)
local body = Http.msgpackEncode({
protocolVersion = Config.protocolVersion,
serverVersion = Config.expectedServerVersionString,
placeId = game.PlaceId,
data = data,
services = services,
})
Http.post(url, body)
:andThen(function()
Log.info("Syncback data sent to server.")
self:addNotification({
text = "Syncback data sent. Server is processing.",
timeout = 10,
})
end)
:catch(function(err)
Log.warn("Syncback failed: " .. tostring(err))
self:addNotification({
text = "Syncback failed: " .. tostring(err),
timeout = 10,
})
end)
end
function App:startSession()
-- If a session is already in progress, tear it down first.
-- In one-shot mode the sync lock is skipped, so this is the only guard
-- against overlapping sessions (e.g., auto-reconnect racing with a
-- manual connect).
if self.serveSession ~= nil then
Log.trace("Ending existing session before starting a new one")
self:endSession()
end
-- Skip session lock in one-shot mode since it's a quick sync-and-disconnect
if not Settings:get("oneShotSync") then
local claimedLock, priorOwner = self:claimSyncLock()
if not claimedLock then
local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner))
Log.warn(msg)
self:addNotification({
text = msg,
timeout = 10,
})
self:setState({
appStatus = AppStatus.Error,
errorMessage = msg,
toolbarIcon = Assets.Images.PluginButtonWarning,
})
return
end
end
local host, port = self:getHostAndPort()
local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port)
local apiContext = ApiContext.new(baseUrl)
local serveSession = ServeSession.new({
apiContext = apiContext,
twoWaySync = Settings:get("twoWaySync"),
})
serveSession:setUpdateLoadingTextCallback(function(text: string)
self:setState({
connectingText = text,
})
end)
local cachedServerInfo = nil
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }, gitMetadata),
})
end)
self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
local now = DateTime.now().UnixTimestamp
self:setState(function(prevState)
local oldPatchData = prevState.patchData
local newPatchData = {
patch = patch,
unapplied = unappliedPatch,
timestamp = now,
}
if PatchSet.isEmpty(patch) then
-- Keep existing patch info, but use new timestamp
newPatchData.patch = oldPatchData.patch
newPatchData.unapplied = oldPatchData.unapplied
elseif now - oldPatchData.timestamp < 2 then
-- Patches that apply in the same second are combined for human clarity
newPatchData.patch = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.patch, patch)
newPatchData.unapplied = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.unapplied, unappliedPatch)
end
return {
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
patchData = newPatchData,
}
end)
end)
serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then
if self.dismissSyncReminder then
self.dismissSyncReminder()
self.dismissSyncReminder = nil
end
self:setState({
appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification({
text = "Connecting to session...",
})
elseif status == ServeSession.Status.Connected then
self.knownProjects[details] = true
self:setPriorSyncInfo(host, port, details)
self:setRunningConnectionInfo(baseUrl)
local address = ("%s:%s"):format(host, port)
if Settings:get("oneShotSync") then
-- One-shot mode: Don't show Connected page since we're about to disconnect.
-- Show a loading state while the patch applies and writes complete.
self:setState({
appStatus = AppStatus.Connecting,
connectingText = "Completing sync...",
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification({
text = string.format("Synced with '%s'. Completing...", details),
})
-- Safety: if endSession hasn't fired within 30s, force disconnect.
-- This catches cases where the promise chain silently breaks
-- (e.g., write request hangs, promise never resolves).
-- Capture the current session so we only kill THIS session, not a
-- different one that may have replaced it (e.g., auto-reconnect race).
local timeoutSession = serveSession
task.delay(30, function()
if
self.serveSession ~= nil
and self.serveSession == timeoutSession
and Settings:get("oneShotSync")
then
Log.warn("One-shot sync safety timeout: forcing disconnect")
self:endSession()
end
end)
else
self:setState({
appStatus = AppStatus.Connected,
projectName = details,
address = address,
toolbarIcon = Assets.Images.PluginButtonConnected,
})
self:addNotification({
text = string.format("Connected to session '%s' at %s.", details, address),
})
end
elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil
-- Only release lock if we claimed it (not in one-shot mode)
if not Settings:get("oneShotSync") then
self:releaseSyncLock()
end
self:clearRunningConnectionInfo()
self:setState({
patchData = {
patch = PatchSet.newEmpty(),
unapplied = PatchSet.newEmpty(),
timestamp = os.time(),
},
})
-- Details being present indicates that this
-- disconnection was from an error.
if details ~= nil then
Log.warn("Disconnected from an error: {}", details)
self:setState({
appStatus = AppStatus.Error,
errorMessage = tostring(details),
toolbarIcon = Assets.Images.PluginButtonWarning,
})
self:addNotification({
text = tostring(details),
timeout = 10,
})
else
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification({
text = "Disconnected from session.",
timeout = 10,
})
end
end
end)
local initialSyncConfirmed = false
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
cachedServerInfo = serverInfo
-- Filter out the DataModel name change from the patch
-- The project name (DataModel.Name) is managed by Studio independently
PatchSet.removeDataModelName(patch, instanceMap)
local isOneShotMode = Settings:get("oneShotSync")
-- ONE-SHOT MODE: After the initial sync is confirmed, skip any
-- subsequent patches entirely. These are server echoes from processing
-- our pull request (e.g. VFS re-snapshots after file deletions).
-- The session is about to disconnect — applying them is pointless,
-- and without this guard the echo triggers a second confirmation
-- dialog that races with endSession() and gets stuck.
if isOneShotMode and initialSyncConfirmed then
Log.info("One-shot mode: skipping post-initial-sync echo patch")
return "Skip"
end
-- ONE-SHOT MODE: Never auto-accept, always require explicit confirmation
-- This ensures nothing can "sneak through" without user review
if not isOneShotMode then
if PatchSet.isEmpty(patch) then
Log.trace("Accepting patch without confirmation because it is empty")
return "Accept"
end
-- Play solo auto-connect does not require confirmation
if self:isAutoConnectPlaytestServerAvailable() then
Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled")
return "Accept"
end
local confirmationBehavior = Settings:get("confirmationBehavior")
if confirmationBehavior ~= "Always" then
if confirmationBehavior == "Initial" then
-- Only confirm if we haven't synced this project yet this session
if self.knownProjects[serverInfo.projectName] then
Log.trace(
"Accepting patch without confirmation because project has already been connected and behavior is set to Initial"
)
return "Accept"
end
elseif confirmationBehavior == "Large Changes" then
-- Only confirm if the patch impacts many instances
if PatchSet.countInstances(patch) < Settings:get("largeChangesConfirmationThreshold") then
Log.trace(
"Accepting patch without confirmation because patch is small and behavior is set to Large Changes"
)
return "Accept"
end
elseif confirmationBehavior == "Unlisted PlaceId" then
-- Only confirm if the current placeId is not in the servePlaceIds allowlist
if serverInfo.expectedPlaceIds then
local isListed = table.find(serverInfo.expectedPlaceIds, game.PlaceId) ~= nil
if isListed then
Log.trace(
"Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId"
)
return "Accept"
end
end
elseif confirmationBehavior == "Never" then
Log.trace("Accepting patch without confirmation because behavior is set to Never")
return "Accept"
end
end
else
Log.info("One-shot mode: skipping all auto-accept paths, requiring explicit confirmation")
end
self:setState({
connectingText = "Computing diff view...",
})
local patchTreeClock = os.clock()
Log.debug("[TIMING] PatchTree.build() starting")
local patchTree =
PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, serverInfo.gitMetadata)
Log.debug(
"[TIMING] PatchTree.build() completed ({} ms)",
string.format("%.1f", (os.clock() - patchTreeClock) * 1000)
)
self:setState({
appStatus = AppStatus.Confirming,
patchTree = patchTree,
confirmData = {
serverInfo = serverInfo,
},
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification({
text = "Please review and confirm the sync changes, or disconnect.",
timeout = 7,
})
Log.debug("[TIMING] Waiting for user to confirm/reject patch...")
local result = self.confirmationEvent:Wait()
-- One-shot sync: Don't transition to Connected UI
-- The actual disconnect is handled by setInitialSyncCompleteCallback
-- to ensure any pending writes complete first
if Settings:get("oneShotSync") then
initialSyncConfirmed = true
return result
end
-- Reset UI state back to Connected after confirmation
-- This is needed for ongoing WebSocket patches where the session
-- is already connected and won't trigger a status change
if self.serveSession and self.serveSession:getStatus() == ServeSession.Status.Connected then
self:setState({
appStatus = AppStatus.Connected,
toolbarIcon = Assets.Images.PluginButtonConnected,
-- Clear patchTree to avoid animation issues when the
-- PatchVisualizer unmounts while Flipper motors are running
patchTree = nil,
})
end
return result
end)
local pendingGitRefreshThread = nil
local GIT_METADATA_DEBOUNCE = 0.5
serveSession:setPatchUpdateCallback(function(instanceMap, patch, changedIds)
-- If all changes have been reverted, auto-accept the empty patch
if PatchSet.isEmpty(patch) then
Log.trace("Patch became empty after merging, auto-accepting")
-- Return empty selections to accept all (nothing to do)
self.confirmationBindable:Fire({ type = "Confirm", selections = {} })
return
end
-- Immediately rebuild with stale metadata so the UI reflects new items
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata),
changedIds = changedIds,
})
-- Debounce: schedule a fresh git metadata fetch after rapid patches settle
if pendingGitRefreshThread then
task.cancel(pendingGitRefreshThread)
end
pendingGitRefreshThread = task.delay(GIT_METADATA_DEBOUNCE, function()
pendingGitRefreshThread = nil
apiContext
:getGitMetadata()
:andThen(function(freshMetadata)
if cachedServerInfo then
cachedServerInfo.gitMetadata = freshMetadata
end
if self.state.appStatus == AppStatus.Confirming then
self:setState({
patchTree = PatchTree.build(
patch,
instanceMap,
{ "Property", "Current", "Incoming" },
freshMetadata
),
})
end
end)
:catch(function(err)
Log.warn("Failed to refresh git metadata: {}", tostring(err))
end)
end)
end)
-- One-shot sync: disconnect after initial sync is fully complete (including any writes)
-- This ensures that "pull" changes are sent to the server before disconnecting
serveSession:setInitialSyncCompleteCallback(function()
if Settings:get("oneShotSync") and self.serveSession then
Log.info("One-shot sync: disconnecting after initial sync complete")
self:endSession()
end
end)
serveSession:start()
self.serveSession = serveSession
end
function App:endSession()
if self.serveSession == nil then
-- Safety: if the session reference was cleared (e.g., by a Disconnected handler)
-- but the UI is still stuck on Connected/Connecting, force reset to NotConnected.
-- This handles race conditions where onStatusChanged(Connected) fires after
-- onStatusChanged(Disconnected) due to a coroutine resuming on a dead session.
if
self.state.appStatus ~= AppStatus.NotConnected
and self.state.appStatus ~= AppStatus.Error
and self.state.appStatus ~= AppStatus.Settings
then
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
})
end
return
end
Log.trace("Disconnecting session")
self.serveSession:stop()
self.serveSession = nil
self:setState({
appStatus = AppStatus.NotConnected,
})
if self.cleanupPrecommit ~= nil then
self.cleanupPrecommit()
end
if self.cleanupPostcommit ~= nil then
self.cleanupPostcommit()
end
Log.trace("Session terminated by user")
end
function App:startMcpSync(requestId, mode, overrides)
mode = mode or "standard"
overrides = overrides or {}
return Promise.new(function(resolve, _reject)
if self.state.appStatus == AppStatus.Connected then
resolve({
requestId = requestId,
status = "already_connected",
changes = {},
message = "Atlas is already connected in live sync mode. All changes sync automatically.",
})
return
end
if self.state.appStatus == AppStatus.Connecting or self.state.appStatus == AppStatus.Confirming then
resolve({
requestId = requestId,
status = "sync_in_progress",
changes = {},
message = "A sync operation is already in progress.",
})
return
end
if self.serveSession ~= nil then
Log.trace("MCP sync: ending existing session before starting")
self:endSession()
end
local host = Config.defaultHost
local port = Config.defaultPort
local baseUrl = ("http://%s:%s"):format(host, port)
local apiContext = ApiContext.new(baseUrl)
local serveSession = ServeSession.new({
apiContext = apiContext,
twoWaySync = false,
})
local _mcpPatchTree = nil
local mcpSyncResolved = false
local function resolveOnce(result)
if mcpSyncResolved then
return
end
mcpSyncResolved = true
result.requestId = requestId
resolve(result)
end
serveSession:setUpdateLoadingTextCallback(function(text: string)
self:setState({
connectingText = text,
})
end)
local _cachedServerInfo = nil
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
_cachedServerInfo = serverInfo
local retainedFromIds = table.clone(instanceMap.fromIds)
self._lastMcpFromIds = retainedFromIds
PatchSet.removeDataModelName(patch, instanceMap)
if PatchSet.isEmpty(patch) then
Log.info("MCP sync: no changes to sync")
resolveOnce({
status = "empty",
changes = {},
})
return "Accept"
end
local gitMetadata = serverInfo.gitMetadata
local patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata)
_mcpPatchTree = patchTree
-- Apply overrides: look up each override by id in the tree, verify, set selection
local overrideSelections = {}
for _, override in overrides do
local node = patchTree:getNode(override.id)
if not node or not node.patchType then
Log.trace("MCP sync: override id {} not found in patch tree, skipping", override.id)
continue
end
local verified = true
if override.studioHash and node.instance and node.instance:IsA("LuaSourceContainer") then
local source = node.instance.Source
local gitBlob = "blob " .. tostring(#source) .. "\0" .. source
local currentHash = SHA1(buffer.fromstring(gitBlob))
if currentHash ~= override.studioHash then
Log.info(
"MCP sync: override {} studioHash mismatch (expected={}, got={})",
override.id,
override.studioHash,
currentHash
)
verified = false
end
end
if verified and override.expectedProperties and node.instance then
for propName, expectedEncoded in override.expectedProperties do
local decodeOk, expectedValue = decodeValue(expectedEncoded, instanceMap)
if not decodeOk then
Log.info(
"MCP sync: override {} could not decode expected value for {}",
override.id,
propName
)
verified = false
break
end
local readOk, currentValue = getProperty(node.instance, propName)
if not readOk then
Log.info("MCP sync: override {} could not read property {}", override.id, propName)
verified = false
break
end
if not trueEquals(expectedValue, currentValue) then
Log.info("MCP sync: override {} property {} mismatch", override.id, propName)
verified = false
break
end
end
end
if verified then
overrideSelections[override.id] = override.direction
end
end
-- Compute resolution state: check defaults + overrides
local allResolved = true
local hasSelectableNodes = false
patchTree:forEach(function(node)
if node.patchType then
hasSelectableNodes = true
if node.defaultSelection == nil and overrideSelections[node.id] == nil then
allResolved = false
end
end
end)
if not hasSelectableNodes then
Log.info("MCP sync: no selectable changes, auto-accepting")
resolveOnce({
status = "empty",
changes = {},
})
return "Accept"
end
-- DRYRUN: return full change list without applying
if mode == "dryrun" then
Log.info("MCP sync: dryrun mode, returning changes without applying")
local changes = self:_buildMcpChangeList(patchTree, patch, instanceMap, nil, true)
resolveOnce({
status = "dryrun",
changes = changes,
})
return "Abort"
end
-- FASTFAIL: if unresolved changes remain, fail immediately
if mode == "fastfail" and not allResolved then
Log.info("MCP sync: fastfail mode, unresolved changes exist")
local changes = self:_buildMcpChangeList(patchTree, patch, instanceMap, nil, true)
resolveOnce({
status = "fastfail_unresolved",
changes = changes,
message = "Unresolved changes exist. Provide overrides or use standard mode for user review.",
})
return "Abort"
end
-- Merge overrides into default selections
local selections = PatchTree.buildInitialSelections(patchTree)
for nodeId, direction in overrideSelections do
selections[nodeId] = direction
end
-- Check if all nodes are now resolved (defaults + overrides)
local allPreSelected = true
patchTree:forEach(function(node)
if node.patchType and selections[node.id] == nil then
allPreSelected = false
end
end)
-- MANUAL: always show UI
if mode == "manual" then
allPreSelected = false
end
if allPreSelected then
Log.info("MCP sync: all changes resolved, fast-forwarding")
local changes = self:_buildMcpChangeList(patchTree, patch, instanceMap, selections, false)
local autoSelectedIds = {}
patchTree:forEach(function(node)
if node.patchType and selections[node.id] then
autoSelectedIds[node.id] = true
end
end)
resolveOnce({
status = "success",
changes = changes,
})
return {
type = "Confirm",
selections = selections,
autoSelectedIds = autoSelectedIds,
}
end
Log.info("MCP sync: changes require user review, showing confirmation UI")
self:setState({
appStatus = AppStatus.Confirming,
patchTree = patchTree,
confirmData = {
serverInfo = serverInfo,
},
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification({
text = "An AI agent requested a sync. Please review and confirm the changes.",
timeout = 10,
})
local result = self.confirmationEvent:Wait()
if result == "Abort" then
local changes = self:_buildMcpChangeList(patchTree, patch, instanceMap, nil, true)
resolveOnce({
status = "rejected",
changes = changes,
message = "User rejected the sync changes.",
})
return result
end
if type(result) == "table" and result.type == "Confirm" then
local changes = self:_buildMcpChangeList(patchTree, patch, instanceMap, result.selections, false)
resolveOnce({
status = "success",
changes = changes,
})
return result
end
resolveOnce({
status = "success",
changes = {},
})
return result
end)
serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then
self:setState({
appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton,
})
elseif status == ServeSession.Status.Connected then
self:setState({
appStatus = AppStatus.Connecting,
connectingText = "Completing sync...",
toolbarIcon = Assets.Images.PluginButton,
})
elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
patchTree = nil,
})
if details ~= nil then
Log.warn("MCP sync disconnected with error: {}", details)
resolveOnce({
status = "error",
changes = {},
message = tostring(details),
})
end
end
end)
serveSession:setInitialSyncCompleteCallback(function()
Log.info("MCP sync: initial sync complete, disconnecting")
if self.serveSession and self.serveSession == serveSession then
self:endSession()
end
end)
serveSession:start()
self.serveSession = serveSession
end)
end
local SCRIPT_CLASS_SET = {
Script = true,
LocalScript = true,
ModuleScript = true,
}
function App:_buildMcpChangeList(patchTree, patch, _instanceMap, selections, includeAll)
local changes = {}
local patchUpdatedLookup = nil
if patch and patch.updated then
patchUpdatedLookup = {}
for _, change in patch.updated do
patchUpdatedLookup[change.id] = change
end
end
patchTree:forEach(function(node)
if not node.patchType then
return
end
local direction = nil
if includeAll then
if selections and selections[node.id] then
direction = selections[node.id]
elseif node.defaultSelection then
direction = node.defaultSelection
end
elseif selections then
local sel = selections[node.id]
if sel == "push" then
direction = "push"
elseif sel == "pull" then
direction = "pull"
else
return
end
else
direction = node.defaultSelection or "push"
end
local segments = {}
local current = node
while current and current.id ~= "ROOT" do
table.insert(segments, 1, current.name or current.id)
if current.parentId then
current = patchTree:getNode(current.parentId)
else
break
end
end
local path = table.concat(segments, "/")
local entry: any = {
path = path,
direction = direction or "unresolved",
id = node.id,
className = node.className,
patchType = node.patchType,
defaultSelection = node.defaultSelection,
}
if node.instance and node.instance:IsA("LuaSourceContainer") then
local source = node.instance.Source
local gitBlob = "blob " .. tostring(#source) .. "\0" .. source
entry.studioHash = SHA1(buffer.fromstring(gitBlob))
end
if node.patchType == "Edit" and patchUpdatedLookup and node.instance then
local patchChange = patchUpdatedLookup[node.id]
if patchChange and patchChange.changedProperties then
local props = {}
local isScript = SCRIPT_CLASS_SET[node.className]
for propName, incomingEncoded in patchChange.changedProperties do
if isScript and propName == "Source" then
continue
end
local descriptor = RbxDom.findCanonicalPropertyDescriptor(node.className, propName)
if not descriptor then
continue
end
local currentOk, currentEncoded = encodeProperty(node.instance, propName, descriptor)
if not currentOk then
continue
end
props[propName] = {
current = currentEncoded,
incoming = incomingEncoded,
}
end
if next(props) then
entry.properties = props
end
end
end
table.insert(changes, entry)
end)
return changes
end
function App:handleMcpGetScript(requestId, params)
local ScriptEditorService = game:GetService("ScriptEditorService")
return Promise.resolve():andThen(function()
local fromIds = self._lastMcpFromIds
if not fromIds then
return {
requestId = requestId,
status = "error",
message = 'No previous sync session. Run atlas_sync(mode: "dryrun") first to establish the instance mapping.',
}
end
local id = params.id
if not id then
return {
requestId = requestId,
status = "error",
message = "No id provided. The server should have resolved fsPath to an id.",
}
end
local instance = fromIds[id]
if not instance then
return {
requestId = requestId,
status = "error",
message = "Instance not found by id. The sync session may be stale, run atlas_sync again.",
}
end
if instance.Parent == nil then
return {
requestId = requestId,
status = "error",
message = "Instance was deleted since last sync. Run atlas_sync again.",
}
end
if not instance:IsA("LuaSourceContainer") then
return {
requestId = requestId,
status = "error",
message = `Instance is not a script (class: {instance.ClassName})`,
}
end
local source
local isDraft = params.fromDraft == true
if isDraft then
local ok, draft = pcall(ScriptEditorService.GetEditorSource, ScriptEditorService, instance)
if ok then
source = draft
else
source = instance.Source
isDraft = false
end
else
source = instance.Source
end
local gitBlob = "blob " .. tostring(#source) .. "\0" .. source
local studioHash = SHA1(buffer.fromstring(gitBlob))
local fullName = instance:GetFullName()
local instancePath = string.gsub(fullName, "^game%.", "")
instancePath = string.gsub(instancePath, "%.", "/")
return {
requestId = requestId,
status = "success",
source = source,
studioHash = studioHash,
className = instance.ClassName,
instancePath = instancePath,
isDraft = isDraft,
}
end)
end
function App:render()
local pluginName = "Atlas " .. Version.display(Config.version)
local function createPageElement(appStatus, additionalProps)
additionalProps = additionalProps or {}
local props = Dictionary.merge(additionalProps, {
component = StatusPages[appStatus],
active = self.state.appStatus == appStatus,
})
return e(Page, props)
end
return e(StudioPluginContext.Provider, {
value = self.props.plugin,
}, {
e(Theme.StudioProvider, nil, {
tooltip = e(Tooltip.Provider, nil, {
gui = e(StudioPluginGui, {
id = pluginName,
title = pluginName,
active = self.state.guiEnabled,
isEphemeral = false,
initDockState = Enum.InitialDockState.Right,
overridePreviousState = false,
floatingSize = Vector2.new(320, 210),
minimumSize = Vector2.new(300, 210),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onInitialState = function(initialState)
self:setState({
guiEnabled = initialState,
})
end,
onClose = function()
self:setState({
guiEnabled = false,
})
end,
}, {
Tooltips = e(Tooltip.Container, nil),
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
host = self.host,
onHostChange = self.setHost,
port = self.port,
onPortChange = self.setPort,
onConnect = function()
self:startSession()
end,
onSyncback = function()
self:setState({
showingSyncbackConfirm = true,
})
end,
onNavigateSettings = function()
self.backPage = AppStatus.NotConnected
self:setState({
appStatus = AppStatus.Settings,
})
end,
}),
ConfirmingPage = createPageElement(AppStatus.Confirming, {
confirmData = self.state.confirmData,
patchTree = self.state.patchTree,
changedIds = self.state.changedIds,
createPopup = not self.state.guiEnabled,
onAbort = function()
self.confirmationBindable:Fire("Abort")
end,
onConfirm = function(selections)
local autoSelectedIds = {}
if self.state.patchTree then
self.state.patchTree:forEach(function(node)
if
node.patchType
and node.defaultSelection ~= nil
and selections[node.id] == node.defaultSelection
then
autoSelectedIds[node.id] = true
end
end)
end
self.confirmationBindable:Fire({
type = "Confirm",
selections = selections,
autoSelectedIds = autoSelectedIds,
})
end,
}),
Connecting = createPageElement(AppStatus.Connecting, {
text = self.state.connectingText,
}),
Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName,
address = self.state.address,
patchTree = self.state.patchTree,
patchData = self.state.patchData,
serveSession = self.serveSession,
onDisconnect = function()
self:endSession()
end,
onNavigateSettings = function()
self.backPage = AppStatus.Connected
self:setState({
appStatus = AppStatus.Settings,
})
end,
}),
Settings = createPageElement(AppStatus.Settings, {
syncActive = self.serveSession ~= nil
and self.serveSession:getStatus() == ServeSession.Status.Connected,
onBack = function()
self:setState({
appStatus = self.backPage or AppStatus.NotConnected,
})
end,
}),
Error = createPageElement(AppStatus.Error, {
errorMessage = self.state.errorMessage,
onClose = function()
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
})
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 (BETA) ⚠️",
active = self.state.showingSyncbackConfirm == true,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(400, 250),
minimumSize = Vector2.new(300, 220),
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,
}, {
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.\n\nNote: Live syncback produces slightly different formatting than CLI syncback (atlas syncback). This may cause minor git diffs even when the data is identical. Pick one method and stick with it to avoid unnecessary churn.",
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",
icon = Assets.Images.PluginButton,
bindable = true,
onTriggered = function()
if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then
self:startSession()
elseif
self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected
then
self:endSession()
end
end,
}),
connectAction = e(StudioPluginAction, {
name = "AtlasConnect",
title = "Atlas: Connect",
description = "Connects the server for an Atlas sync session",
icon = Assets.Images.PluginButton,
bindable = true,
onTriggered = function()
if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then
self:startSession()
end
end,
}),
disconnectAction = e(StudioPluginAction, {
name = "AtlasDisconnect",
title = "Atlas: Disconnect",
description = "Disconnects the server for an Atlas sync session",
icon = Assets.Images.PluginButton,
bindable = true,
onTriggered = function()
if self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected then
self:endSession()
end
end,
}),
toolbar = e(StudioToolbar, {
name = pluginName,
}, {
button = e(StudioToggleButton, {
name = "Atlas",
tooltip = "Show or hide the Atlas panel",
icon = self.state.toolbarIcon,
active = self.state.guiEnabled,
enabled = true,
onClick = function()
self:setState(function(state)
return {
guiEnabled = not state.guiEnabled,
}
end)
end,
}),
}),
}),
})
end
return function(props)
local mergedProps = Dictionary.merge(props, {
soundPlayer = soundPlayer.new(Settings),
})
return e(App, mergedProps)
end