one-shot + new commit dialogue

This commit is contained in:
Joe 2026-01-27 06:34:40 -06:00
parent ad61bfa903
commit 298fc43987
10 changed files with 428 additions and 163 deletions

View file

@ -51,6 +51,103 @@ local function ChangeTag(props)
end) end)
end end
-- Radio button option for selection
local function SelectionOption(props)
return Theme.with(function(theme)
local isSelected = props.isSelected
local bgColor = if isSelected
then (if props.optionType == "push" then Color3.fromHex("27AE60")
elseif props.optionType == "pull" then Color3.fromHex("E74C3C")
else Color3.fromHex("7F8C8D"))
else theme.BorderedContainer.BackgroundColor
local textColor = if isSelected then Color3.new(1, 1, 1) else theme.TextColor
return e("TextButton", {
Size = UDim2.new(0, 36, 0, 18),
BackgroundColor3 = bgColor,
BackgroundTransparency = props.transparency:map(function(t)
return if isSelected then 0.1 + (0.9 * t) else 0.7 + (0.3 * t)
end),
BorderSizePixel = 0,
Text = props.text,
FontFace = if isSelected then theme.Font.Bold else theme.Font.Main,
TextSize = theme.TextSize.Small,
TextColor3 = textColor,
TextTransparency = props.transparency,
LayoutOrder = props.layoutOrder,
ZIndex = 10,
[Roact.Event.Activated] = function()
if props.onClick then
props.onClick()
end
end,
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 3),
}),
})
end)
end
-- Selection radio group component
local function SelectionRadio(props)
if not props.visible then
return nil
end
return e("Frame", {
Size = UDim2.new(0, 118, 0, 18),
BackgroundTransparency = 1,
Position = UDim2.new(1, -5, 0, 3),
AnchorPoint = Vector2.new(1, 0),
ZIndex = 10,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 4),
}),
Pull = e(SelectionOption, {
text = "Pull",
optionType = "pull",
isSelected = props.selection == "pull",
transparency = props.transparency,
layoutOrder = 1,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "pull")
end
end,
}),
Skip = e(SelectionOption, {
text = "Skip",
optionType = "ignore",
isSelected = props.selection == "ignore",
transparency = props.transparency,
layoutOrder = 2,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "ignore")
end
end,
}),
Push = e(SelectionOption, {
text = "Push",
optionType = "push",
isSelected = props.selection == "push",
transparency = props.transparency,
layoutOrder = 3,
onClick = function()
if props.onSelectionChange then
props.onSelectionChange(props.nodeId, "push")
end
end,
}),
})
end
local Expansion = Roact.Component:extend("Expansion") local Expansion = Roact.Component:extend("Expansion")
function Expansion:render() function Expansion:render()
@ -230,16 +327,6 @@ function DomLabel:render()
end end
end end
end, end,
}, {
StateTip = if (props.instance or props.changeList)
then e(Tooltip.Trigger, {
text = (if props.changeList
then "Click to " .. (if self.expanded then "hide" else "view") .. " changes"
else "") .. (if props.instance
then (if props.changeList then " & d" else "D") .. "ouble click to open in Explorer"
else ""),
})
else nil,
}), }),
Expansion = if props.changeList Expansion = if props.changeList
then e(Expansion, { then e(Expansion, {
@ -285,9 +372,10 @@ function DomLabel:render()
}), }),
ChangeInfo = e("Frame", { ChangeInfo = e("Frame", {
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(1, -indent - 80, 0, 24), Size = UDim2.new(1, -indent - (if props.patchType and props.onSelectionChange then 210 else 80), 0, 24),
Position = UDim2.new(1, -2, 0, 0), Position = UDim2.new(1, if props.patchType and props.onSelectionChange then -130 else -2, 0, 0),
AnchorPoint = Vector2.new(1, 0), AnchorPoint = Vector2.new(1, 0),
ClipsDescendants = true,
}, { }, {
Layout = e("UIListLayout", { Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal, FillDirection = Enum.FillDirection.Horizontal,
@ -324,6 +412,13 @@ function DomLabel:render()
}) })
else nil, else nil,
}), }),
SelectionRadio = e(SelectionRadio, {
visible = props.patchType ~= nil and props.onSelectionChange ~= nil,
nodeId = props.nodeId,
selection = props.selection,
transparency = props.transparency,
onSelectionChange = props.onSelectionChange,
}),
LineGuides = e("Folder", nil, lineGuides), LineGuides = e("Folder", nil, lineGuides),
}) })
end) end)

