mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-26 19:24:52 +00:00
one-shot + new commit dialogue
This commit is contained in:
parent
ad61bfa903
commit
298fc43987
10 changed files with 428 additions and 163 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue