Update syncback feature to handle instance pulling from Studio

- Updated the `ServeSession` to implement logic for pulling instances that exist in Studio but not in Rojo's filesystem, creating appropriate files during sync confirmation.
- Introduced a new `encodeInstance` function to serialize Studio instances for transmission, supporting all instance types.
- Modified the `PatchTree` to allow explicit user selection for push/pull/skip actions, enhancing user control during sync operations.
- Added tests for the new encoding functionality and updated existing tests to ensure proper behavior with the new syncback logic.
This commit is contained in:
Joe 2026-01-30 07:18:10 -06:00
parent ace839e46f
commit c7cddb72c2
15 changed files with 3534 additions and 39 deletions

View file

@ -35,7 +35,8 @@ function ConfirmingPage:init()
showingTableDiff = false,
oldTable = {},
newTable = {},
showingRejectConfirm = false,
showingAcceptConfirm = false,
unselectedCount = 0,
selections = initialSelections,
})
@ -64,10 +65,30 @@ function ConfirmingPage:init()
end
function ConfirmingPage:didUpdate(prevProps)
-- If patchTree changed, reinitialize selections
-- If patchTree changed, update selections
if prevProps.patchTree ~= self.props.patchTree and self.props.patchTree then
local initialSelections = PatchTree.buildInitialSelections(self.props.patchTree)
self:setState({ selections = initialSelections })
-- Get the new items that need default selections
local newSelections = PatchTree.buildInitialSelections(self.props.patchTree)
-- Preserve existing selections, but unselect items that changed
local updatedSelections = {}
for id, selection in pairs(self.state.selections) do
-- Preserve selection only if the item wasn't changed
-- (Changed items must be re-reviewed, so leave them unselected)
local wasChanged = self.props.changedIds and self.props.changedIds[id]
if not wasChanged then
updatedSelections[id] = selection
end
end
-- Merge with any new default selections (new items get nil = unselected)
for id, selection in pairs(newSelections) do
if updatedSelections[id] == nil then
updatedSelections[id] = selection
end
end
self:setState({ selections = updatedSelections })
end
end
@ -165,15 +186,28 @@ function ConfirmingPage:render()
end,
}),
Commit = e(TextButton, {
text = "Commit",
Accept = e(TextButton, {
text = "Accept",
style = "Primary",
transparency = self.props.transparency,
layoutOrder = 5,
enabled = allSelected,
onClick = function()
if allSelected and self.props.onConfirm then
if not self.props.onConfirm then
return
end
-- Check if all items are selected
if allSelected then
-- All items selected, proceed immediately
self.props.onConfirm(self.state.selections)
else
-- Some items unselected, show confirmation popup
local unselectedCount =
PatchTree.countUnselected(self.props.patchTree, self.state.selections)
self:setState({
showingAcceptConfirm = true,
unselectedCount = unselectedCount,
})
end
end,
}),
@ -274,6 +308,83 @@ function ConfirmingPage:render()
}),
}),
}),
-- Accept confirmation popup for unselected items
AcceptConfirm = e(StudioPluginGui, {
id = "Rojo_AcceptConfirm",
title = "Unreviewed Items",
active = self.state.showingAcceptConfirm,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(400, 150),
minimumSize = Vector2.new(350, 130),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({ showingAcceptConfirm = false })
end,
}, {
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundColor3 = theme.Background2,
BorderSizePixel = 0,
}, {
Message = e("TextLabel", {
Text = string.format(
"%d item%s not been reviewed.\n\nAccept selected changes and skip the rest?",
self.state.unselectedCount,
self.state.unselectedCount == 1 and " has" or "s have"
),
FontFace = theme.Font.Regular,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextWrapped = true,
TextXAlignment = Enum.TextXAlignment.Center,
Size = UDim2.new(1, -20, 1, -60),
Position = UDim2.new(0, 10, 0, 10),
BackgroundTransparency = 1,
}),
Buttons = e("Frame", {
Size = UDim2.new(1, -20, 0, 34),
Position = UDim2.new(0, 10, 1, -44),
BackgroundTransparency = 1,
}, {
Cancel = e(TextButton, {
text = "Cancel",
style = "Bordered",
transparency = self.props.transparency,
layoutOrder = 1,
onClick = function()
self:setState({ showingAcceptConfirm = false })
end,
}),
AcceptAndSkip = e(TextButton, {
text = "Accept and Skip Rest",
style = "Primary",
transparency = self.props.transparency,
layoutOrder = 2,
onClick = function()
self:setState({ showingAcceptConfirm = false })
if self.props.onConfirm then
self.props.onConfirm(self.state.selections)
end
end,
}),
Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Right,
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
}),
}),
}),
})
if self.props.createPopup then

View file

@ -823,14 +823,7 @@ function App:startSession()
return result
end)
serveSession:setPatchUpdateCallback(function(instanceMap, patch)
-- ONE-SHOT MODE: Ignore all further changes during confirmation
-- They will be picked up on the next connection attempt
if Settings:get("oneShotSync") then
Log.info("One-shot mode: ignoring incoming changes during confirmation")
return
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")
@ -840,8 +833,11 @@ function App:startSession()
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)
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }),
changedIds = changedIds, -- Pass to UI so it can unselect changed items
})
end)
@ -949,6 +945,7 @@ function App:render()
ConfirmingPage = createPageElement(AppStatus.Confirming, {
confirmData = self.state.confirmData,
patchTree = self.state.patchTree,
changedIds = self.state.changedIds,
createPopup = not self.state.guiEnabled,
onAbort = function()