diff --git a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua index 343d7962..e6a80e2c 100644 --- a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua +++ b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua @@ -51,6 +51,103 @@ local function ChangeTag(props) 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") function Expansion:render() @@ -230,16 +327,6 @@ function DomLabel:render() 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 then e(Expansion, { @@ -285,9 +372,10 @@ function DomLabel:render() }), ChangeInfo = e("Frame", { BackgroundTransparency = 1, - Size = UDim2.new(1, -indent - 80, 0, 24), - Position = UDim2.new(1, -2, 0, 0), + Size = UDim2.new(1, -indent - (if props.patchType and props.onSelectionChange then 210 else 80), 0, 24), + Position = UDim2.new(1, if props.patchType and props.onSelectionChange then -130 else -2, 0, 0), AnchorPoint = Vector2.new(1, 0), + ClipsDescendants = true, }, { Layout = e("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, @@ -324,6 +412,13 @@ function DomLabel:render() }) 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), }) end) diff --git a/plugin/src/App/Components/PatchVisualizer/init.lua b/plugin/src/App/Components/PatchVisualizer/init.lua index 3e600dca..449339b8 100644 --- a/plugin/src/App/Components/PatchVisualizer/init.lua +++ b/plugin/src/App/Components/PatchVisualizer/init.lua @@ -32,6 +32,10 @@ function PatchVisualizer:shouldUpdate(nextProps) return true end + if self.props.selections ~= nextProps.selections then + return true + end + local currentPatch, nextPatch = self.props.patch, nextProps.patch if currentPatch ~= nil or nextPatch ~= nil then return not PatchSet.isEqual(currentPatch, nextPatch) @@ -96,6 +100,10 @@ function PatchVisualizer:render() name = node.name, changeInfo = node.changeInfo, 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 diff --git a/plugin/src/App/Components/TextButton.lua b/plugin/src/App/Components/TextButton.lua index 9bab438e..35aa6b9d 100644 --- a/plugin/src/App/Components/TextButton.lua +++ b/plugin/src/App/Components/TextButton.lua @@ -130,7 +130,7 @@ function TextButton:render() 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, color = bindingUtil.mapLerp( bindingEnabled, diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 9c05061a..4552198f 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -6,6 +6,7 @@ local Roact = require(Packages.Roact) local Settings = require(Plugin.Settings) local Theme = require(Plugin.App.Theme) +local PatchTree = require(Plugin.PatchTree) local TextButton = require(Plugin.App.Components.TextButton) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local Tooltip = require(Plugin.App.Components.Tooltip) @@ -21,6 +22,12 @@ function ConfirmingPage:init() self.contentSize, self.setContentSize = Roact.createBinding(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({ showingStringDiff = false, currentString = "", @@ -29,10 +36,48 @@ function ConfirmingPage:init() oldTable = {}, newTable = {}, 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 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) local pageContent = Roact.createFragment({ Title = e("TextLabel", { @@ -56,6 +101,8 @@ function ConfirmingPage:render() layoutOrder = 3, patchTree = self.props.patchTree, + selections = self.state.selections, + onSelectionChange = self.onSelectionChange, showStringDiff = function(currentString: string, incomingString: string) self:setState({ @@ -78,44 +125,57 @@ function ConfirmingPage:render() LayoutOrder = 4, BackgroundTransparency = 1, }, { - Abort = e(TextButton, { - text = "Abort", - style = "Bordered", - transparency = self.props.transparency, - layoutOrder = 1, - onClick = self.props.onAbort, - }, { - Tip = e(Tooltip.Trigger, { - text = "Stop the connection process", - }), - }), + Abort = e(TextButton, { + text = "Abort", + style = "Bordered", + transparency = self.props.transparency, + layoutOrder = 1, + onClick = self.props.onAbort, + }), - Reject = if Settings:get("twoWaySync") + PullAll = if Settings:get("twoWaySync") then e(TextButton, { - text = "Reject", - style = "Warning", + text = "Pull All", + style = "Danger", transparency = self.props.transparency, - layoutOrder = 0, + layoutOrder = 2, onClick = function() - self:setState({ showingRejectConfirm = true }) + self.setAllSelections("pull") end, - }, { - Tip = e(Tooltip.Trigger, { - text = "Push Studio changes to the Rojo server", - }), }) else nil, - Accept = e(TextButton, { - text = "Accept", - style = "Solid", + SkipAll = e(TextButton, { + text = "Skip All", + style = "Neutral", transparency = self.props.transparency, layoutOrder = 3, - onClick = self.props.onAccept, - }, { - Tip = e(Tooltip.Trigger, { - text = "Pull Rojo server changes to Studio", - }), + onClick = function() + self.setAllSelections("ignore") + end, + }), + + 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", { @@ -215,74 +275,7 @@ 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 return e(StudioPluginGui, { diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index b63d30e5..7f66cc16 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -237,6 +237,14 @@ function SettingsPage:render() 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, { id = "logLevel", name = "Log Level", diff --git a/plugin/src/App/Theme.lua b/plugin/src/App/Theme.lua index 5ffb8845..919ed334 100644 --- a/plugin/src/App/Theme.lua +++ b/plugin/src/App/Theme.lua @@ -75,8 +75,60 @@ function StudioProvider:updateTheme() 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 uses orange theming for destructive actions. + -- Warning uses orange theming for caution actions. ActionFillColor = Color3.fromHex("FFFFFF"), ActionFillTransparency = 0.8, Enabled = { diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index 5b144565..48cf72ef 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -709,14 +709,28 @@ function App:startSession() end) 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 Log.trace("Accepting patch without confirmation because it is empty") + scheduleOneShotDisconnect() 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") + scheduleOneShotDisconnect() return "Accept" end @@ -728,6 +742,7 @@ function App:startSession() Log.trace( "Accepting patch without confirmation because project has already been connected and behavior is set to Initial" ) + scheduleOneShotDisconnect() return "Accept" end elseif confirmationBehavior == "Large Changes" then @@ -736,6 +751,7 @@ function App:startSession() Log.trace( "Accepting patch without confirmation because patch is small and behavior is set to Large Changes" ) + scheduleOneShotDisconnect() return "Accept" end elseif confirmationBehavior == "Unlisted PlaceId" then @@ -746,11 +762,13 @@ function App:startSession() Log.trace( "Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId" ) + scheduleOneShotDisconnect() return "Accept" end end elseif confirmationBehavior == "Never" then Log.trace("Accepting patch without confirmation because behavior is set to Never") + scheduleOneShotDisconnect() return "Accept" end end @@ -769,6 +787,7 @@ function App:startSession() and datamodelUpdates.changedClassName == nil then Log.trace("Accepting patch without confirmation because it only contains a datamodel name change") + scheduleOneShotDisconnect() return "Accept" end end @@ -786,15 +805,15 @@ function App:startSession() }) self:addNotification({ - text = string.format( - "Please accept%sor abort the initializing sync session.", - Settings:get("twoWaySync") and ", reject, " or " " - ), + text = "Please review and confirm the sync changes, or disconnect.", timeout = 7, }) 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 -- This is needed for ongoing WebSocket patches where the session -- is already connected and won't trigger a status change @@ -808,6 +827,20 @@ function App:startSession() }) 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 end) @@ -815,7 +848,8 @@ function App:startSession() -- 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") - self.confirmationBindable:Fire("Accept") + -- Return empty selections to accept all (nothing to do) + self.confirmationBindable:Fire({ type = "Confirm", selections = {} }) return end @@ -925,11 +959,8 @@ function App:render() onAbort = function() self.confirmationBindable:Fire("Abort") end, - onAccept = function() - self.confirmationBindable:Fire("Accept") - end, - onReject = function() - self.confirmationBindable:Fire("Reject") + onConfirm = function(selections) + self.confirmationBindable:Fire({ type = "Confirm", selections = selections }) end, }), diff --git a/plugin/src/PatchTree.lua b/plugin/src/PatchTree.lua index 72c4ff9f..d788473b 100644 --- a/plugin/src/PatchTree.lua +++ b/plugin/src/PatchTree.lua @@ -313,6 +313,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) end -- Add this node to tree + -- For Edit nodes, default to "push" (apply Rojo changes to Studio) tree:addNode(instanceMap.fromInstances[instance.Parent], { id = change.id, patchType = "Edit", @@ -321,6 +322,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) instance = instance, changeInfo = changeInfo, changeList = changeList, + defaultSelection = "push", }) end Timer.stop() @@ -364,6 +366,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap) -- 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) instanceMap:insert(nodeId, instance) tree:addNode(instanceMap.fromInstances[instance.Parent], { @@ -372,6 +375,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) className = instance.ClassName, name = instance.Name, instance = instance, + defaultSelection = "push", }) end Timer.stop() @@ -440,6 +444,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) end -- Add this node to tree + -- For Add nodes, default to "push" (apply Rojo addition to Studio) tree:addNode(change.Parent, { id = change.Id, patchType = "Add", @@ -448,6 +453,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) changeInfo = changeInfo, changeList = changeList, instance = instanceMap.fromIds[id], + defaultSelection = "push", }) end Timer.stop() @@ -456,6 +462,41 @@ function PatchTree.build(patch, instanceMap, changeListHeaders) return tree 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 -- Builds a new tree from the data if one isn't provided -- Always returns a new tree for immutability purposes in Roact diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua index dede41fe..48184cc8 100644 --- a/plugin/src/ServeSession.lua +++ b/plugin/src/ServeSession.lua @@ -590,55 +590,91 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo) if userDecision == "Abort" then return Promise.reject("Aborted Rojo sync operation") - elseif userDecision == "Reject" then - if not self.__twoWaySync then - return Promise.reject("Cannot reject sync operation without two-way sync enabled") - end - -- The user wants their studio DOM to write back to their Rojo DOM - -- so we will reverse the patch and send it back - - local inversePatch = PatchSet.newEmpty() - - -- Send back the current properties - for _, change in catchUpPatch.updated do - local instance = self.__instanceMap.fromIds[change.id] - if not instance then - continue - end - - local propertiesToSync = change.changedProperties - if self.__syncSourceOnly then - -- Filter to only Source property when server is in source-only mode - if propertiesToSync.Source then - propertiesToSync = { Source = propertiesToSync.Source } - else - continue - end - end - - local update = encodePatchUpdate(instance, change.id, propertiesToSync) - if update then - table.insert(inversePatch.updated, update) - 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 - -- Remove the additions we've rejected - for id, _change in catchUpPatch.added do - table.insert(inversePatch.removed, id) - end - - return self.__apiContext:write(inversePatch) elseif userDecision == "Accept" then + -- Legacy: Accept all changes (push all to Studio) self:__setStatus(Status.Connected, serverInfo.projectName) self:__applyGameAndPlaceId(serverInfo) self:__applyPatch(catchUpPatch) + return Promise.resolve() + elseif type(userDecision) == "table" and userDecision.type == "Confirm" then + -- New: Apply based on per-item selections + local selections = userDecision.selections or {} + + -- 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 + + -- Process updated items + 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] + if instance then + local propertiesToSync = change.changedProperties + if self.__syncSourceOnly then + if propertiesToSync.Source then + propertiesToSync = { Source = propertiesToSync.Source } + else + continue + end + end + local update = encodePatchUpdate(instance, change.id, propertiesToSync) + if update then + table.insert(pullPatch.updated, update) + end + end + end + -- "ignore" items are skipped + end + + -- Process removed items + 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:__applyGameAndPlaceId(serverInfo) + + -- 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() else - return Promise.reject("Invalid user decision: " .. userDecision) + return Promise.reject("Invalid user decision: " .. tostring(userDecision)) end end diff --git a/plugin/src/Settings.lua b/plugin/src/Settings.lua index 2c7008d9..5dca3cc0 100644 --- a/plugin/src/Settings.lua +++ b/plugin/src/Settings.lua @@ -12,6 +12,7 @@ local Roact = require(Packages.Roact) local defaultSettings = { openScriptsExternally = false, twoWaySync = false, + oneShotSync = false, autoReconnect = false, showNotifications = true, enableSyncFallback = true,