View file

@ -32,6 +32,10 @@ function PatchVisualizer:shouldUpdate(nextProps)
return true return true
end end
if self.props.selections ~= nextProps.selections then
return true
end
local currentPatch, nextPatch = self.props.patch, nextProps.patch local currentPatch, nextPatch = self.props.patch, nextProps.patch
if currentPatch ~= nil or nextPatch ~= nil then if currentPatch ~= nil or nextPatch ~= nil then
return not PatchSet.isEqual(currentPatch, nextPatch) return not PatchSet.isEqual(currentPatch, nextPatch)
@ -96,6 +100,10 @@ function PatchVisualizer:render()
name = node.name, name = node.name,
changeInfo = node.changeInfo, changeInfo = node.changeInfo,
changeList = node.changeList, changeList = node.changeList,
-- Selection props
nodeId = node.id,
selection = self.props.selections and self.props.selections[node.id],
onSelectionChange = self.props.onSelectionChange,
}) })
if isFinalChild then if isFinalChild then

View file

@ -130,7 +130,7 @@ function TextButton:render()
zIndex = -1, zIndex = -1,
}), }),
Background = (style == "Solid" or style == "Warning") and e(SlicedImage, { Background = (style == "Solid" or style == "Warning" or style == "Primary" or style == "Success" or style == "Danger" or style == "Neutral") and e(SlicedImage, {
slice = Assets.Slices.RoundedBackground, slice = Assets.Slices.RoundedBackground,
color = bindingUtil.mapLerp( color = bindingUtil.mapLerp(
bindingEnabled, bindingEnabled,

View file

@ -6,6 +6,7 @@ local Roact = require(Packages.Roact)
local Settings = require(Plugin.Settings) local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local PatchTree = require(Plugin.PatchTree)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
@ -21,6 +22,12 @@ function ConfirmingPage:init()
self.contentSize, self.setContentSize = Roact.createBinding(0) self.contentSize, self.setContentSize = Roact.createBinding(0)
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
-- Initialize selections from patchTree defaults
local initialSelections = {}
if self.props.patchTree then
initialSelections = PatchTree.buildInitialSelections(self.props.patchTree)
end
self:setState({ self:setState({
showingStringDiff = false, showingStringDiff = false,
currentString = "", currentString = "",
@ -29,10 +36,48 @@ function ConfirmingPage:init()
oldTable = {}, oldTable = {},
newTable = {}, newTable = {},
showingRejectConfirm = false, showingRejectConfirm = false,
selections = initialSelections,
}) })
-- Callback to update individual selection
self.onSelectionChange = function(nodeId, selection)
self:setState(function(state)
local newSelections = table.clone(state.selections)
newSelections[nodeId] = selection
return { selections = newSelections }
end)
end
-- Set all selections to a specific value
self.setAllSelections = function(value)
if not self.props.patchTree then
return
end
local newSelections = {}
self.props.patchTree:forEach(function(node)
if node.patchType then
newSelections[node.id] = value
end
end)
self:setState({ selections = newSelections })
end
end
function ConfirmingPage:didUpdate(prevProps)
-- If patchTree changed, reinitialize selections
if prevProps.patchTree ~= self.props.patchTree and self.props.patchTree then
local initialSelections = PatchTree.buildInitialSelections(self.props.patchTree)
self:setState({ selections = initialSelections })
end
end end
function ConfirmingPage:render() function ConfirmingPage:render()
-- Check if all items have a selection
local allSelected = true
if self.props.patchTree then
allSelected = PatchTree.allNodesSelected(self.props.patchTree, self.state.selections)
end
return Theme.with(function(theme) return Theme.with(function(theme)
local pageContent = Roact.createFragment({ local pageContent = Roact.createFragment({
Title = e("TextLabel", { Title = e("TextLabel", {
@ -56,6 +101,8 @@ function ConfirmingPage:render()
layoutOrder = 3, layoutOrder = 3,
patchTree = self.props.patchTree, patchTree = self.props.patchTree,
selections = self.state.selections,
onSelectionChange = self.onSelectionChange,
showStringDiff = function(currentString: string, incomingString: string) showStringDiff = function(currentString: string, incomingString: string)
self:setState({ self:setState({
@ -84,38 +131,51 @@ function ConfirmingPage:render()
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 1, layoutOrder = 1,
onClick = self.props.onAbort, onClick = self.props.onAbort,
}, {
Tip = e(Tooltip.Trigger, {
text = "Stop the connection process",
}),
}), }),
Reject = if Settings:get("twoWaySync") PullAll = if Settings:get("twoWaySync")
then e(TextButton, { then e(TextButton, {
text = "Reject", text = "Pull All",
style = "Warning", style = "Danger",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 0, layoutOrder = 2,
onClick = function() onClick = function()
self:setState({ showingRejectConfirm = true }) self.setAllSelections("pull")
end, end,
}, {
Tip = e(Tooltip.Trigger, {
text = "Push Studio changes to the Rojo server",
}),
}) })
else nil, else nil,
Accept = e(TextButton, { SkipAll = e(TextButton, {
text = "Accept", text = "Skip All",
style = "Solid", style = "Neutral",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 3,
onClick = self.props.onAccept, onClick = function()
}, { self.setAllSelections("ignore")
Tip = e(Tooltip.Trigger, { end,
text = "Pull Rojo server changes to Studio",
}), }),
PushAll = e(TextButton, {
text = "Push All",
style = "Success",
transparency = self.props.transparency,
layoutOrder = 4,
onClick = function()
self.setAllSelections("push")
end,
}),
Commit = e(TextButton, {
text = "Commit",
style = "Primary",
transparency = self.props.transparency,
layoutOrder = 5,
enabled = allSelected,
onClick = function()
if allSelected and self.props.onConfirm then
self.props.onConfirm(self.state.selections)
end
end,
}), }),
Layout = e("UIListLayout", { Layout = e("UIListLayout", {
@ -215,73 +275,6 @@ function ConfirmingPage:render()
}), }),
}), }),
RejectConfirm = e(StudioPluginGui, {
id = "Rojo_RejectConfirm",
title = "Confirm Reject",
active = self.state.showingRejectConfirm,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(340, 120),
minimumSize = Vector2.new(300, 100),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({ showingRejectConfirm = false })
end,
}, {
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundColor3 = theme.BackgroundColor,
}, {
Message = e("TextLabel", {
Text = "Are you sure you want to reject incoming changes and push Studio changes to the server?",
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextWrapped = true,
Size = UDim2.new(1, -20, 0, 50),
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,
}, {
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 = self.props.transparency,
layoutOrder = 1,
onClick = function()
self:setState({ showingRejectConfirm = false })
end,
}),
Confirm = e(TextButton, {
text = "Reject",
style = "Warning",
transparency = self.props.transparency,
layoutOrder = 2,
onClick = function()
self:setState({ showingRejectConfirm = false })
self.props.onReject()
end,
}),
}),
}),
}),
}) })
if self.props.createPopup then if self.props.createPopup then

View file

@ -237,6 +237,14 @@ function SettingsPage:render()
layoutOrder = layoutIncrement(), layoutOrder = layoutIncrement(),
}), }),
OneShotSync = e(Setting, {
id = "oneShotSync",
name = "One-Shot Sync",
description = "Disconnect automatically after the initial sync completes",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
LogLevel = e(Setting, { LogLevel = e(Setting, {
id = "logLevel", id = "logLevel",
name = "Log Level", name = "Log Level",

View file

@ -75,8 +75,60 @@ function StudioProvider:updateTheme()
BackgroundColor = BRAND_COLOR, BackgroundColor = BRAND_COLOR,
}, },
}, },
Primary = {
-- Primary uses blue theming for main actions.
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = Color3.fromHex("335FFF"),
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = Color3.fromHex("2A4FCC"),
},
},
Success = {
-- Success uses green theming for positive actions.
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = Color3.fromHex("27AE60"),
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = Color3.fromHex("1E8449"),
},
},
Danger = {
-- Danger uses red theming for destructive actions.
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = Color3.fromHex("E74C3C"),
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = Color3.fromHex("B03A2E"),
},
},
Neutral = {
-- Neutral uses gray theming for skip/ignore actions.
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = Color3.fromHex("7F8C8D"),
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = Color3.fromHex("616A6B"),
},
},
Warning = { Warning = {
-- Warning uses orange theming for destructive actions. -- Warning uses orange theming for caution actions.
ActionFillColor = Color3.fromHex("FFFFFF"), ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8, ActionFillTransparency = 0.8,
Enabled = { Enabled = {

View file

@ -709,14 +709,28 @@ function App:startSession()
end) end)
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
-- Helper to schedule one-shot disconnect after auto-accept
local function scheduleOneShotDisconnect()
if Settings:get("oneShotSync") then
task.delay(0.5, function()
if self.serveSession then
Log.info("One-shot sync: disconnecting after sync")
self:endSession()
end
end)
end
end
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
Log.trace("Accepting patch without confirmation because it is empty") Log.trace("Accepting patch without confirmation because it is empty")
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
-- Play solo auto-connect does not require confirmation -- Play solo auto-connect does not require confirmation
if self:isAutoConnectPlaytestServerAvailable() then if self:isAutoConnectPlaytestServerAvailable() then
Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled") Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled")
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
@ -728,6 +742,7 @@ function App:startSession()
Log.trace( Log.trace(
"Accepting patch without confirmation because project has already been connected and behavior is set to Initial" "Accepting patch without confirmation because project has already been connected and behavior is set to Initial"
) )
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
elseif confirmationBehavior == "Large Changes" then elseif confirmationBehavior == "Large Changes" then
@ -736,6 +751,7 @@ function App:startSession()
Log.trace( Log.trace(
"Accepting patch without confirmation because patch is small and behavior is set to Large Changes" "Accepting patch without confirmation because patch is small and behavior is set to Large Changes"
) )
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
elseif confirmationBehavior == "Unlisted PlaceId" then elseif confirmationBehavior == "Unlisted PlaceId" then
@ -746,11 +762,13 @@ function App:startSession()
Log.trace( Log.trace(
"Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId" "Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId"
) )
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
end end
elseif confirmationBehavior == "Never" then elseif confirmationBehavior == "Never" then
Log.trace("Accepting patch without confirmation because behavior is set to Never") Log.trace("Accepting patch without confirmation because behavior is set to Never")
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
end end
@ -769,6 +787,7 @@ function App:startSession()
and datamodelUpdates.changedClassName == nil and datamodelUpdates.changedClassName == nil
then then
Log.trace("Accepting patch without confirmation because it only contains a datamodel name change") Log.trace("Accepting patch without confirmation because it only contains a datamodel name change")
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
end end
@ -786,15 +805,15 @@ function App:startSession()
}) })
self:addNotification({ self:addNotification({
text = string.format( text = "Please review and confirm the sync changes, or disconnect.",
"Please accept%sor abort the initializing sync session.",
Settings:get("twoWaySync") and ", reject, " or " "
),
timeout = 7, timeout = 7,
}) })
local result = self.confirmationEvent:Wait() local result = self.confirmationEvent:Wait()
-- Track if this was the initial sync for this project
local isInitialSyncForProject = not self.knownProjects[serverInfo.projectName]
-- Reset UI state back to Connected after confirmation -- Reset UI state back to Connected after confirmation
-- This is needed for ongoing WebSocket patches where the session -- This is needed for ongoing WebSocket patches where the session
-- is already connected and won't trigger a status change -- is already connected and won't trigger a status change
@ -808,6 +827,20 @@ function App:startSession()
}) })
end end
-- One-shot sync: disconnect after confirmation
if Settings:get("oneShotSync") then
local resultType = if type(result) == "table" then result.type else result
if resultType == "Confirm" or resultType == "Accept" then
-- Delay slightly to allow patch to be applied
task.delay(0.5, function()
if self.serveSession then
Log.info("One-shot sync: disconnecting after sync")
self:endSession()
end
end)
end
end
return result return result
end) end)
@ -815,7 +848,8 @@ function App:startSession()
-- If all changes have been reverted, auto-accept the empty patch -- If all changes have been reverted, auto-accept the empty patch
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
Log.trace("Patch became empty after merging, auto-accepting") Log.trace("Patch became empty after merging, auto-accepting")
self.confirmationBindable:Fire("Accept") -- Return empty selections to accept all (nothing to do)
self.confirmationBindable:Fire({ type = "Confirm", selections = {} })
return return
end end
@ -925,11 +959,8 @@ function App:render()
onAbort = function() onAbort = function()
self.confirmationBindable:Fire("Abort") self.confirmationBindable:Fire("Abort")
end, end,
onAccept = function() onConfirm = function(selections)
self.confirmationBindable:Fire("Accept") self.confirmationBindable:Fire({ type = "Confirm", selections = selections })
end,
onReject = function()
self.confirmationBindable:Fire("Reject")
end, end,
}), }),

