rojo/plugin/src/App/init.lua
Joe 63b0b031ff Optimize syncback process and enhance patch selection logic
- Improved the syncback functionality by detecting unchanged output file hashes to avoid unnecessary writes, optimizing performance for larger projects.
- Updated the patch selection logic to ensure that auto-selection only occurs when the current selection matches the default, enhancing user experience during patch reviews.

These changes aim to streamline file synchronization and improve the clarity of patch management in the application.
2026-03-01 06:43:25 -06:00

1389 lines
39 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 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 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)
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()
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...",
})
self:setState({
appStatus = AppStatus.Confirming,
patchTree = PatchTree.build(
patch,
instanceMap,
{ "Property", "Current", "Incoming" },
serverInfo.gitMetadata
),
confirmData = {
serverInfo = serverInfo,
},
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification({
text = "Please review and confirm the sync changes, or disconnect.",
timeout = 7,
})
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)
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
-- Update the patchTree when new changes arrive during confirmation
-- The changedIds parameter contains IDs of items that were modified
-- so their selections can be reset (user must re-review)
local gitMetadata = cachedServerInfo and cachedServerInfo.gitMetadata
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }, gitMetadata),
changedIds = changedIds, -- Pass to UI so it can unselect changed items
})
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: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