Voxoria Studio 0 = total then state.projectOffset = math.floor((total - 1) / state.projectLimit) * state.projectLimit refreshProjects({ quiet = options.quiet }) return end local rawProjects = data.projects or {} local fingerprints = {} if not RunService:IsRunning() then local projectIds = {} for _, project in ipairs(rawProjects) do table.insert(projectIds, project.id) end fingerprints = ProjectRegistry.fingerprints(projectIds) end local projects = {} for _, project in ipairs(rawProjects) do table.insert(projects, decorateProject(project, fingerprints[project.id])) end state:update({ projects = projects, projectTotal = total, }) ui:setProjects( projects, state.projectTotal, state.projectLimit, state.projectOffset ) if not options.quiet then ui:showProjectsStatus( "Projets vérifiés. Le mode manuel reste prioritaire.", "success" ) end if state.projectAutoSync then for _, project in ipairs(projects) do if ProjectSync.canAutoApply(project.sync_state, { inPlayMode = RunService:IsRunning(), alreadyApplied = project.local_version == project.cloud_version, ignored = project.ignored, hasUnmanagedConflicts = false, }) then loadProjectPayload(project, nil, true) break end end end end) end applyProjectPayload = function(project, payload, allowUnmanaged, automatic, successMessage) if updateRequired then ui:showProjectsStatus(OUTDATED_MESSAGE, "error") return end if state.projectBusy then return end if RunService:IsRunning() then ui:showProjectsStatus( "Arrête le mode Play avant de charger un projet.", "warning" ) return end state.projectBusy = true ui:setProjectsBusy(true) ui:showProjectsStatus( automatic and "Mise à jour sûre en cours…" or "Création du point de restauration et application…", "info" ) task.defer(function() local ok, err, _, warnings = Operations.applyProject( payload.changeSet, Config.PROJECT_MAX_OPERATIONS, { projectId = project.id, projectName = project.name or payload.name, version = payload.version, allowUnmanaged = allowUnmanaged == true, } ) state.projectBusy = false ui:setProjectsBusy(false) if not ok then ui:showProjectsStatus(err or "Chargement impossible.", "error") return end state.projectPending = nil state.projectIgnoredVersions[project.id] = nil persistIgnoredVersions() ui:setUndoAvailable(Operations.canUndo()) local baseMessage = successMessage or string.format( "%s chargé en v%d. Undo est disponible.", project.name, payload.version ) local warnCount = warnings and #warnings or 0 if warnCount > 0 then ui:showProjectsStatus( string.format( "%s\n%d asset(s) non inséré(s) :\n%s", baseMessage, warnCount, table.concat(warnings, "\n") ), "warning" ) else ui:showProjectsStatus(baseMessage, "success") end refreshProjects({ quiet = true }) end) end loadProjectPayload = function(project, version, automatic) if updateRequired then if not automatic then ui:showProjectsStatus(OUTDATED_MESSAGE, "error") end return end if state.projectBusy then return end state.projectBusy = true ui:setProjectsBusy(true) ui:showProjectsStatus( automatic and "Vérification de la mise à jour automatique…" or "Préparation de l'aperçu…", "info" ) task.spawn(function() local data, err, statusCode = api:getProject(project.id, version) state.projectBusy = false ui:setProjectsBusy(false) if not data then if statusCode == 401 then clearSession("La session Studio a expiré. Reconnecte le plugin.") return end ui:showProjectsStatus(err or "Version impossible à charger.", "error") return end local inspection, validationError = Operations.inspectProject( data.changeSet, Config.PROJECT_MAX_OPERATIONS, project.id ) if not inspection then ui:showProjectsStatus( "Contrat projet refusé par Studio : " .. tostring(validationError), "error" ) return end local displayedConflicts = table.clone(inspection.conflicts) if project.has_local_changes then table.insert( displayedConflicts, "Modifications locales détectées dans les objets gérés par Voxoria." ) end if project.local_version and project.local_version > data.version then table.insert( displayedConflicts, string.format( "Studio est en v%d alors que le cloud a été restauré en v%d.", project.local_version, data.version ) ) end state.projectPending = { project = project, payload = data, conflicts = displayedConflicts, } if automatic and ProjectSync.canAutoApply(project.sync_state, { inPlayMode = RunService:IsRunning(), alreadyApplied = project.local_version == data.version, ignored = project.ignored, hasUnmanagedConflicts = inspection.hasUnmanagedConflicts, }) then applyProjectPayload(project, data, false, true) return end if automatic and inspection.hasUnmanagedConflicts then ui:showProjectsStatus( "Mise à jour automatique suspendue : confirmation requise pour des objets non gérés.", "warning" ) end ui:showProjectPreview( project, data, Diff.preview(data.changeSet, Config.MAX_PREVIEW_CHARACTERS), displayedConflicts ) end) end -- Contexte Studio minimal pour le copilote (sélection uniquement, bornée). local function captureEditContext() local captured = ProjectContext.capture() local selection = {} for index, item in ipairs(captured.selection or {}) do if index > 5 then break end local props = item.properties or {} table.insert(selection, { name = item.name, className = item.className, position = props.position, size = props.size, orientation = props.orientation, parentPath = item.path and string.match(item.path, "^(.*)/[^/]+$") or nil, }) end if #selection == 0 then return nil -- pas de champ studioContext (JSONEncode {} ≠ tableau) end return { selection = selection } end -- Live Project Copilot : demande naturelle → nouvelle version → application. local function editProjectRequest(project, requestText) if updateRequired then ui:showProjectsStatus(OUTDATED_MESSAGE, "error") return end if state.projectBusy then return end if RunService:IsRunning() then ui:showProjectsStatus("Arrête le mode Play avant de modifier un projet.", "warning") return end state.projectBusy = true ui:setProjectsBusy(true) ui:showProjectsStatus("Voxoria prépare ta modification…", "info") task.spawn(function() local context = captureEditContext() local data, err, statusCode = api:editProject(project.id, requestText, context) if not data then state.projectBusy = false ui:setProjectsBusy(false) if statusCode == 401 then clearSession("La session Studio a expiré. Reconnecte le plugin.") return end ui:showProjectsStatus(err or "Modification impossible.", "error") return end local payload, payloadErr = api:getProject(project.id, data.version) state.projectBusy = false ui:setProjectsBusy(false) if not payload then ui:showProjectsStatus( payloadErr or "Nouvelle version indisponible. Utilise « Vérifier maintenant ».", "error" ) return end local nextAction = data.nextActions and data.nextActions[1] local successMessage = string.format( "v%d · %s%s", data.version, tostring(data.summary or "Modification appliquée."), nextAction and ("\nEnsuite : " .. tostring(nextAction.title)) or "" ) applyProjectPayload(project, payload, false, false, successMessage) end) end local function loadProjectHistory(project) if state.projectBusy then return end state.projectBusy = true ui:setProjectsBusy(true) ui:showProjectsStatus("Chargement de l'historique…", "info") task.spawn(function() local data, err, statusCode = api:getProjectVersions(project.id) state.projectBusy = false ui:setProjectsBusy(false) if not data then if statusCode == 401 then clearSession("La session Studio a expiré. Reconnecte le plugin.") return end ui:showProjectsStatus(err or "Historique indisponible.", "error") return end ui:showProjectHistory(project, data.versions or {}) end) end local function submit(mode, prompt) if state.busy then return end if not state.connected then ui:showStatus("Connecte d'abord ton compte Voxoria.", "warning") return end if not modeAllowed(mode) then ui:showStatus("Ce mode nécessite un plan Pro ou supérieur.", "warning") return end state.busy = true if mode ~= "ask" then state:update({ pendingChangeSet = nil }) ui:clearChangeSet() end ui:setBusy(true, mode == "ask" and "Voxoria réfléchit…" or "Préparation des changements…") task.spawn(function() local context = ProjectContext.capture() local data, err if mode == "ask" then data, err = api:ask(prompt, context) elseif mode == "generate" then data, err = api:generate(prompt, context) else local selectedScript, selectionError = ProjectContext.selectedScript() if not selectedScript then state.busy = false ui:setBusy(false) ui:showStatus(selectionError, "error") return end data, err = api:fix( selectedScript.path, selectedScript.source, prompt, context ) end state.busy = false ui:setBusy(false) if not data then ui:showStatus(err or "Voxoria n'a pas pu répondre.", "error") if string.find(string.lower(err or ""), "session") then clearSession("La session du plugin doit être reconnectée.") end return end if mode == "ask" then state:update({ lastAnswer = data.text, pendingChangeSet = nil }) ui:clearChangeSet() ui:showAnswer(data.text or "Aucune réponse.") ui:showStatus("Réponse reçue.", "success") else local changeSet = data.changeSet local valid, validationError = Operations.validate( changeSet, state.capabilities.maxOperations or 8 ) if not valid then ui:showStatus("Plan refusé par Studio : " .. validationError, "error") return end state:update({ pendingChangeSet = changeSet }) ui:showChangeSet( changeSet, Diff.preview(changeSet, Config.MAX_PREVIEW_CHARACTERS) ) ui:showStatus("Aperçu prêt. Vérifie avant d'appliquer.", "success") end end) end local function applyPending() if state.busy or not state.pendingChangeSet then return end state.busy = true ui:setBusy(true, "Application contrôlée dans Studio…") task.defer(function() local ok, err, warnings = Operations.apply( state.pendingChangeSet, state.capabilities.maxOperations or 8 ) state.busy = false ui:setBusy(false) if not ok then ui:showStatus(err, "error") return end state:update({ pendingChangeSet = nil }) ui:clearChangeSet() ui:setUndoAvailable(true) local warnCount = warnings and #warnings or 0 if warnCount > 0 then ui:showStatus( string.format( "Changements appliqués (%d asset(s) non inséré(s)). Undo reste disponible.\n%s", warnCount, table.concat(warnings, "\n") ), "warning" ) else ui:showStatus("Changements appliqués. Undo reste disponible.", "success") end end) end local function undoLast() if state.busy then return end local ok, err = Operations.undo() if not ok then ui:showStatus(err, "error") return end ui:setUndoAvailable(false) ui:showStatus("Derniers changements Voxoria annulés.", "success") if state.connected then refreshProjects({ quiet = true }) end end ui = UI.new(widget, { onConnect = startLink, onDisconnect = function() clearSession("Plugin déconnecté localement. Tu peux aussi révoquer la session depuis le site.") end, onOpenLink = openLink, onMode = function(mode) if not modeAllowed(mode) then ui:showStatus("Ce mode nécessite un plan Pro ou supérieur.", "warning") end end, onSubmit = submit, onApply = applyPending, onDiscard = function() state:update({ pendingChangeSet = nil }) ui:clearChangeSet() ui:showStatus("Proposition ignorée. Aucun objet n'a été modifié.", "info") end, onUndo = undoLast, onProjectsRefresh = function() refreshProjects({ reset = false }) end, onProjectCheck = function(project) local current = findProject(project.id) if current then refreshProjects({ reset = false }) end end, onProjectLoad = function(project, version) loadProjectPayload(project, version, false) end, onProjectHistory = loadProjectHistory, onProjectEdit = editProjectRequest, onProjectApply = function(project, payload, allowUnmanaged) applyProjectPayload(project, payload, allowUnmanaged, false) end, onProjectIgnore = function(project, version) state.projectIgnoredVersions[project.id] = version persistIgnoredVersions() ui:showProjectsStatus( string.format("La v%d reste disponible, mais ne sera pas appliquée automatiquement.", version), "info" ) refreshProjects({ quiet = true }) end, onProjectLater = function() ui:setProjects( state.projects, state.projectTotal, state.projectLimit, state.projectOffset ) end, onProjectPage = function(direction) local nextOffset = state.projectOffset + direction * state.projectLimit local lastPageOffset = math.floor( math.max(0, state.projectTotal - 1) / state.projectLimit ) * state.projectLimit state.projectOffset = math.clamp(nextOffset, 0, lastPageOffset) refreshProjects({ reset = false }) end, onProjectAutoSync = function(enabled) state.projectAutoSync = enabled == true plugin:SetSetting(Config.PROJECT_AUTO_SYNC_SETTING, state.projectAutoSync) ui:showProjectsStatus( state.projectAutoSync and "Auto sécurisé activé : hors Play, sans conflit et avec point de restauration." or "Mode manuel activé.", "info" ) if state.projectAutoSync then refreshProjects({ quiet = true }) end end, onProjectPollInterval = function(seconds) local value = tonumber(seconds) or 0 if not ALLOWED_POLL_INTERVALS[value] then value = Config.PROJECT_POLL_INTERVAL end state.projectPollInterval = value plugin:SetSetting(Config.PROJECT_POLL_INTERVAL_SETTING, value) if value > 0 then ui:showProjectsStatus( string.format("Vérification cloud automatique toutes les %d secondes.", value), "info" ) if state.connected and not state.projectBusy then refreshProjects({ quiet = true }) end else ui:showProjectsStatus( "Vérification automatique désactivée. Utilise « Vérifier maintenant ».", "info" ) end end, }) ui:setProjectsAutoSync(state.projectAutoSync) ui:setProjectsPollInterval(state.projectPollInterval) toolbarButton.Click:Connect(function() widget.Enabled = not widget.Enabled end) widget:GetPropertyChangedSignal("Enabled"):Connect(function() toolbarButton:SetActive(widget.Enabled) end) Selection.SelectionChanged:Connect(refreshSelection) refreshSelection() task.spawn(function() if not state.token then ui:setConnected(false, "free", nil) return end ui:showStatus("Vérification de la session Studio…", "info") local data, err = api:getSession() if not data then clearSession(err or "La session Studio a expiré.") return end state:update({ connected = true, plan = data.plan or state.plan, capabilities = data.capabilities or state.capabilities, }) plugin:SetSetting(Config.PLAN_SETTING, state.plan) ui:setConnected(true, state.plan, state.capabilities) ui:setProjectsAutoSync(state.projectAutoSync) ui:showStatus("Session restaurée. Voxoria est prêt.", "success") refreshProjects({ reset = true }) end) -- Update Checker : au lancement, compare la version locale au manifest serveur. -- HONNÊTE : pas d'auto-update (un plugin local ne peut pas se remplacer) — on -- affiche le statut, le changelog et le lien de téléchargement à copier. task.spawn(function() local data = api:getVersion() if type(data) ~= "table" then -- Hors ligne / API indisponible : ne jamais bloquer sur un échec réseau. ui:setVersionInfo(string.format("Roblox Studio copilot · v%s", Config.PLUGIN_VERSION)) return end local status = Version.status( Config.PLUGIN_VERSION, data.latestVersion, data.minimumSupportedVersion ) if status == nil or status == "current" then ui:setVersionInfo( string.format("Roblox Studio copilot · v%s · à jour", Config.PLUGIN_VERSION) ) return end updateRequired = status == "update_required" ui:setVersionInfo( string.format( "Roblox Studio copilot · v%s · %s", Config.PLUGIN_VERSION, updateRequired and "mise à jour requise" or "mise à jour disponible" ) ) local changelog = "" if type(data.changelog) == "table" and #data.changelog > 0 then changelog = "• " .. table.concat(data.changelog, " • ") end ui:showUpdateBanner({ required = updateRequired, title = updateRequired and string.format("Mise à jour requise → v%s", tostring(data.latestVersion)) or string.format("Mise à jour disponible → v%s", tostring(data.latestVersion)), changelog = changelog, url = Config.API_BASE_URL .. (data.downloadUrl or "/downloads/Voxoria.rbxmx"), }) end) task.spawn(function() while true do task.wait(5) if state.connected and not state.projectBusy and state.projectPollInterval and state.projectPollInterval > 0 and ProjectSync.shouldPoll( os.time(), state.lastProjectPoll, state.projectPollInterval ) then refreshProjects({ quiet = true }) end end end) plugin.Unloading:Connect(function() pollGeneration += 1 projectPollGeneration += 1 end) ]]> ApiClient Assets MAX_PARTS then return false, string.format( "Asset trop lourd (%d parts > %d autorisées).", parts, MAX_PARTS ) end for _, instance in ipairs(toRemove) do instance:Destroy() end return true, { parts = parts, scriptsRemoved = scriptsRemoved, remotesRemoved = remotesRemoved, } end -- Charge un asset PAR ID via InsertService, puis scanne/nettoie. -- Retourne (model, report) ou (nil, message). JAMAIS d'insertion aveugle : -- les scripts tiers sont retirés par défaut, le poids est borné. function Assets.load(assetId, stripScripts) if type(assetId) ~= "number" or assetId <= 0 then return nil, "Identifiant d'asset invalide." end local ok, result = pcall(function() return InsertService:LoadAsset(assetId) end) if not ok or typeof(result) ~= "Instance" then return nil, "Asset introuvable, privé ou non distribuable (ID " .. tostring(assetId) .. ")." end local cleanOk, report = scanAndClean(result, stripScripts) if not cleanOk then result:Destroy() return nil, report end return result, report end return Assets ]]> ClusterBuilder Config Diff maxCharacters then return string.sub(output, 1, maxCharacters) .. "\n\n… aperçu tronqué" end return output end return Diff ]]> InstancePath Operations maxOperations then return false, "Ce plan contient trop d'opérations." end local pending = {} for index, operation in ipairs(changeSet.operations) do local valid, err = validateOperation(operation, pending) if not valid then return false, string.format("Opération %d : %s", index, err) end end return true end local function operationTarget(operation) if operation.op == "create_instance" or operation.op == "create_script" or operation.op == "create_remote" then return operation.parent .. "/" .. operation.name elseif operation.op == "set_property" or operation.op == "replace_script_source" then return operation.path elseif operation.op == "destroy_instance" then return operation.path elseif operation.op == "insert_asset" then return operation.parent .. "/" .. (operation.name or ("Asset_" .. tostring(operation.assetId))) end return nil end local function existingTarget(operation) if operation.op == "create_instance" or operation.op == "create_script" or operation.op == "create_remote" then local parent = InstancePath.resolve(operation.parent) return parent and parent:FindFirstChild(operation.name) or nil elseif operation.op == "set_property" or operation.op == "replace_script_source" then return InstancePath.resolve(operation.path) elseif operation.op == "destroy_instance" then return InstancePath.resolve(operation.path) elseif operation.op == "insert_asset" then local parent = InstancePath.resolve(operation.parent) local assetName = operation.name or ("Asset_" .. tostring(operation.assetId)) return parent and parent:FindFirstChild(assetName) or nil end return nil end local function expectedClass(operation) if operation.op == "create_instance" then return operation.className elseif operation.op == "create_script" then return operation.scriptType elseif operation.op == "create_remote" then return operation.remoteType end return nil end local function appendUnique(output, seen, value) if value and not seen[value] then seen[value] = true table.insert(output, value) end end function Operations.inspectProject(changeSet, maxOperations, projectId) local valid, validationError = Operations.validate(changeSet, maxOperations) if not valid then return nil, validationError end local conflicts = {} local seenConflicts = {} local conflictingProjectIds = {} local expected = {} for _, operation in ipairs(changeSet.operations) do local targetPath = operationTarget(operation) if targetPath then expected[targetPath] = true end local existing = existingTarget(operation) if existing and not ProjectRegistry.isManagedBy(existing, projectId) then appendUnique( conflicts, seenConflicts, targetPath or existing:GetFullName() ) local otherProjectId = ProjectRegistry.managedProjectId(existing) if otherProjectId and otherProjectId ~= projectId then conflictingProjectIds[otherProjectId] = true end for _, path in ipairs( ProjectRegistry.unmanagedDescendantPaths( existing, otherProjectId or projectId ) ) do appendUnique(conflicts, seenConflicts, path) end elseif existing and expectedClass(operation) and existing.ClassName ~= expectedClass(operation) then for _, path in ipairs( ProjectRegistry.unmanagedDescendantPaths(existing, projectId) ) do appendUnique(conflicts, seenConflicts, path) end end end for _, instance in ipairs(ProjectRegistry.managedInstances(projectId)) do local path = InstancePath.fromInstance(instance) if path and not expected[path] then for _, descendantPath in ipairs( ProjectRegistry.unmanagedDescendantPaths(instance, projectId) ) do appendUnique(conflicts, seenConflicts, descendantPath) end end end for conflictingProjectId in pairs(conflictingProjectIds) do for _, instance in ipairs( ProjectRegistry.managedInstances(conflictingProjectId) ) do for _, descendantPath in ipairs( ProjectRegistry.unmanagedDescendantPaths( instance, conflictingProjectId ) ) do appendUnique(conflicts, seenConflicts, descendantPath) end end end return { conflicts = conflicts, hasUnmanagedConflicts = #conflicts > 0, conflictingProjectIds = conflictingProjectIds, } end local function applyProperties(instance, properties) for property, value in pairs(properties or {}) do instance[property] = decodeValue(value) end end local function prepareExisting(existing, expectedClass, context, targetPath) if not existing then return nil end if not ProjectRegistry.isManagedBy(existing, context.projectId) and not context.allowUnmanaged then error("Objet non géré à confirmer avant remplacement : " .. tostring(targetPath)) end if expectedClass and existing.ClassName ~= expectedClass then existing:Destroy() return nil end return existing end local function applyOperation(operation, projectContext, warnings) if operation.op == "create_instance" then local parent = InstancePath.resolve(operation.parent) if not parent then return end local instance = parent:FindFirstChild(operation.name) if projectContext then instance = prepareExisting( instance, operation.className, projectContext, operationTarget(operation) ) elseif instance then return end if not instance then instance = Instance.new(operation.className) instance.Name = operation.name instance.Parent = parent end applyProperties(instance, operation.properties) if projectContext then ProjectRegistry.markManaged(instance, projectContext.projectId, projectContext.version) end elseif operation.op == "set_property" then local target = InstancePath.resolve(operation.path) if target then if projectContext then prepareExisting(target, nil, projectContext, operation.path) end target[operation.property] = decodeValue(operation.value) if projectContext then ProjectRegistry.markManaged(target, projectContext.projectId, projectContext.version) end end elseif operation.op == "create_script" then local parent = InstancePath.resolve(operation.parent) if not parent then return end local instance = parent:FindFirstChild(operation.name) if projectContext then instance = prepareExisting( instance, operation.scriptType, projectContext, operationTarget(operation) ) elseif instance then return end if not instance then instance = Instance.new(operation.scriptType) instance.Name = operation.name instance.Parent = parent end instance.Source = operation.source if projectContext then ProjectRegistry.markManaged(instance, projectContext.projectId, projectContext.version) end elseif operation.op == "replace_script_source" then local target = InstancePath.resolve(operation.path) if target then if projectContext then prepareExisting(target, nil, projectContext, operation.path) end target.Source = operation.source if projectContext then ProjectRegistry.markManaged(target, projectContext.projectId, projectContext.version) end end elseif operation.op == "create_remote" then local parent = InstancePath.resolve(operation.parent) if not parent then return end local instance = parent:FindFirstChild(operation.name) if projectContext then instance = prepareExisting( instance, operation.remoteType, projectContext, operationTarget(operation) ) elseif instance then return end if not instance then instance = Instance.new(operation.remoteType) instance.Name = operation.name instance.Parent = parent end if projectContext then ProjectRegistry.markManaged(instance, projectContext.projectId, projectContext.version) end elseif operation.op == "destroy_instance" then local target = InstancePath.resolve(operation.path) if target then if projectContext then prepareExisting(target, nil, projectContext, operation.path) end target:Destroy() end elseif operation.op == "insert_asset" then local parent = InstancePath.resolve(operation.parent) if not parent then return end local assetName = operation.name or ("Asset_" .. tostring(operation.assetId)) local existing = parent:FindFirstChild(assetName) if projectContext then existing = prepareExisting( existing, nil, projectContext, operation.parent .. "/" .. assetName ) elseif existing then return -- déjà présent : pas de doublon end if existing then return -- déjà présent (géré) : pas de doublon end -- Chargement PAR ID + scan (retrait des scripts tiers) — jamais aveugle. -- NON BLOQUANT : si Roblox refuse l'insertion, on note un warning et on -- continue ; le reste du projet se charge quand même. local okLoad, model, report = pcall(function() return Assets.load(operation.assetId, operation.stripScripts) end) if not okLoad then model = nil report = "Insertion refusée par Roblox." end if not model then if warnings then table.insert( warnings, string.format( "Asset %s non inséré (%s).", tostring(operation.assetId), tostring(report or "indisponible") ) ) end return end model.Name = assetName model:SetAttribute("BloxForgeAssetId", operation.assetId) model:SetAttribute("BloxForgeAssetSource", operation.source) if projectContext then model:SetAttribute("BloxForgeProjectId", projectContext.projectId) model:SetAttribute("BloxForgeVersion", projectContext.version) end model.Parent = parent if projectContext then ProjectRegistry.markManaged( model, projectContext.projectId, projectContext.version ) end elseif operation.op == "create_cluster" then -- Contrat v2 : crée un dossier conteneur puis étend le cluster en interne -- (nombreuses instances) de façon déterministe. local parent = InstancePath.resolve(operation.parent) if not parent then return end local folder = parent:FindFirstChild(operation.name) if projectContext then folder = prepareExisting( folder, "Folder", projectContext, operation.parent .. "/" .. operation.name ) elseif folder then return end if not folder then folder = Instance.new("Folder") folder.Name = operation.name folder.Parent = parent else -- Clean scene : rejoue proprement un cluster déjà présent (pas d'empilement). folder:ClearAllChildren() end local okCluster, clusterErr = ClusterBuilder.build(folder, operation.cluster) if not okCluster and warnings then table.insert( warnings, string.format( "Cluster %s partiel (%s).", tostring(operation.cluster.kind), tostring(clusterErr) ) ) end if projectContext then ProjectRegistry.markManaged(folder, projectContext.projectId, projectContext.version) end end end -- Vrai uniquement quand une application Voxoria a créé un waypoint annulable. local canUndo = false -- Clean scene : masque (sans supprimer) la Baseplate Roblox par défaut pour éviter -- qu'elle apparaisse sous la map Voxoria (z-fighting, sols superposés). On ne touche -- QUE la Baseplate par défaut (jamais du contenu utilisateur), et on la MASQUE -- (Transparency + CanCollide) au lieu de la détruire → réversible via Undo. local function hideDefaultBaseplate(warnings) local base = workspace:FindFirstChild("Baseplate") if base and base:IsA("BasePart") and base.Transparency < 1 then base.Transparency = 1 base.CanCollide = false base.CanTouch = false if warnings then table.insert(warnings, "Baseplate Roblox par défaut masquée (scène propre).") end return true end return false end function Operations.apply(changeSet, maxOperations) local valid, validationError = Operations.validate(changeSet, maxOperations) if not valid then return false, validationError end -- API moderne : un seul recording = un seul point d'annulation propre. local recording = ChangeHistoryService:TryBeginRecording( "Voxoria : application du projet" ) if not recording then return false, "Studio n'a pas pu créer le point d'annulation. Réessaie hors Play Mode." end -- RÉSILIENCE : chaque opération est appliquée dans son propre pcall. Une op -- fautive est IGNORÉE (jamais de rollback total) et rapportée dans `skipped`. local warnings = {} local skipped = {} hideDefaultBaseplate(warnings) for _, operation in ipairs(changeSet.operations) do local okOp, errOp = pcall(applyOperation, operation, nil, warnings) if not okOp then table.insert(skipped, { op = tostring(operation.op), error = tostring(errOp) }) table.insert( warnings, string.format("Opération %s ignorée (%s).", tostring(operation.op), tostring(errOp)) ) end end ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit) canUndo = true return true, nil, warnings, skipped end local function depth(instance) local count = 0 local current = instance while current and current ~= game do count += 1 current = current.Parent end return count end local function removeObsoleteManaged(changeSet, projectId) local expected = {} for _, operation in ipairs(changeSet.operations) do local target = operationTarget(operation) if target then expected[target] = true end end local managed = ProjectRegistry.managedInstances(projectId) table.sort(managed, function(a, b) return depth(a) > depth(b) end) for _, instance in ipairs(managed) do local path = InstancePath.fromInstance(instance) if path and not expected[path] then instance:Destroy() end end end function Operations.applyProject(changeSet, maxOperations, context) local valid, validationError = Operations.validate(changeSet, maxOperations) if not valid then return false, validationError end local rootValid, rootError = ProjectRegistry.validateRoot() if not rootValid then return false, rootError end local inspection, inspectionError = Operations.inspectProject( changeSet, maxOperations, context.projectId ) if not inspection then return false, inspectionError end if inspection.hasUnmanagedConflicts and not context.allowUnmanaged then return false, "Des objets non gérés occupent les chemins du projet. Confirme leur remplacement." end local recording = ChangeHistoryService:TryBeginRecording( "Voxoria : charger " .. tostring(context.projectName or "un projet") ) if not recording then return false, "Studio n'a pas pu créer le point de restauration. Réessaie hors Play Mode." end local record local warnings = {} local skipped = {} local ok, err = pcall(function() if context.allowUnmanaged then for conflictingProjectId in pairs(inspection.conflictingProjectIds) do ProjectRegistry.remove(conflictingProjectId) end end removeObsoleteManaged(changeSet, context.projectId) hideDefaultBaseplate(warnings) -- RÉSILIENCE : une op fautive est ignorée (jamais de rollback total). for _, operation in ipairs(changeSet.operations) do local okOp, errOp = pcall(applyOperation, operation, context, warnings) if not okOp then table.insert(skipped, { op = tostring(operation.op), error = tostring(errOp) }) table.insert( warnings, string.format("Opération %s ignorée (%s).", tostring(operation.op), tostring(errOp)) ) end end local fingerprint = ProjectRegistry.fingerprint(context.projectId) local saved, saveError = ProjectRegistry.save( context.projectId, context.projectName, context.version, fingerprint ) if not saved then error(saveError) end record = saved end) ChangeHistoryService:FinishRecording( recording, ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel ) if not ok then return false, "Chargement annulé : " .. tostring(err) end canUndo = true return true, nil, record, warnings, skipped end function Operations.canUndo() return canUndo end function Operations.undo() -- Ne jamais appeler Undo au-delà de l'historique disponible. if not canUndo then return false, "Aucun changement Voxoria à annuler." end canUndo = false local ok = pcall(function() ChangeHistoryService:Undo() end) if not ok then return false, "Annulation indisponible (l'historique Studio a changé)." end return true end return Operations ]]> ProjectContext = MAX_SCENE_OBJECTS or seen[instance] then return end local description = describe(instance) if description then seen[instance] = true table.insert(output, description) end end local function shouldDescribe(instance) return instance:IsA("BasePart") or instance:IsA("Model") or instance:IsA("Folder") or instance:IsA("LuaSourceContainer") or instance:IsA("RemoteEvent") or instance:IsA("RemoteFunction") or instance:IsA("GuiObject") or instance:IsA("LayerCollector") or instance:IsA("Light") end local function isDefaultBaseplate(instance) return instance.Parent == workspace and instance.Name == "Baseplate" and instance:IsA("BasePart") and instance.Size.X >= 256 and instance.Size.Z >= 256 end local function visibleFocusPart(instance) return instance:IsA("BasePart") and instance.Transparency < 0.95 and not isDefaultBaseplate(instance) end local function appendFocusParts(parts, seen, instance) if visibleFocusPart(instance) and not seen[instance] then seen[instance] = true table.insert(parts, instance) end for _, descendant in ipairs(instance:GetDescendants()) do if visibleFocusPart(descendant) and not seen[descendant] then seen[descendant] = true table.insert(parts, descendant) end end end local function bounds(parts) if #parts == 0 then return nil end local minimum = Vector3.new(math.huge, math.huge, math.huge) local maximum = Vector3.new(-math.huge, -math.huge, -math.huge) for _, part in ipairs(parts) do local half = part.Size / 2 minimum = Vector3.new( math.min(minimum.X, part.Position.X - half.X), math.min(minimum.Y, part.Position.Y - half.Y), math.min(minimum.Z, part.Position.Z - half.Z) ) maximum = Vector3.new( math.max(maximum.X, part.Position.X + half.X), math.max(maximum.Y, part.Position.Y + half.Y), math.max(maximum.Z, part.Position.Z + half.Z) ) end return { center = vector3((minimum + maximum) / 2), size = vector3(maximum - minimum), min = vector3(minimum), max = vector3(maximum), } end function ProjectContext.capture() local selected = {} local selection = Selection:Get() for index, instance in ipairs(selection) do if index > 20 then break end local description = describe(instance) if description then table.insert(selected, description) end end local sceneObjects = {} local seenObjects = {} for _, instance in ipairs(selection) do appendDescription(sceneObjects, seenObjects, instance) for _, descendant in ipairs(instance:GetDescendants()) do if shouldDescribe(descendant) then appendDescription(sceneObjects, seenObjects, descendant) end end end for _, serviceName in ipairs(ROOT_SERVICES) do local service = game:GetService(serviceName) for _, instance in ipairs(service:GetDescendants()) do if #sceneObjects >= MAX_SCENE_OBJECTS then break end if shouldDescribe(instance) and instance:GetAttribute("BloxForgeRegistryEntry") ~= true then appendDescription(sceneObjects, seenObjects, instance) end end if #sceneObjects >= MAX_SCENE_OBJECTS then break end end local focusParts = {} local seenParts = {} if #selection > 0 then for _, instance in ipairs(selection) do appendFocusParts(focusParts, seenParts, instance) end else for _, instance in ipairs(workspace:GetDescendants()) do if visibleFocusPart(instance) then table.insert(focusParts, instance) end end end return { placeName = game.Name, placeId = tostring(game.PlaceId), selection = selected, scene = { focus = #selection > 0 and "selection" or "workspace", bounds = bounds(focusParts), objects = sceneObjects, truncated = #sceneObjects >= MAX_SCENE_OBJECTS, }, } end function ProjectContext.selectedScript() local selected = Selection:Get() if #selected ~= 1 then return nil, "Sélectionne exactement un Script, LocalScript ou ModuleScript." end local instance = selected[1] if not instance:IsA("LuaSourceContainer") then return nil, "La sélection n'est pas un script Luau." end local path = InstancePath.fromInstance(instance) if not path then return nil, "Ce script doit se trouver dans un service Roblox reconnu." end local ok, source = pcall(function() return instance.Source end) if not ok then return nil, "Roblox Studio refuse la lecture de ce script." end return { instance = instance, path = path, source = source, } end return ProjectContext ]]> ProjectRegistry depth(b) end) for _, instance in ipairs(instances) do instance:Destroy() end local entry = findEntry(projectId) if entry then entry:Destroy() end end function ProjectRegistry.exportDebug(projectId) local record = ProjectRegistry.get(projectId) if not record then return nil end return HttpService:JSONEncode(record) end ProjectRegistry.Attributes = { ProjectId = PROJECT_ATTRIBUTE, Managed = MANAGED_ATTRIBUTE, Version = VERSION_ATTRIBUTE, } return ProjectRegistry ]]> ProjectSync localVersion then return ProjectSync.State.UpdateAvailable end if cloudVersion < localVersion then return ProjectSync.State.Conflict end if hasLocalChanges then return ProjectSync.State.LocalChanges end return ProjectSync.State.Synced end function ProjectSync.canAutoApply(syncState, context) context = context or {} return syncState == ProjectSync.State.UpdateAvailable and context.inPlayMode ~= true and context.alreadyApplied ~= true and context.ignored ~= true and context.hasUnmanagedConflicts ~= true end function ProjectSync.page(total, offset, limit) local safeLimit = math.max(1, limit or 1) local safeTotal = math.max(0, total or 0) local safeOffset = math.max(0, offset or 0) local current = math.floor(safeOffset / safeLimit) + 1 local count = math.max(1, math.ceil(safeTotal / safeLimit)) return { current = math.min(current, count), count = count, hasPrevious = safeOffset > 0, hasNext = safeOffset + safeLimit < safeTotal, } end function ProjectSync.shouldPoll(nowSeconds, lastPollSeconds, intervalSeconds) if lastPollSeconds == nil then return true end return nowSeconds - lastPollSeconds >= math.max(5, intervalSeconds or 30) end return ProjectSync ]]> ProjectsUI 0 and C.Text or C.TextMuted end end function ProjectsUI:setBusy(busy) self.busy = busy setEnabled(self.refreshButton, self.connected and not busy) setEnabled(self.autoButton, self.connected and not busy) end function ProjectsUI:setLoading(loading) self.loading = loading if loading then self:showStatus("Synchronisation avec le cloud Voxoria…", "info") end self:render() end function ProjectsUI:setError(message) self.loading = false self:showStatus(message, "error") self:render() end function ProjectsUI:showStatus(message, kind) self.status.Visible = message ~= nil and message ~= "" self.statusText.Text = message or "" if kind == "error" then self.status.BackgroundColor3 = C.DangerSoft self.statusText.TextColor3 = C.Danger elseif kind == "success" then self.status.BackgroundColor3 = C.SuccessSoft self.statusText.TextColor3 = C.Success elseif kind == "warning" then self.status.BackgroundColor3 = C.WarningSoft self.statusText.TextColor3 = C.Warning else self.status.BackgroundColor3 = C.SurfaceRaised self.statusText.TextColor3 = C.TextMuted end end function ProjectsUI:setProjects(projects, total, limit, offset) self.loading = false self.projects = projects or {} self.total = total or #self.projects self.limit = limit or self.limit self.offset = offset or 0 self.list.Visible = true self.footer.Visible = true self.detail.Visible = false self:render() end function ProjectsUI:_emptyCard(titleText, bodyText, actionText, action) local card = Instance.new("Frame") card.Size = UDim2.new(1, 0, 0, 150) card.BackgroundColor3 = C.Surface card.BorderSizePixel = 0 card.Parent = self.list corner(card, 12) stroke(card, C.Border) padding(card, 16, 16, 16, 16) local title = label(card, titleText, 15, C.Text, Theme.FontBold) title.Size = UDim2.new(1, 0, 0, 24) local body = label(card, bodyText, 12, C.TextMuted) body.Position = UDim2.fromOffset(0, 30) body.Size = UDim2.new(1, 0, 0, 48) body.TextWrapped = true body.TextYAlignment = Enum.TextYAlignment.Top if actionText then local actionButton = button(card, actionText, "primary") actionButton.AnchorPoint = Vector2.new(0, 1) actionButton.Position = UDim2.new(0, 0, 1, 0) actionButton.Size = UDim2.new(1, 0, 0, 36) actionButton.MouseButton1Click:Connect(action) end end function ProjectsUI:_projectCard(project) -- Copilote : la zone « Que veux-tu modifier ? » n'apparaît que sur un projet -- déjà chargé dans CE Studio (local_version connue). local editable = project.local_version ~= nil local card = Instance.new("Frame") card.Size = UDim2.new(1, 0, 0, editable and 352 or 198) card.BackgroundColor3 = C.Surface card.BorderSizePixel = 0 card.Parent = self.list corner(card, 12) stroke(card, project.sync_state == ProjectSync.State.Conflict and C.Danger or C.Border) padding(card, 13, 13, 13, 13) local title = label(card, project.name or "Projet sans nom", 14, C.Text, Theme.FontBold) title.Size = UDim2.new(1, -138, 0, 22) title.TextTruncate = Enum.TextTruncate.AtEnd local state = project.sync_state or ProjectSync.State.NotLoaded local pill = Instance.new("Frame") pill.AnchorPoint = Vector2.new(1, 0) pill.Position = UDim2.new(1, 0, 0, 0) pill.Size = UDim2.fromOffset(132, 24) pill.BackgroundColor3 = STATE_BACKGROUNDS[state] or C.SurfaceRaised pill.BorderSizePixel = 0 pill.Parent = card corner(pill, 12) local pillText = label( pill, STATE_LABELS[state] or string.upper(state), 8, STATE_COLORS[state] or C.TextMuted, Theme.FontBold ) pillText.Size = UDim2.fromScale(1, 1) pillText.TextXAlignment = Enum.TextXAlignment.Center local metadata = label( card, string.format( "%s · %s · %s", project.genre or "Genre non défini", project.kind or "project", formatDate(project.updated_at) ), 10, C.TextDim ) metadata.Position = UDim2.fromOffset(0, 25) metadata.Size = UDim2.new(1, 0, 0, 17) metadata.TextTruncate = Enum.TextTruncate.AtEnd local versions = label(card, syncDetails(project), 11, C.TextMuted, Theme.FontMedium) versions.Position = UDim2.fromOffset(0, 46) versions.Size = UDim2.new(1, 0, 0, 18) local summary = label( card, project.summary or "Projet Voxoria prêt à charger dans Studio.", 11, C.TextMuted ) summary.Position = UDim2.fromOffset(0, 69) summary.Size = UDim2.new(1, 0, 0, 45) summary.TextWrapped = true summary.TextYAlignment = Enum.TextYAlignment.Top summary.TextTruncate = Enum.TextTruncate.AtEnd local primaryText = state == ProjectSync.State.UpdateAvailable and "PRÉVISUALISER" or state == ProjectSync.State.Conflict and "RÉSOUDRE LE CONFLIT" or state == ProjectSync.State.LocalChanges and "VOIR LES CHANGEMENTS" or "CHARGER DANS STUDIO" local load = button(card, primaryText, "primary") load.Position = UDim2.fromOffset(0, 122) load.Size = UDim2.new(1, 0, 0, 34) load.MouseButton1Click:Connect(function() self.callbacks.onLoad(project) end) setEnabled( load, state ~= ProjectSync.State.Loading and state ~= ProjectSync.State.Error ) local check = button(card, "VÉRIFIER MAINTENANT", "ghost") check.Position = UDim2.fromOffset(0, 162) check.Size = UDim2.new(0.56, -4, 0, 28) check.MouseButton1Click:Connect(function() self.callbacks.onCheck(project) end) local history = button(card, "HISTORIQUE", "ghost") history.AnchorPoint = Vector2.new(1, 0) history.Position = UDim2.new(1, 0, 0, 162) history.Size = UDim2.new(0.44, -4, 0, 28) history.MouseButton1Click:Connect(function() self.callbacks.onHistory(project) end) if not editable then return end -- ── Live Project Copilot : modifier le projet EN COURS depuis Studio ── local editTitle = label(card, "QUE VEUX-TU MODIFIER ?", 10, C.TextMuted, Theme.FontBold) editTitle.Position = UDim2.fromOffset(0, 200) editTitle.Size = UDim2.new(1, 0, 0, 14) local box = Instance.new("TextBox") box.Position = UDim2.fromOffset(0, 218) box.Size = UDim2.new(1, 0, 0, 32) box.BackgroundColor3 = C.SurfaceRaised box.BorderSizePixel = 0 box.Text = "" box.PlaceholderText = "ex : Ajoute une toile sur le mur" box.PlaceholderColor3 = C.TextDim box.TextColor3 = C.Text box.TextSize = 12 box.Font = Theme.Font box.TextXAlignment = Enum.TextXAlignment.Left box.ClearTextOnFocus = false box.ClipsDescendants = true box.Parent = card corner(box, 8) padding(box, 6, 8, 6, 8) local function quickChip(text, x, width, y) local chip = button(card, text, "ghost") chip.Position = UDim2.new(x, x > 0 and 3 or 0, 0, y) chip.Size = UDim2.new(width, -3, 0, 22) chip.TextSize = 9 chip.MouseButton1Click:Connect(function() box.Text = text end) return chip end quickChip("Améliore cette zone", 0, 0.34, 254) quickChip("Ajoute une lumière ici", 0.34, 0.33, 254) quickChip("Ajoute un objet déco", 0.67, 0.33, 254) quickChip("Corrige ce qui cloche", 0, 0.5, 280) quickChip("Continue la roadmap", 0.5, 0.5, 280) local applyEdit = button(card, "APPLIQUER LA MODIFICATION", "primary") applyEdit.Position = UDim2.fromOffset(0, 308) applyEdit.Size = UDim2.new(1, 0, 0, 32) applyEdit.MouseButton1Click:Connect(function() local text = string.gsub(box.Text, "^%s*(.-)%s*$", "%1") if #text < 2 then return end box.Text = "" self.callbacks.onEdit(project, text) end) end function ProjectsUI:render() clear(self.list) self.detail.Visible = false self.list.Visible = true self.footer.Visible = true if not self.connected then self:_emptyCard( "Compte requis", "Connecte ton compte Voxoria pour retrouver tes projets cloud.", nil, nil ) elseif self.loading then self:_emptyCard( "Chargement des projets…", "Voxoria vérifie les versions cloud et l'état local de cette place.", nil, nil ) elseif #self.projects == 0 then self:_emptyCard( "Aucun projet pour le moment", "Crée ton premier jeu sur Voxoria, puis reviens ici pour le charger dans Studio.", "ACTUALISER", function() self.callbacks.onRefresh() end ) else for _, project in ipairs(self.projects) do self:_projectCard(project) end end local page = ProjectSync.page(self.total, self.offset, self.limit) self.pageText.Text = string.format("Page %d / %d", page.current, page.count) setEnabled(self.previousButton, page.hasPrevious and not self.loading) setEnabled(self.nextButton, page.hasNext and not self.loading) self.footer.Visible = self.connected and not self.loading and self.total > 0 end function ProjectsUI:_detailHeader(titleText, subtitleText) local header = Instance.new("Frame") header.AutomaticSize = Enum.AutomaticSize.Y header.Size = UDim2.new(1, 0, 0, 0) header.BackgroundColor3 = C.Surface header.BorderSizePixel = 0 header.Parent = self.detail corner(header, 12) stroke(header, C.Border) padding(header, 13, 13, 13, 13) local layout = Instance.new("UIListLayout") layout.Padding = UDim.new(0, 6) layout.Parent = header local back = button(header, "← RETOUR AUX PROJETS", "ghost") back.Size = UDim2.new(1, 0, 0, 30) back.MouseButton1Click:Connect(function() self.detail.Visible = false self.list.Visible = true self.footer.Visible = self.total > 0 end) local title = label(header, titleText, 16, C.Text, Theme.FontBold) title.Size = UDim2.new(1, 0, 0, 24) local subtitle = label(header, subtitleText, 11, C.TextMuted) subtitle.AutomaticSize = Enum.AutomaticSize.Y subtitle.Size = UDim2.new(1, 0, 0, 0) subtitle.TextWrapped = true subtitle.TextYAlignment = Enum.TextYAlignment.Top end function ProjectsUI:showPreview(project, payload, previewText, conflicts) clear(self.detail) self.list.Visible = false self.footer.Visible = false self.detail.Visible = true self:_detailHeader( project.name or payload.name, string.format( "Version cloud v%d · %d opération(s)", payload.version or 0, #(payload.changeSet.operations or {}) ) ) local summaryCard = Instance.new("Frame") summaryCard.AutomaticSize = Enum.AutomaticSize.Y summaryCard.Size = UDim2.new(1, 0, 0, 0) summaryCard.BackgroundColor3 = C.SurfaceRaised summaryCard.BorderSizePixel = 0 summaryCard.Parent = self.detail corner(summaryCard, 10) padding(summaryCard, 12, 12, 12, 12) local summary = label( summaryCard, payload.changeSet.summary or "Changements Voxoria", 12, C.Text ) summary.AutomaticSize = Enum.AutomaticSize.Y summary.Size = UDim2.new(1, 0, 0, 0) summary.TextWrapped = true summary.TextYAlignment = Enum.TextYAlignment.Top local diff = Instance.new("TextBox") diff.Size = UDim2.new(1, 0, 0, math.clamp(180 + (#previewText / 14), 220, 480)) diff.BackgroundColor3 = C.Code diff.BorderSizePixel = 0 diff.ClearTextOnFocus = false diff.MultiLine = true diff.TextEditable = false diff.Text = previewText diff.TextColor3 = C.TextMuted diff.TextSize = 10 diff.Font = Theme.FontCode diff.TextWrapped = false diff.TextXAlignment = Enum.TextXAlignment.Left diff.TextYAlignment = Enum.TextYAlignment.Top diff.Parent = self.detail corner(diff, 10) stroke(diff, C.Border) padding(diff, 10, 10, 10, 10) if #(conflicts or {}) > 0 then local warning = label( self.detail, "Attention : une confirmation renforcée est requise avant de remplacer l'état local ou des objets existants.\n" .. table.concat(conflicts, "\n"), 11, C.Danger ) warning.AutomaticSize = Enum.AutomaticSize.Y warning.Size = UDim2.new(1, 0, 0, 0) warning.BackgroundTransparency = 0 warning.BackgroundColor3 = C.DangerSoft warning.TextWrapped = true warning.TextYAlignment = Enum.TextYAlignment.Top corner(warning, 10) padding(warning, 10, 10, 10, 10) end local actionCard = Instance.new("Frame") actionCard.Size = UDim2.new(1, 0, 0, 118) actionCard.BackgroundColor3 = C.Surface actionCard.BorderSizePixel = 0 actionCard.Parent = self.detail corner(actionCard, 10) stroke(actionCard, C.Border) padding(actionCard, 10, 10, 10, 10) local apply = button( actionCard, #(conflicts or {}) > 0 and "REMPLACER ET CHARGER" or "APPLIQUER DANS STUDIO", "primary" ) apply.Size = UDim2.new(1, 0, 0, 38) apply.MouseButton1Click:Connect(function() self.callbacks.onApply(project, payload, #(conflicts or {}) > 0) end) local ignore = button(actionCard, "IGNORER CETTE VERSION", "ghost") ignore.Position = UDim2.fromOffset(0, 46) ignore.Size = UDim2.new(0.56, -4, 0, 34) ignore.MouseButton1Click:Connect(function() self.callbacks.onIgnore(project, payload.version) end) local later = button(actionCard, "PLUS TARD", "ghost") later.AnchorPoint = Vector2.new(1, 0) later.Position = UDim2.new(1, 0, 0, 46) later.Size = UDim2.new(0.44, -4, 0, 34) later.MouseButton1Click:Connect(function() self.callbacks.onLater() end) end function ProjectsUI:showHistory(project, versions) clear(self.detail) self.list.Visible = false self.footer.Visible = false self.detail.Visible = true self:_detailHeader( "Historique · " .. tostring(project.name), "Choisis une version pour voir son résumé et la prévisualiser avant chargement." ) if #(versions or {}) == 0 then local empty = label(self.detail, "Aucune version disponible.", 12, C.TextMuted) empty.Size = UDim2.new(1, 0, 0, 44) return end for _, version in ipairs(versions) do local row = Instance.new("Frame") row.Size = UDim2.new(1, 0, 0, 92) row.BackgroundColor3 = C.Surface row.BorderSizePixel = 0 row.Parent = self.detail corner(row, 10) stroke(row, C.Border) padding(row, 11, 11, 11, 11) local title = label( row, string.format("v%d · %s", version.version_number, version.label or "Version"), 12, C.Text, Theme.FontBold ) title.Size = UDim2.new(1, -96, 0, 20) title.TextTruncate = Enum.TextTruncate.AtEnd local summary = label( row, version.change_summary or formatDate(version.created_at), 10, C.TextMuted ) summary.Position = UDim2.fromOffset(0, 24) summary.Size = UDim2.new(1, -96, 0, 42) summary.TextWrapped = true summary.TextYAlignment = Enum.TextYAlignment.Top local preview = button(row, "APERÇU", "ghost") preview.AnchorPoint = Vector2.new(1, 0.5) preview.Position = UDim2.new(1, 0, 0.5, 0) preview.Size = UDim2.fromOffset(86, 34) preview.MouseButton1Click:Connect(function() self.callbacks.onLoad(project, version.version_number) end) end end return ProjectsUI ]]> State Theme UI 0 self.warnings.Text = #warnings > 0 and ("Attention : " .. table.concat(warnings, "\n")) or "" self.applyRow.Visible = true self.hasPendingChanges = true setButtonEnabled(self.applyButton, true) end function UI:clearChangeSet() self.hasPendingChanges = false self.resultCard.Visible = false self.applyRow.Visible = false self.preview.Visible = false self.warnings.Visible = false end function UI:setUndoAvailable(available) self.canUndo = available setButtonEnabled(self.undoButton, available) end function UI:setContext(text) self.contextText.Text = "Contexte : " .. text end function UI:setProjectsAutoSync(enabled) self.projectsView:setAutoSync(enabled) end function UI:setProjectsPollInterval(seconds) self.projectsView:setPollInterval(seconds) end function UI:setProjectsLoading(loading) self.projectsView:setLoading(loading) end function UI:setProjectsBusy(busy) self.projectsView:setBusy(busy) end function UI:setProjectsError(message) self.projectsView:setError(message) end function UI:setProjects(projects, total, limit, offset) self.projectsView:setProjects(projects, total, limit, offset) end function UI:showProjectPreview(project, payload, previewText, conflicts) self.projectsView:showPreview(project, payload, previewText, conflicts) end function UI:showProjectHistory(project, versions) self.projectsView:showHistory(project, versions) end function UI:showProjectsStatus(message, kind) self.projectsView:showStatus(message, kind) end return UI ]]> Version b ; nil si l'une est illisible. function Version.compare(a, b) local a1, a2, a3 = Version.parse(a) local b1, b2, b3 = Version.parse(b) if a1 == nil or b1 == nil then return nil end if a1 ~= b1 then return a1 < b1 and -1 or 1 end if a2 ~= b2 then return a2 < b2 and -1 or 1 end if a3 ~= b3 then return a3 < b3 and -1 or 1 end return 0 end -- "current" | "update_available" | "update_required" ; nil si données illisibles. function Version.status(localVersion, latest, minimum) local belowMinimum = Version.compare(localVersion, minimum) local belowLatest = Version.compare(localVersion, latest) if belowLatest == nil then return nil end if belowMinimum ~= nil and belowMinimum < 0 then return "update_required" end if belowLatest < 0 then return "update_available" end return "current" end return Version ]]>