View file

@ -313,6 +313,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
end end
-- Add this node to tree -- Add this node to tree
-- For Edit nodes, default to "push" (apply Rojo changes to Studio)
tree:addNode(instanceMap.fromInstances[instance.Parent], { tree:addNode(instanceMap.fromInstances[instance.Parent], {
id = change.id, id = change.id,
patchType = "Edit", patchType = "Edit",
@ -321,6 +322,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
instance = instance, instance = instance,
changeInfo = changeInfo, changeInfo = changeInfo,
changeList = changeList, changeList = changeList,
defaultSelection = "push",
}) })
end end
Timer.stop() Timer.stop()
@ -364,6 +366,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap) tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap)
-- Add this node to tree -- Add this node to tree
-- For Remove nodes, default to "push" (apply Rojo removal to Studio)
local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false) local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false)
instanceMap:insert(nodeId, instance) instanceMap:insert(nodeId, instance)
tree:addNode(instanceMap.fromInstances[instance.Parent], { tree:addNode(instanceMap.fromInstances[instance.Parent], {
@ -372,6 +375,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
className = instance.ClassName, className = instance.ClassName,
name = instance.Name, name = instance.Name,
instance = instance, instance = instance,
defaultSelection = "push",
}) })
end end
Timer.stop() Timer.stop()
@ -440,6 +444,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
end end
-- Add this node to tree -- Add this node to tree
-- For Add nodes, default to "push" (apply Rojo addition to Studio)
tree:addNode(change.Parent, { tree:addNode(change.Parent, {
id = change.Id, id = change.Id,
patchType = "Add", patchType = "Add",
@ -448,6 +453,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
changeInfo = changeInfo, changeInfo = changeInfo,
changeList = changeList, changeList = changeList,
instance = instanceMap.fromIds[id], instance = instanceMap.fromIds[id],
defaultSelection = "push",
}) })
end end
Timer.stop() Timer.stop()
@ -456,6 +462,41 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
return tree return tree
end end
-- Builds initial selections map from the tree using defaultSelection values
-- Returns a table mapping node id -> selection ("push", "pull", or "ignore")
function PatchTree.buildInitialSelections(tree)
local selections = {}
tree:forEach(function(node)
if node.patchType and node.defaultSelection then
selections[node.id] = node.defaultSelection
end
end)
return selections
end
-- Gets all selectable node IDs (nodes that have a patchType)
-- Returns an array of node IDs
function PatchTree.getSelectableNodeIds(tree)
local ids = {}
tree:forEach(function(node)
if node.patchType then
table.insert(ids, node.id)
end
end)
return ids
end
-- Checks if all selectable nodes have a selection
function PatchTree.allNodesSelected(tree, selections)
local allSelected = true
tree:forEach(function(node)
if node.patchType and not selections[node.id] then
allSelected = false
end
end)
return allSelected
end
-- Updates the metadata of a tree with the unapplied patch and currently existing instances -- Updates the metadata of a tree with the unapplied patch and currently existing instances
-- Builds a new tree from the data if one isn't provided -- Builds a new tree from the data if one isn't provided
-- Always returns a new tree for immutability purposes in Roact -- Always returns a new tree for immutability purposes in Roact

