diff --git a/newIDE/app/scripts/import-libGD.js b/newIDE/app/scripts/import-libGD.js index 2accaaeac2f9..7c705358265e 100644 --- a/newIDE/app/scripts/import-libGD.js +++ b/newIDE/app/scripts/import-libGD.js @@ -66,17 +66,28 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { return branch; }; - // Try to download libGD.js from a specific commit on the current branch - const downloadCommitLibGdJs = (branch, gitRef) => + const getHashFromGitRef = gitRef => { + const hashShellString = shell.exec(`git rev-parse "${gitRef}"`, { + silent: true, + }); + const hash = (hashShellString.stdout || 'unknown-hash').trim(); + + if (hashShellString.stderr || hashShellString.code) { + shell.echo(`⚠️ Can't find the hash of the associated commit.`); + return null; + } + + return hash; + }; + + // Try to download libGD.js from a specific commit on the inferred branch. + const downloadCommitLibGdJs = gitRef => new Promise((resolve, reject) => { shell.echo(`ℹ️ Trying to download libGD.js for ${gitRef}.`); - var hashShellString = shell.exec(`git rev-parse "${gitRef}"`, { - silent: true, - }); - const hash = (hashShellString.stdout || 'unknown-hash').trim(); + const hash = getHashFromGitRef(gitRef); const branch = getBranchFromGitRef(gitRef); - if (hashShellString.stderr || hashShellString.code || !branch) { + if (!hash || !branch) { shell.echo( `⚠️ Can't find the hash or branch of the associated commit.` ); @@ -91,6 +102,25 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { ); }); + const downloadMasterCommitLibGdJs = gitRef => + new Promise((resolve, reject) => { + shell.echo( + `ℹ️ Trying to download libGD.js for ${gitRef} from master.` + ); + + const hash = getHashFromGitRef(gitRef); + if (!hash) { + reject(); + return; + } + + resolve( + downloadLibGdJs( + `https://s3.amazonaws.com/gdevelop-gdevelop.js/master/commit/${hash}` + ) + ); + }); + // Try to download libGD.js from the latest version built for master branch. const downloadBranchLatestLibGdJs = branchName => { shell.echo( @@ -151,48 +181,55 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { }; const branch = getBranchFromGitRef('HEAD'); + const downloadCommitLibGdJsWithMasterFallback = gitRef => + downloadCommitLibGdJs(gitRef).catch(() => + downloadMasterCommitLibGdJs(gitRef) + ); + const tryDownloadInOrder = downloaders => + downloaders.reduce( + (previousDownload, download) => previousDownload.catch(() => download()), + Promise.reject() + ); // Try to download the latest libGD.js, fallback to previous or master ones // if not found (including different parents, for handling of merge commits). - downloadCommitLibGdJs(branch, 'HEAD').then(onLibGdJsDownloaded, () => { - // Force the exact version of GDevelop.js to be downloaded for AppVeyor - because - // this means we build the app and we don't want to risk mismatch (Core C++ not up to date - // with the IDE JavaScript). - if (process.env.APPVEYOR || process.env.REQUIRES_EXACT_LIBGD_JS_VERSION) { - shell.echo( - `❌ Can't download the exact required version of libGD.js - check it was built by CircleCI before running this CI.` - ); - shell.echo( - `ℹ️ See the pipeline on https://app.circleci.com/pipelines/github/4ian/GDevelop.` - ); - shell.exit(1); - } + downloadCommitLibGdJsWithMasterFallback('HEAD').then( + onLibGdJsDownloaded, + () => { + // Force the exact version of GDevelop.js to be downloaded for AppVeyor - because + // this means we build the app and we don't want to risk mismatch (Core C++ not up to date + // with the IDE JavaScript). + if (process.env.APPVEYOR || process.env.REQUIRES_EXACT_LIBGD_JS_VERSION) { + shell.echo( + `❌ Can't download the exact required version of libGD.js - check it was built by CircleCI before running this CI.` + ); + shell.echo( + `ℹ️ See the pipeline on https://app.circleci.com/pipelines/github/4ian/GDevelop.` + ); + shell.exit(1); + } - downloadCommitLibGdJs(branch, 'HEAD~1').then(onLibGdJsDownloaded, () => - downloadCommitLibGdJs(branch, 'HEAD~2').then(onLibGdJsDownloaded, () => - downloadCommitLibGdJs(branch, 'HEAD~3').then(onLibGdJsDownloaded, () => - downloadBranchLatestLibGdJs(branch).then(onLibGdJsDownloaded, () => - downloadBranchLatestLibGdJs('master').then( - onLibGdJsDownloaded, - () => { - if (alreadyHasLibGdJs) { - shell.echo( - `ℹ️ Can't download any version of libGD.js, assuming you can go ahead with the existing one.` - ); - shell.exit(0); - return; - } else { - shell.echo( - `❌ Can't download any version of libGD.js, please check your internet connection.` - ); - shell.exit(1); - return; - } - } - ) - ) - ) - ) - ); - }); + tryDownloadInOrder([ + () => downloadCommitLibGdJsWithMasterFallback('HEAD~1'), + () => downloadCommitLibGdJsWithMasterFallback('HEAD~2'), + () => downloadCommitLibGdJsWithMasterFallback('HEAD~3'), + () => downloadBranchLatestLibGdJs(branch), + () => downloadBranchLatestLibGdJs('master'), + ]).then(onLibGdJsDownloaded, () => { + if (alreadyHasLibGdJs) { + shell.echo( + `ℹ️ Can't download any version of libGD.js, assuming you can go ahead with the existing one.` + ); + shell.exit(0); + return; + } else { + shell.echo( + `❌ Can't download any version of libGD.js, please check your internet connection.` + ); + shell.exit(1); + return; + } + }); + } + ); } diff --git a/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalEditorSettingsStorage.js b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalEditorSettingsStorage.js new file mode 100644 index 000000000000..09d77b2a832a --- /dev/null +++ b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalEditorSettingsStorage.js @@ -0,0 +1,216 @@ +// @flow +import optionalRequire from '../../Utils/OptionalRequire'; + +const path = optionalRequire('path'); + +export const editorSettingsDirectoryName = '.gdevelop'; +export const editorSettingsFileSuffix = '.editor-settings.json'; + +export type ProjectEditorSettings = {| + layoutSettings: { [layoutName: string]: Object }, + externalLayoutSettings: { [externalLayoutName: string]: Object }, + eventsBasedObjectVariantSettings: { + [variantIdentifier: string]: Object, + }, +|}; + +export const getProjectEditorSettingsFilePath = (filePath: string): string => { + if (!path) { + throw new Error('Filesystem paths are not supported.'); + } + + return path.join( + path.dirname(filePath), + editorSettingsDirectoryName, + path.basename(filePath, path.extname(filePath)) + editorSettingsFileSuffix + ); +}; + +const makeProjectEditorSettings = (): ProjectEditorSettings => ({ + layoutSettings: {}, + externalLayoutSettings: {}, + eventsBasedObjectVariantSettings: {}, +}); + +const hasOwn = (object: Object, propertyName: string): boolean => + Object.keys(object).indexOf(propertyName) !== -1; + +const isEmpty = (object: Object): boolean => Object.keys(object).length === 0; + +const makeVariantIdentifier = ( + extensionName: string, + objectName: string, + variantName: string +): string => `${extensionName}::${objectName}::${variantName}`; + +export const extractProjectEditorSettings = ( + serializedProjectObject: Object +): ?ProjectEditorSettings => { + const projectEditorSettings = makeProjectEditorSettings(); + + if (Array.isArray(serializedProjectObject.layouts)) { + serializedProjectObject.layouts.forEach(layout => { + if ( + layout && + typeof layout.name === 'string' && + hasOwn(layout, 'uiSettings') + ) { + projectEditorSettings.layoutSettings[layout.name] = layout.uiSettings; + delete layout.uiSettings; + } + }); + } + + if (Array.isArray(serializedProjectObject.externalLayouts)) { + serializedProjectObject.externalLayouts.forEach(externalLayout => { + if ( + externalLayout && + typeof externalLayout.name === 'string' && + hasOwn(externalLayout, 'editionSettings') + ) { + projectEditorSettings.externalLayoutSettings[externalLayout.name] = + externalLayout.editionSettings; + delete externalLayout.editionSettings; + } + }); + } + + if (Array.isArray(serializedProjectObject.eventsFunctionsExtensions)) { + serializedProjectObject.eventsFunctionsExtensions.forEach(extension => { + if ( + !extension || + typeof extension.name !== 'string' || + !Array.isArray(extension.eventsBasedObjects) + ) { + return; + } + + extension.eventsBasedObjects.forEach(eventsBasedObject => { + if (!eventsBasedObject || typeof eventsBasedObject.name !== 'string') { + return; + } + + if (hasOwn(eventsBasedObject, 'editionSettings')) { + projectEditorSettings.eventsBasedObjectVariantSettings[ + makeVariantIdentifier(extension.name, eventsBasedObject.name, '') + ] = eventsBasedObject.editionSettings; + delete eventsBasedObject.editionSettings; + } + + if (Array.isArray(eventsBasedObject.variants)) { + eventsBasedObject.variants.forEach(variant => { + if ( + variant && + typeof variant.name === 'string' && + hasOwn(variant, 'editionSettings') + ) { + projectEditorSettings.eventsBasedObjectVariantSettings[ + makeVariantIdentifier( + extension.name, + eventsBasedObject.name, + variant.name + ) + ] = variant.editionSettings; + delete variant.editionSettings; + } + }); + } + }); + }); + } + + if ( + isEmpty(projectEditorSettings.layoutSettings) && + isEmpty(projectEditorSettings.externalLayoutSettings) && + isEmpty(projectEditorSettings.eventsBasedObjectVariantSettings) + ) { + return null; + } + + return projectEditorSettings; +}; + +export const applyProjectEditorSettings = ( + serializedProjectObject: Object, + projectEditorSettings: ?ProjectEditorSettings +) => { + if (!projectEditorSettings) return; + + const { layoutSettings } = projectEditorSettings; + if (layoutSettings && Array.isArray(serializedProjectObject.layouts)) { + serializedProjectObject.layouts.forEach(layout => { + if ( + layout && + typeof layout.name === 'string' && + layoutSettings[layout.name] + ) { + layout.uiSettings = layoutSettings[layout.name]; + } + }); + } + + const { externalLayoutSettings } = projectEditorSettings; + if ( + externalLayoutSettings && + Array.isArray(serializedProjectObject.externalLayouts) + ) { + serializedProjectObject.externalLayouts.forEach(externalLayout => { + if ( + externalLayout && + typeof externalLayout.name === 'string' && + externalLayoutSettings[externalLayout.name] + ) { + externalLayout.editionSettings = + externalLayoutSettings[externalLayout.name]; + } + }); + } + + const { eventsBasedObjectVariantSettings } = projectEditorSettings; + if ( + eventsBasedObjectVariantSettings && + Array.isArray(serializedProjectObject.eventsFunctionsExtensions) + ) { + serializedProjectObject.eventsFunctionsExtensions.forEach(extension => { + if ( + !extension || + typeof extension.name !== 'string' || + !Array.isArray(extension.eventsBasedObjects) + ) { + return; + } + + extension.eventsBasedObjects.forEach(eventsBasedObject => { + if (!eventsBasedObject || typeof eventsBasedObject.name !== 'string') { + return; + } + + const defaultVariantSettings = + eventsBasedObjectVariantSettings[ + makeVariantIdentifier(extension.name, eventsBasedObject.name, '') + ]; + if (defaultVariantSettings) { + eventsBasedObject.editionSettings = defaultVariantSettings; + } + + if (Array.isArray(eventsBasedObject.variants)) { + eventsBasedObject.variants.forEach(variant => { + if (!variant || typeof variant.name !== 'string') return; + + const variantSettings = + eventsBasedObjectVariantSettings[ + makeVariantIdentifier( + extension.name, + eventsBasedObject.name, + variant.name + ) + ]; + if (variantSettings) { + variant.editionSettings = variantSettings; + } + }); + } + }); + }); + } +}; diff --git a/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalEditorSettingsStorage.spec.js b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalEditorSettingsStorage.spec.js new file mode 100644 index 000000000000..b8998306363c --- /dev/null +++ b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalEditorSettingsStorage.spec.js @@ -0,0 +1,135 @@ +// @flow +import { + applyProjectEditorSettings, + extractProjectEditorSettings, + getProjectEditorSettingsFilePath, +} from './LocalEditorSettingsStorage'; +// $FlowFixMe[cannot-resolve-module] +import path from 'path'; + +describe('LocalEditorSettingsStorage', () => { + it('stores settings separately for projects in the same folder', () => { + expect(getProjectEditorSettingsFilePath('C:/Projects/game.json')).toBe( + path.join('C:/Projects', '.gdevelop', 'game.editor-settings.json') + ); + expect(getProjectEditorSettingsFilePath('C:/Projects/prototype.json')).toBe( + path.join('C:/Projects', '.gdevelop', 'prototype.editor-settings.json') + ); + }); + + it('extracts editor settings from project content and applies them back', () => { + const serializedProjectObject: any = { + layouts: [ + { + name: 'Level 1', + uiSettings: { + grid: true, + zoomFactor: 0.5, + }, + }, + { + name: 'Level 2', + }, + ], + externalLayouts: [ + { + name: 'HUD', + editionSettings: { + windowMask: true, + }, + }, + ], + eventsFunctionsExtensions: [ + { + name: 'Inventory', + eventsBasedObjects: [ + { + name: 'Slot', + editionSettings: { + selectedLayer: 'Base', + }, + variants: [ + { + name: 'Large', + editionSettings: { + gridWidth: 64, + }, + }, + ], + }, + ], + }, + ], + }; + + const editorSettings = extractProjectEditorSettings( + serializedProjectObject + ); + + expect(editorSettings).toEqual({ + layoutSettings: { + 'Level 1': { + grid: true, + zoomFactor: 0.5, + }, + }, + externalLayoutSettings: { + HUD: { + windowMask: true, + }, + }, + eventsBasedObjectVariantSettings: { + 'Inventory::Slot::': { + selectedLayer: 'Base', + }, + 'Inventory::Slot::Large': { + gridWidth: 64, + }, + }, + }); + expect(serializedProjectObject.layouts[0].uiSettings).toBeUndefined(); + expect( + serializedProjectObject.externalLayouts[0].editionSettings + ).toBeUndefined(); + expect( + serializedProjectObject.eventsFunctionsExtensions[0].eventsBasedObjects[0] + .editionSettings + ).toBeUndefined(); + expect( + serializedProjectObject.eventsFunctionsExtensions[0].eventsBasedObjects[0] + .variants[0].editionSettings + ).toBeUndefined(); + + applyProjectEditorSettings(serializedProjectObject, editorSettings); + + expect(serializedProjectObject.layouts[0].uiSettings).toEqual({ + grid: true, + zoomFactor: 0.5, + }); + expect(serializedProjectObject.externalLayouts[0].editionSettings).toEqual({ + windowMask: true, + }); + expect( + serializedProjectObject.eventsFunctionsExtensions[0].eventsBasedObjects[0] + .editionSettings + ).toEqual({ + selectedLayer: 'Base', + }); + expect( + serializedProjectObject.eventsFunctionsExtensions[0].eventsBasedObjects[0] + .variants[0].editionSettings + ).toEqual({ + gridWidth: 64, + }); + }); + + it('returns null when there are no editor settings to extract', () => { + expect( + extractProjectEditorSettings({ + layouts: [{ name: 'Level 1' }], + externalLayouts: [], + eventsFunctionsExtensions: [], + }) + ).toBe(null); + }); +}); diff --git a/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectOpener.js b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectOpener.js index 19805526b1cb..9fec75abf6fe 100644 --- a/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectOpener.js +++ b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectOpener.js @@ -3,6 +3,10 @@ import optionalRequire from '../../Utils/OptionalRequire'; import { type FileMetadata } from '../index'; import { unsplit } from '../../Utils/ObjectSplitter'; import { openFilePicker, readJSONFile } from '../../Utils/FileSystem'; +import { + applyProjectEditorSettings, + getProjectEditorSettingsFilePath, +} from './LocalEditorSettingsStorage'; const fs = optionalRequire('fs'); const path = optionalRequire('path'); @@ -34,9 +38,25 @@ export const onOpen = ( // to be un-splitted, but not the content of these properties), to avoid very slow processing // of large game files. maxUnsplitDepth: 3, - }).then(() => { - return { content: object }; - }); + }) + .then(() => + readJSONFile(getProjectEditorSettingsFilePath(filePath)) + .then(projectEditorSettings => { + applyProjectEditorSettings(object, projectEditorSettings); + }) + .catch(error => { + if (error && error.code === 'ENOENT') { + return; + } + console.warn( + 'Unable to read project editor settings. Opening the project without them.', + error + ); + }) + ) + .then(() => { + return { content: object }; + }); }); }; diff --git a/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectWriter.js b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectWriter.js index 1bc87e340f5e..cb29d1cb080f 100644 --- a/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectWriter.js +++ b/newIDE/app/src/ProjectsStorage/LocalFileStorageProvider/LocalProjectWriter.js @@ -19,6 +19,10 @@ import { splitPaths, getSlugifiedUniqueNameFromProperty, } from '../../Utils/ObjectSplitter'; +import { + extractProjectEditorSettings, + getProjectEditorSettingsFilePath, +} from './LocalEditorSettingsStorage'; import type { MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow'; import LocalFolderPicker from '../../UI/LocalFolderPicker'; import SaveAsOptionsDialog from '../SaveAsOptionsDialog'; @@ -131,8 +135,20 @@ const writeProjectFiles = async ({ } else { serializedProjectObject = serializeToJSObject(project); } + const projectEditorSettings = extractProjectEditorSettings( + serializedProjectObject + ); const serializeEndTime = Date.now(); + if (projectEditorSettings) { + await writeAndCheckFormattedJSONFile( + projectEditorSettings, + getProjectEditorSettingsFilePath(filePath) + ); + } else { + await fs.remove(getProjectEditorSettingsFilePath(filePath)); + } + if (project.isFolderProject()) { const partialObjects = split(serializedProjectObject, { pathSeparator: '/',