View file

@ -590,55 +590,91 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
if userDecision == "Abort" then if userDecision == "Abort" then
return Promise.reject("Aborted Rojo sync operation") return Promise.reject("Aborted Rojo sync operation")
elseif userDecision == "Reject" then elseif userDecision == "Accept" then
if not self.__twoWaySync then -- Legacy: Accept all changes (push all to Studio)
return Promise.reject("Cannot reject sync operation without two-way sync enabled") self:__setStatus(Status.Connected, serverInfo.projectName)
end self:__applyGameAndPlaceId(serverInfo)
-- The user wants their studio DOM to write back to their Rojo DOM self:__applyPatch(catchUpPatch)
-- so we will reverse the patch and send it back return Promise.resolve()
elseif type(userDecision) == "table" and userDecision.type == "Confirm" then
-- New: Apply based on per-item selections
local selections = userDecision.selections or {}
local inversePatch = PatchSet.newEmpty() -- Build partial patches based on selections
local pushPatch = PatchSet.newEmpty() -- Items to apply to Studio
local pullPatch = PatchSet.newEmpty() -- Items to send back to Rojo
-- Send back the current properties -- Process updated items
for _, change in catchUpPatch.updated do for _, change in catchUpPatch.updated do
local selection = selections[change.id]
if selection == "push" then
-- Apply Rojo changes to Studio
table.insert(pushPatch.updated, change)
elseif selection == "pull" and self.__twoWaySync then
-- Send Studio state back to Rojo
local instance = self.__instanceMap.fromIds[change.id] local instance = self.__instanceMap.fromIds[change.id]
if not instance then if instance then
continue
end
local propertiesToSync = change.changedProperties local propertiesToSync = change.changedProperties
if self.__syncSourceOnly then if self.__syncSourceOnly then
-- Filter to only Source property when server is in source-only mode
if propertiesToSync.Source then if propertiesToSync.Source then
propertiesToSync = { Source = propertiesToSync.Source } propertiesToSync = { Source = propertiesToSync.Source }
else else
continue continue
end end
end end
local update = encodePatchUpdate(instance, change.id, propertiesToSync) local update = encodePatchUpdate(instance, change.id, propertiesToSync)
if update then if update then
table.insert(inversePatch.updated, update) table.insert(pullPatch.updated, update)
end end
end end
-- Add the removed instances back to Rojo
-- selene:allow(empty_if, unused_variable, empty_loop)
for _, instance in catchUpPatch.removed do
-- TODO: Generate ID for our instance and add it to inversePatch.added
end end
-- Remove the additions we've rejected -- "ignore" items are skipped
for id, _change in catchUpPatch.added do
table.insert(inversePatch.removed, id)
end end
return self.__apiContext:write(inversePatch) -- Process removed items
elseif userDecision == "Accept" then for _, idOrInstance in catchUpPatch.removed do
local id = if type(idOrInstance) == "string" then idOrInstance else self.__instanceMap.fromInstances[idOrInstance]
local selection = selections[id]
if selection == "push" then
-- Apply Rojo removal to Studio
table.insert(pushPatch.removed, idOrInstance)
elseif selection == "pull" and self.__twoWaySync then
-- Don't remove in Studio, but we can't easily "add back" to Rojo
-- For now, just skip (similar to ignore)
end
-- "ignore" items are skipped
end
-- Process added items
for id, change in catchUpPatch.added do
local selection = selections[id]
if selection == "push" then
-- Apply Rojo addition to Studio
pushPatch.added[id] = change
elseif selection == "pull" and self.__twoWaySync then
-- Don't add in Studio, remove from Rojo
table.insert(pullPatch.removed, id)
end
-- "ignore" items are skipped
end
-- Apply the changes
self:__setStatus(Status.Connected, serverInfo.projectName) self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo) self:__applyGameAndPlaceId(serverInfo)
self:__applyPatch(catchUpPatch)
-- Apply push items to Studio
if not PatchSet.isEmpty(pushPatch) then
self:__applyPatch(pushPatch)
end
-- Send pull items back to Rojo server
if self.__twoWaySync and not PatchSet.isEmpty(pullPatch) then
return self.__apiContext:write(pullPatch)
end
return Promise.resolve() return Promise.resolve()
else else
return Promise.reject("Invalid user decision: " .. userDecision) return Promise.reject("Invalid user decision: " .. tostring(userDecision))
end end
end end

View file

@ -12,6 +12,7 @@ local Roact = require(Packages.Roact)
local defaultSettings = { local defaultSettings = {
openScriptsExternally = false, openScriptsExternally = false,
twoWaySync = false, twoWaySync = false,
oneShotSync = false,
autoReconnect = false, autoReconnect = false,
showNotifications = true, showNotifications = true,
enableSyncFallback = true, enableSyncFallback = true,