Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2018-06-13 19:06:35 +0300
committerPhil Hughes <me@iamphill.com>2018-08-07 16:45:55 +0300
commit7b4b9e1cc453c2620502daceb94d3e2248b58dcb (patch)
tree96f79cdace962466a0a27d31c91dbdbc82d4bd88
parentf3b36ac1171f6d170d008c52a0a324a438f3e886 (diff)
Web IDE & CodeSandbox
This enables JavaScripts projects to have live previews straight in the browser without requiring any local configuration. This uses the CodeSandbox package `sandpack` to compile it all inside of an iframe. This feature is off by default and can be toggled on in the admin settings. Only projects with a `package.json` and a `main` key are supported. Updates happen in real-time with hot-reloading. We just watch for changes to files and then send them to `sandpack` to allow it to reload the iframe. The iframe includes a very simple navigation bar, the text bar is `readonly` to stop users navigating away from the preview and the back and forward buttons just pop/splice the navigation stack which is tracked by a listener on `sandpack` There is a button inside the iframe which allows the user to open the projects inside of CodeSandbox. This button is only visible on **public** projects. On private or internal projects this button get hidden to protect private code being leaked into an external public URL. Closes #47268
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue33
-rw-r--r--app/assets/javascripts/ide/components/preview/clientside.vue171
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue147
-rw-r--r--app/assets/javascripts/ide/constants.js3
-rw-r--r--app/assets/javascripts/ide/index.js8
-rw-r--r--app/assets/javascripts/ide/stores/getters.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js9
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js2
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/ide/utils.js7
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss41
-rw-r--r--app/helpers/application_settings_helper.rb3
-rw-r--r--app/views/admin/application_settings/show.html.haml23
-rw-r--r--app/views/ide/index.html.haml5
-rw-r--r--changelogs/unreleased/ide-codesandbox-poc.yml5
-rw-r--r--config/dependency_decisions.yml24
-rw-r--r--db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb20
-rw-r--r--db/schema.rb1
-rw-r--r--locale/gitlab.pot27
-rw-r--r--package.json2
-rw-r--r--spec/javascripts/ide/components/panes/right_spec.js13
-rw-r--r--spec/javascripts/ide/components/preview/clientside_spec.js362
-rw-r--r--spec/javascripts/ide/components/preview/navigator_spec.js185
-rw-r--r--spec/javascripts/ide/stores/getters_spec.js10
-rw-r--r--spec/javascripts/test_bundle.js2
-rw-r--r--yarn.lock60
26 files changed, 1150 insertions, 18 deletions
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index e4a5fcc67c4..79df225c432 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants';
@@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue';
+import Clientside from '../preview/clientside.vue';
export default {
directives: {
@@ -18,15 +19,20 @@ export default {
JobsDetail,
ResizablePanel,
MergeRequestInfo,
+ Clientside,
},
computed: {
- ...mapState(['rightPane', 'currentMergeRequestId']),
+ ...mapState(['rightPane', 'currentMergeRequestId', 'clientsidePreviewEnabled']),
+ ...mapGetters(['packageJson']),
pipelinesActive() {
return (
this.rightPane === rightSidebarViews.pipelines ||
this.rightPane === rightSidebarViews.jobsDetail
);
},
+ showLivePreview() {
+ return this.packageJson && this.clientsidePreviewEnabled;
+ },
},
methods: {
...mapActions(['setRightPane']),
@@ -49,8 +55,9 @@ export default {
:collapsible="false"
:initial-width="350"
:min-size="350"
- class="multi-file-commit-panel-inner"
+ :class="`ide-right-sidebar-${rightPane}`"
side="right"
+ class="multi-file-commit-panel-inner"
>
<component :is="rightPane" />
</resizable-panel>
@@ -98,6 +105,26 @@ export default {
/>
</button>
</li>
+ <li v-if="showLivePreview">
+ <button
+ v-tooltip
+ :title="__('Live preview')"
+ :aria-label="__('Live preview')"
+ :class="{
+ active: rightPane === $options.rightSidebarViews.clientSidePreview
+ }"
+ data-container="body"
+ data-placement="left"
+ class="ide-sidebar-link is-right"
+ type="button"
+ @click="clickTab($event, $options.rightSidebarViews.clientSidePreview)"
+ >
+ <icon
+ :size="16"
+ name="live-preview"
+ />
+ </button>
+ </li>
</ul>
</nav>
</div>
diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue
new file mode 100644
index 00000000000..fef36eae7b1
--- /dev/null
+++ b/app/assets/javascripts/ide/components/preview/clientside.vue
@@ -0,0 +1,171 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import _ from 'underscore';
+import { Manager } from 'smooshpack';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+import Navigator from './navigator.vue';
+import { packageJsonPath } from '../../constants';
+import { createPathWithExt } from '../../utils';
+
+export default {
+ components: {
+ LoadingIcon,
+ Navigator,
+ },
+ data() {
+ return {
+ manager: {},
+ loading: false,
+ };
+ },
+ computed: {
+ ...mapState(['entries', 'promotionSvgPath', 'links']),
+ ...mapGetters(['packageJson', 'currentProject']),
+ normalizedEntries() {
+ return Object.keys(this.entries).reduce((acc, path) => {
+ const file = this.entries[path];
+
+ if (file.type === 'tree' || !(file.raw || file.content)) return acc;
+
+ return {
+ ...acc,
+ [`/${path}`]: {
+ code: file.content || file.raw,
+ },
+ };
+ }, {});
+ },
+ mainEntry() {
+ if (!this.packageJson.raw) return false;
+
+ const parsedPackage = JSON.parse(this.packageJson.raw);
+
+ return parsedPackage.main;
+ },
+ showPreview() {
+ return this.mainEntry && !this.loading;
+ },
+ showEmptyState() {
+ return !this.mainEntry && !this.loading;
+ },
+ showOpenInCodeSandbox() {
+ return this.currentProject && this.currentProject.visibility === 'public';
+ },
+ sandboxOpts() {
+ return {
+ files: { ...this.normalizedEntries },
+ entry: `/${this.mainEntry}`,
+ showOpenInCodeSandbox: this.showOpenInCodeSandbox,
+ };
+ },
+ },
+ watch: {
+ entries: {
+ deep: true,
+ handler: 'update',
+ },
+ },
+ mounted() {
+ this.loading = true;
+
+ return this.loadFileContent(packageJsonPath)
+ .then(() => {
+ this.loading = false;
+ })
+ .then(() => this.$nextTick())
+ .then(() => this.initPreview());
+ },
+ beforeDestroy() {
+ if (!_.isEmpty(this.manager)) {
+ this.manager.listener();
+ }
+ this.manager = {};
+
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ },
+ methods: {
+ ...mapActions(['getFileData', 'getRawFileData']),
+ loadFileContent(path) {
+ return this.getFileData({ path, makeFileActive: false }).then(() =>
+ this.getRawFileData({ path }),
+ );
+ },
+ initPreview() {
+ if (!this.mainEntry) return null;
+
+ return this.loadFileContent(this.mainEntry)
+ .then(() => this.$nextTick())
+ .then(() =>
+ this.initManager('#ide-preview', this.sandboxOpts, {
+ fileResolver: {
+ isFile: p => Promise.resolve(!!this.entries[createPathWithExt(p)]),
+ readFile: p => this.loadFileContent(createPathWithExt(p)).then(content => content),
+ },
+ }),
+ );
+ },
+ update() {
+ if (this.timeout) return;
+
+ this.timeout = setTimeout(() => {
+ if (_.isEmpty(this.manager)) {
+ this.initPreview();
+
+ return;
+ }
+
+ this.manager.updatePreview(this.sandboxOpts);
+
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ }, 500);
+ },
+ initManager(el, opts, resolver) {
+ this.manager = new Manager(el, opts, resolver);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="preview h-100 w-100 d-flex flex-column">
+ <template v-if="showPreview">
+ <navigator
+ :manager="manager"
+ />
+ <div id="ide-preview"></div>
+ </template>
+ <div
+ v-else-if="showEmptyState"
+ v-once
+ class="d-flex h-100 flex-column align-items-center justify-content-center svg-content"
+ >
+ <img
+ :src="promotionSvgPath"
+ :alt="s__('IDE|Live Preview')"
+ width="130"
+ height="100"
+ />
+ <h3>
+ {{ s__('IDE|Live Preview') }}
+ </h3>
+ <p class="text-center">
+ {{ s__('IDE|Preview your web application using Web IDE client-side evaluation.') }}
+ </p>
+ <a
+ :href="links.webIDEHelpPagePath"
+ class="btn btn-primary"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ s__('IDE|Get started with Live Preview') }}
+ </a>
+ </div>
+ <loading-icon
+ v-else
+ size="2"
+ class="align-self-center mt-auto mb-auto"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
new file mode 100644
index 00000000000..4bf346946b6
--- /dev/null
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -0,0 +1,147 @@
+<script>
+import { listen } from 'codesandbox-api';
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
+
+export default {
+ components: {
+ Icon,
+ LoadingIcon,
+ },
+ props: {
+ manager: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentBrowsingIndex: null,
+ navigationStack: [],
+ forwardNavigationStack: [],
+ path: '',
+ loading: true,
+ };
+ },
+ computed: {
+ backButtonDisabled() {
+ return this.navigationStack.length <= 1;
+ },
+ forwardButtonDisabled() {
+ return !this.forwardNavigationStack.length;
+ },
+ },
+ mounted() {
+ this.listener = listen(e => {
+ switch (e.type) {
+ case 'urlchange':
+ this.onUrlChange(e);
+ break;
+ case 'done':
+ this.loading = false;
+ break;
+ default:
+ break;
+ }
+ });
+ },
+ beforeDestroy() {
+ this.listener();
+ },
+ methods: {
+ onUrlChange(e) {
+ const lastPath = this.path;
+
+ this.path = e.url.replace(this.manager.bundlerURL, '') || '/';
+
+ if (lastPath !== this.path) {
+ this.currentBrowsingIndex =
+ this.currentBrowsingIndex === null ? 0 : this.currentBrowsingIndex + 1;
+ this.navigationStack.push(this.path);
+ }
+ },
+ back() {
+ const lastPath = this.path;
+
+ this.visitPath(this.navigationStack[this.currentBrowsingIndex - 1]);
+
+ this.forwardNavigationStack.push(lastPath);
+
+ if (this.currentBrowsingIndex === 1) {
+ this.currentBrowsingIndex = null;
+ this.navigationStack = [];
+ }
+ },
+ forward() {
+ this.visitPath(this.forwardNavigationStack.splice(0, 1)[0]);
+ },
+ refresh() {
+ this.visitPath(this.path);
+ },
+ visitPath(path) {
+ this.manager.iframe.src = `${this.manager.bundlerURL}${path}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <header class="ide-preview-header d-flex align-items-center">
+ <button
+ :aria-label="s__('IDE|Back')"
+ :disabled="backButtonDisabled"
+ :class="{
+ 'disabled-content': backButtonDisabled
+ }"
+ type="button"
+ class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
+ @click="back"
+ >
+ <icon
+ :size="24"
+ name="chevron-left"
+ class="m-auto"
+ />
+ </button>
+ <button
+ :aria-label="s__('IDE|Back')"
+ :disabled="forwardButtonDisabled"
+ :class="{
+ 'disabled-content': forwardButtonDisabled
+ }"
+ type="button"
+ class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
+ @click="forward"
+ >
+ <icon
+ :size="24"
+ name="chevron-right"
+ class="m-auto"
+ />
+ </button>
+ <button
+ :aria-label="s__('IDE|Refresh preview')"
+ type="button"
+ class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
+ @click="refresh"
+ >
+ <icon
+ :size="18"
+ name="retry"
+ class="m-auto"
+ />
+ </button>
+ <div class="position-relative w-100 prepend-left-4">
+ <input
+ :value="path || '/'"
+ type="text"
+ class="ide-navigator-location form-control bg-white"
+ readonly
+ />
+ <loading-icon
+ v-if="loading"
+ class="position-absolute ide-preview-loading-icon"
+ />
+ </div>
+ </header>
+</template>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index d3ac57471c9..8caa5b86a9b 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -32,6 +32,7 @@ export const rightSidebarViews = {
pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info',
+ clientSidePreview: 'clientside',
};
export const stageKeys = {
@@ -58,3 +59,5 @@ export const modalTypes = {
rename: 'rename',
tree: 'tree',
};
+
+export const packageJsonPath = 'package.json';
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 2d74192e6b3..79e38ae911e 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue';
import store from './stores';
import router from './ide_router';
+import { convertPermissionToBoolean } from '../lib/utils/common_utils';
Vue.use(Translate);
@@ -23,13 +24,18 @@ export function initIde(el) {
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
+ promotionSvgPath: el.dataset.promotionSvgPath,
});
this.setLinks({
ciHelpPagePath: el.dataset.ciHelpPagePath,
+ webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
+ });
+ this.setInitialData({
+ clientsidePreviewEnabled: convertPermissionToBoolean(el.dataset.clientsidePreviewEnabled),
});
},
methods: {
- ...mapActions(['setEmptyStateSvgs', 'setLinks']),
+ ...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
},
render(createElement) {
return createElement('ide');
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 79cdb494e5a..709748fb530 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -1,5 +1,5 @@
import { getChangesCountForFiles, filePathMatches } from './utils';
-import { activityBarViews } from '../constants';
+import { activityBarViews, packageJsonPath } from '../constants';
export const activeFile = state => state.openFiles.find(file => file.active) || null;
@@ -90,5 +90,7 @@ export const lastCommit = (state, getters) => {
export const currentBranch = (state, getters) =>
getters.currentProject && getters.currentProject.branches[state.currentBranchId];
+export const packageJson = state => state.entries[packageJsonPath];
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index d0bf847dbde..1eda5768709 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -115,13 +115,20 @@ export default {
},
[types.SET_EMPTY_STATE_SVGS](
state,
- { emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath },
+ {
+ emptyStateSvgPath,
+ noChangesStateSvgPath,
+ committedStateSvgPath,
+ pipelinesEmptyStateSvgPath,
+ promotionSvgPath,
+ },
) {
Object.assign(state, {
emptyStateSvgPath,
noChangesStateSvgPath,
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
+ promotionSvgPath,
});
},
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index c75add39bcd..a937fb157f8 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -44,7 +44,7 @@ export default {
rawPath: data.raw_path,
binary: data.binary,
renderError: data.render_error,
- raw: null,
+ raw: (state.entries[file.path] && state.entries[file.path].raw) || null,
baseRaw: null,
html: data.html,
size: data.size,
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 2371b201f8c..46b52fa00fc 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -31,4 +31,5 @@ export default () => ({
path: '',
entry: {},
},
+ clientsidePreviewEnabled: false,
});
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 92b15cf232d..d895eca7af0 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -1,6 +1,5 @@
import { commitItemIconMap } from './constants';
-// eslint-disable-next-line import/prefer-default-export
export const getCommitIconMap = file => {
if (file.deleted) {
return commitItemIconMap.deleted;
@@ -10,3 +9,9 @@ export const getCommitIconMap = file => {
return commitItemIconMap.modified;
};
+
+export const createPathWithExt = p => {
+ const ext = p.lastIndexOf('.') >= 0 ? p.substring(p.lastIndexOf('.') + 1) : '';
+
+ return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`;
+};
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 31dbf1da73c..37ad6a717d9 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -1229,6 +1229,10 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
background-color: $white-light;
border-left: 1px solid $white-dark;
}
+
+ .ide-right-sidebar-clientside {
+ padding: 0;
+ }
}
.ide-pipeline {
@@ -1412,3 +1416,40 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
color: $white-normal;
background-color: $blue-500;
}
+
+.ide-preview-header {
+ padding: 0 $grid-size;
+ border-bottom: 1px solid $white-dark;
+ background-color: $gray-light;
+ min-height: 44px;
+}
+
+.ide-navigator-btn {
+ height: 24px;
+ min-width: 24px;
+ max-width: 24px;
+ padding: 0;
+ margin: 0 ($grid-size / 2);
+ color: $gl-gray-light;
+
+ &:first-child {
+ margin-left: 0;
+ }
+}
+
+.ide-navigator-location {
+ padding-top: ($grid-size / 2);
+ padding-bottom: ($grid-size / 2);
+
+ &:focus {
+ outline: 0;
+ box-shadow: none;
+ border-color: $theme-gray-200;
+ }
+}
+
+.ide-preview-loading-icon {
+ right: $grid-size;
+ top: 50%;
+ transform: translateY(-50%);
+}
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index a9499140f8a..2bdf2c2c120 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -255,7 +255,8 @@ module ApplicationSettingsHelper
:instance_statistics_visibility_private,
:user_default_external,
:user_oauth_applications,
- :version_check_enabled
+ :version_check_enabled,
+ :web_ide_clientside_preview_enabled
]
end
end
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index 1c8801566d4..258d50ad676 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -338,4 +338,27 @@
= render_if_exists 'admin/application_settings/custom_templates_form', expanded: expanded
+%section.settings.no-animate#js-web-ide-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Web IDE')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _('Manage Web IDE features')
+ .settings-content
+ = form_for @application_setting, url: admin_application_settings_path(anchor: "#js-web-ide-settings"), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .form-check
+ = f.check_box :web_ide_clientside_preview_enabled, class: 'form-check-input'
+ = f.label :web_ide_clientside_preview_enabled, class: 'form-check-label' do
+ = s_('IDE|Client side evaluation')
+ %span.form-text.text-muted
+ = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation.')
+
+ = f.submit _('Save changes'), class: "btn btn-success"
+
= render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded
diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml
index d29dda43c89..4cae9c51acc 100644
--- a/app/views/ide/index.html.haml
+++ b/app/views/ide/index.html.haml
@@ -8,7 +8,10 @@
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
"committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg'),
"pipelines-empty-state-svg-path": image_path('illustrations/pipelines_empty.svg'),
- "ci-help-page-path" => help_page_path('ci/quick_start/README'), } }
+ "promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
+ "ci-help-page-path" => help_page_path('ci/quick_start/README'),
+ "web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
+ "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s } }
.text-center
= icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...')
diff --git a/changelogs/unreleased/ide-codesandbox-poc.yml b/changelogs/unreleased/ide-codesandbox-poc.yml
new file mode 100644
index 00000000000..7da1f4e6472
--- /dev/null
+++ b/changelogs/unreleased/ide-codesandbox-poc.yml
@@ -0,0 +1,5 @@
+---
+title: Added live preview for JavaScript projects in the Web IDE
+merge_request: 19764
+author:
+type: added
diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml
index 73115449871..dce1fc1bc45 100644
--- a/config/dependency_decisions.yml
+++ b/config/dependency_decisions.yml
@@ -546,3 +546,27 @@
:why: Our own library
:versions: []
:when: 2018-07-17 21:02:54.529227000 Z
+- - :approve
+ - lz-string
+ - :who: Phil Hughes
+ :why: https://github.com/pieroxy/lz-string/blob/master/LICENSE.txt
+ :versions: []
+ :when: 2018-08-03 08:22:44.973457000 Z
+- - :approve
+ - smooshpack
+ - :who: Phil Hughes
+ :why: https://github.com/CompuIves/codesandbox-client/blob/master/packages/sandpack/LICENSE.md
+ :versions: []
+ :when: 2018-08-03 08:24:29.578991000 Z
+- - :approve
+ - codesandbox-import-util-types
+ - :who: Phil Hughes
+ :why: https://github.com/codesandbox-app/codesandbox-importers/blob/master/packages/types/LICENSE
+ :versions: []
+ :when: 2018-08-03 12:22:47.574421000 Z
+- - :approve
+ - codesandbox-import-utils
+ - :who: Phil Hughes
+ :why: https://github.com/codesandbox-app/codesandbox-importers/blob/master/packages/import-utils/LICENSE
+ :versions: []
+ :when: 2018-08-03 12:23:24.083046000 Z
diff --git a/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb b/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb
new file mode 100644
index 00000000000..1ebb91da00c
--- /dev/null
+++ b/db/migrate/20180723135214_add_web_ide_client_side_preview_enabled_to_application_settings.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AddWebIdeClientSidePreviewEnabledToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:application_settings, :web_ide_clientside_preview_enabled,
+ :boolean,
+ default: false,
+ allow_null: false)
+ end
+
+ def down
+ remove_column(:application_settings, :web_ide_clientside_preview_enabled)
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 30b8147a474..c132f787530 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -169,6 +169,7 @@ ActiveRecord::Schema.define(version: 20180726172057) do
t.boolean "mirror_available", default: true, null: false
t.boolean "hide_third_party_offers", default: false, null: false
t.boolean "instance_statistics_visibility_private", default: false, null: false
+ t.boolean "web_ide_clientside_preview_enabled", default: false, null: false
end
create_table "audit_events", force: :cascade do |t|
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7d1a8b527f4..6c89186beaa 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2905,18 +2905,39 @@ msgstr ""
msgid "ID"
msgstr ""
+msgid "IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox client side evaluation."
+msgstr ""
+
+msgid "IDE|Back"
+msgstr ""
+
+msgid "IDE|Client side evaluation"
+msgstr ""
+
msgid "IDE|Commit"
msgstr ""
msgid "IDE|Edit"
msgstr ""
+msgid "IDE|Get started with Live Preview"
+msgstr ""
+
msgid "IDE|Go to project"
msgstr ""
+msgid "IDE|Live Preview"
+msgstr ""
+
msgid "IDE|Open in file view"
msgstr ""
+msgid "IDE|Preview your web application using Web IDE client-side evaluation."
+msgstr ""
+
+msgid "IDE|Refresh preview"
+msgstr ""
+
msgid "IDE|Review"
msgstr ""
@@ -3234,6 +3255,9 @@ msgstr ""
msgid "List your GitHub repositories"
msgstr ""
+msgid "Live preview"
+msgstr ""
+
msgid "Loading the GitLab IDE..."
msgstr ""
@@ -3267,6 +3291,9 @@ msgstr ""
msgid "Manage Git repositories with fine-grained access controls that keep your code secure. Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki."
msgstr ""
+msgid "Manage Web IDE features"
+msgstr ""
+
msgid "Manage access"
msgstr ""
diff --git a/package.json b/package.json
index 4e5cf05f49b..975dd2619d7 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"chart.js": "1.0.2",
"classlist-polyfill": "^1.2.0",
"clipboard": "^1.7.1",
+ "codesandbox-api": "^0.0.18",
"compression-webpack-plugin": "^1.1.11",
"core-js": "^2.4.1",
"cropper": "^2.3.0",
@@ -80,6 +81,7 @@
"sanitize-html": "^1.16.1",
"select2": "3.5.2-browserify",
"sha1": "^1.1.1",
+ "smooshpack": "^0.0.48",
"sortablejs": "^1.7.0",
"sql.js": "^0.4.0",
"stickyfilljs": "^2.0.5",
diff --git a/spec/javascripts/ide/components/panes/right_spec.js b/spec/javascripts/ide/components/panes/right_spec.js
index 99879fb0930..c75975d2af6 100644
--- a/spec/javascripts/ide/components/panes/right_spec.js
+++ b/spec/javascripts/ide/components/panes/right_spec.js
@@ -69,4 +69,17 @@ describe('IDE right pane', () => {
});
});
});
+
+ describe('live preview', () => {
+ it('renders live preview button', done => {
+ Vue.set(vm.$store.state.entries, 'package.json', { name: 'package.json' });
+ vm.$store.state.clientsidePreviewEnabled = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('button[aria-label="Live preview"]')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
});
diff --git a/spec/javascripts/ide/components/preview/clientside_spec.js b/spec/javascripts/ide/components/preview/clientside_spec.js
new file mode 100644
index 00000000000..3ec65882418
--- /dev/null
+++ b/spec/javascripts/ide/components/preview/clientside_spec.js
@@ -0,0 +1,362 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import Clientside from '~/ide/components/preview/clientside.vue';
+import timeoutPromise from 'spec/helpers/set_timeout_promise_helper';
+import { resetStore, file } from '../../helpers';
+
+describe('IDE clientside preview', () => {
+ let vm;
+ let Component;
+
+ beforeAll(() => {
+ Component = Vue.extend(Clientside);
+ });
+
+ beforeEach(done => {
+ const store = createStore();
+
+ Vue.set(store.state.entries, 'package.json', {
+ ...file('package.json'),
+ });
+ Vue.set(store.state, 'currentProjectId', 'gitlab-ce');
+ Vue.set(store.state.projects, 'gitlab-ce', {
+ visibility: 'public',
+ });
+
+ vm = createComponentWithStore(Component, store);
+
+ spyOn(vm, 'getFileData').and.returnValue(Promise.resolve());
+ spyOn(vm, 'getRawFileData').and.returnValue(Promise.resolve(''));
+ spyOn(vm, 'initManager');
+
+ vm.$mount();
+
+ timeoutPromise()
+ .then(() => vm.$nextTick())
+ .then(done)
+ .catch(done.fail);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ resetStore(vm.$store);
+ });
+
+ describe('without main entry', () => {
+ it('creates sandpack manager', () => {
+ expect(vm.initManager).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with main entry', () => {
+ beforeEach(done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+
+ vm
+ .$nextTick()
+ .then(() => vm.initPreview())
+ .then(vm.$nextTick)
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('creates sandpack manager', () => {
+ expect(vm.initManager).toHaveBeenCalledWith(
+ '#ide-preview',
+ {
+ files: jasmine.any(Object),
+ entry: '/index.js',
+ showOpenInCodeSandbox: true,
+ },
+ {
+ fileResolver: {
+ isFile: jasmine.any(Function),
+ readFile: jasmine.any(Function),
+ },
+ },
+ );
+ });
+ });
+
+ describe('computed', () => {
+ describe('normalizedEntries', () => {
+ beforeEach(done => {
+ vm.$store.state.entries['index.js'] = {
+ ...file('index.js'),
+ type: 'blob',
+ raw: 'test',
+ };
+ vm.$store.state.entries['index2.js'] = {
+ ...file('index2.js'),
+ type: 'blob',
+ content: 'content',
+ };
+ vm.$store.state.entries.tree = {
+ ...file('tree'),
+ type: 'tree',
+ };
+ vm.$store.state.entries.empty = {
+ ...file('empty'),
+ type: 'blob',
+ };
+
+ vm.$nextTick(done);
+ });
+
+ it('returns flattened list of blobs with content', () => {
+ expect(vm.normalizedEntries).toEqual({
+ '/index.js': {
+ code: 'test',
+ },
+ '/index2.js': {
+ code: 'content',
+ },
+ });
+ });
+ });
+
+ describe('mainEntry', () => {
+ it('returns false when package.json is empty', () => {
+ expect(vm.mainEntry).toBe(false);
+ });
+
+ it('returns main key from package.json', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+
+ vm.$nextTick(() => {
+ expect(vm.mainEntry).toBe('index.js');
+
+ done();
+ });
+ });
+ });
+
+ describe('showPreview', () => {
+ it('returns false if no mainEntry', () => {
+ expect(vm.showPreview).toBe(false);
+ });
+
+ it('returns false if loading', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.showPreview).toBe(false);
+
+ done();
+ });
+ });
+
+ it('returns true if not loading and mainEntry exists', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.showPreview).toBe(true);
+
+ done();
+ });
+ });
+ });
+
+ describe('showEmptyState', () => {
+ it('returns true if no mainEnry exists', () => {
+ expect(vm.showEmptyState).toBe(true);
+ });
+
+ it('returns false if loading', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.showEmptyState).toBe(false);
+
+ done();
+ });
+ });
+
+ it('returns false if not loading and mainEntry exists', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.showEmptyState).toBe(false);
+
+ done();
+ });
+ });
+ });
+
+ describe('showOpenInCodeSandbox', () => {
+ it('returns true when visiblity is public', () => {
+ expect(vm.showOpenInCodeSandbox).toBe(true);
+ });
+
+ it('returns false when visiblity is private', done => {
+ vm.$store.state.projects['gitlab-ce'].visibility = 'private';
+
+ vm.$nextTick(() => {
+ expect(vm.showOpenInCodeSandbox).toBe(false);
+
+ done();
+ });
+ });
+ });
+
+ describe('sandboxOpts', () => {
+ beforeEach(done => {
+ vm.$store.state.entries['index.js'] = {
+ ...file('index.js'),
+ type: 'blob',
+ raw: 'test',
+ };
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+
+ vm.$nextTick(done);
+ });
+
+ it('returns sandbox options', () => {
+ expect(vm.sandboxOpts).toEqual({
+ files: {
+ '/index.js': {
+ code: 'test',
+ },
+ '/package.json': {
+ code: '{"main":"index.js"}',
+ },
+ },
+ entry: '/index.js',
+ showOpenInCodeSandbox: true,
+ });
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('loadFileContent', () => {
+ it('calls getFileData', () => {
+ expect(vm.getFileData).toHaveBeenCalledWith({
+ path: 'package.json',
+ makeFileActive: false,
+ });
+ });
+
+ it('calls getRawFileData', () => {
+ expect(vm.getRawFileData).toHaveBeenCalledWith({ path: 'package.json' });
+ });
+ });
+
+ describe('update', () => {
+ beforeEach(() => {
+ jasmine.clock().install();
+ vm.manager.updatePreview = jasmine.createSpy('updatePreview');
+ vm.manager.listener = jasmine.createSpy('updatePreview');
+ });
+
+ afterEach(() => {
+ jasmine.clock().uninstall();
+ });
+
+ it('calls initPreview if manager is empty', () => {
+ spyOn(vm, 'initPreview');
+ vm.manager = {};
+
+ vm.update();
+
+ jasmine.clock().tick(500);
+
+ expect(vm.initPreview).toHaveBeenCalled();
+ });
+
+ it('calls updatePreview', () => {
+ vm.update();
+
+ jasmine.clock().tick(500);
+
+ expect(vm.manager.updatePreview).toHaveBeenCalledWith(vm.sandboxOpts);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders ide-preview element when showPreview is true', done => {
+ Vue.set(
+ vm.$store.state.entries['package.json'],
+ 'raw',
+ JSON.stringify({
+ main: 'index.js',
+ }),
+ );
+ vm.loading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('#ide-preview')).not.toBe(null);
+ done();
+ });
+ });
+
+ it('renders empty state', done => {
+ vm.loading = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.textContent).toContain(
+ 'Preview your web application using Web IDE client-side evaluation.',
+ );
+
+ done();
+ });
+ });
+
+ it('renders loading icon', done => {
+ vm.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/preview/navigator_spec.js b/spec/javascripts/ide/components/preview/navigator_spec.js
new file mode 100644
index 00000000000..576d2fae003
--- /dev/null
+++ b/spec/javascripts/ide/components/preview/navigator_spec.js
@@ -0,0 +1,185 @@
+import Vue from 'vue';
+import ClientsideNavigator from '~/ide/components/preview/navigator.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('IDE clientside preview navigator', () => {
+ let vm;
+ let Component;
+ let manager;
+
+ beforeAll(() => {
+ Component = Vue.extend(ClientsideNavigator);
+ });
+
+ beforeEach(() => {
+ manager = {
+ bundlerURL: gl.TEST_HOST,
+ iframe: { src: '' },
+ };
+
+ vm = mountComponent(Component, {
+ manager,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders readonly URL bar', () => {
+ expect(vm.$el.querySelector('input[readonly]').value).toBe('/');
+ });
+
+ it('disables back button when navigationStack is empty', () => {
+ expect(vm.$el.querySelector('.ide-navigator-btn')).toHaveAttr('disabled');
+ expect(vm.$el.querySelector('.ide-navigator-btn').classList).toContain('disabled-content');
+ });
+
+ it('disables forward button when forwardNavigationStack is empty', () => {
+ vm.forwardNavigationStack = [];
+
+ expect(vm.$el.querySelectorAll('.ide-navigator-btn')[1]).toHaveAttr('disabled');
+ expect(vm.$el.querySelectorAll('.ide-navigator-btn')[1].classList).toContain(
+ 'disabled-content',
+ );
+ });
+
+ it('calls back method when clicking back button', done => {
+ vm.navigationStack.push('/test');
+ vm.navigationStack.push('/test2');
+ spyOn(vm, 'back');
+
+ vm.$nextTick(() => {
+ vm.$el.querySelector('.ide-navigator-btn').click();
+
+ expect(vm.back).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls forward method when clicking forward button', done => {
+ vm.forwardNavigationStack.push('/test');
+ spyOn(vm, 'forward');
+
+ vm.$nextTick(() => {
+ vm.$el.querySelectorAll('.ide-navigator-btn')[1].click();
+
+ expect(vm.forward).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ describe('onUrlChange', () => {
+ it('updates the path', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ expect(vm.path).toBe('/url');
+ });
+
+ it('sets currentBrowsingIndex 0 if not already set', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ expect(vm.currentBrowsingIndex).toBe(0);
+ });
+
+ it('increases currentBrowsingIndex if path doesnt match', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url2`,
+ });
+
+ expect(vm.currentBrowsingIndex).toBe(1);
+ });
+
+ it('does not increase currentBrowsingIndex if path matches', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ expect(vm.currentBrowsingIndex).toBe(0);
+ });
+
+ it('pushes path into navigation stack', () => {
+ vm.onUrlChange({
+ url: `${gl.TEST_HOST}/url`,
+ });
+
+ expect(vm.navigationStack).toEqual(['/url']);
+ });
+ });
+
+ describe('back', () => {
+ beforeEach(() => {
+ vm.path = '/test2';
+ vm.currentBrowsingIndex = 1;
+ vm.navigationStack.push('/test');
+ vm.navigationStack.push('/test2');
+
+ spyOn(vm, 'visitPath');
+
+ vm.back();
+ });
+
+ it('visits the last entry in navigationStack', () => {
+ expect(vm.visitPath).toHaveBeenCalledWith('/test');
+ });
+
+ it('adds last entry to forwardNavigationStack', () => {
+ expect(vm.forwardNavigationStack).toEqual(['/test2']);
+ });
+
+ it('clears navigation stack if currentBrowsingIndex is 1', () => {
+ expect(vm.navigationStack).toEqual([]);
+ });
+
+ it('sets currentBrowsingIndex to null is currentBrowsingIndex is 1', () => {
+ expect(vm.currentBrowsingIndex).toBe(null);
+ });
+ });
+
+ describe('forward', () => {
+ it('calls visitPath with first entry in forwardNavigationStack', () => {
+ spyOn(vm, 'visitPath');
+
+ vm.forwardNavigationStack.push('/test');
+ vm.forwardNavigationStack.push('/test2');
+
+ vm.forward();
+
+ expect(vm.visitPath).toHaveBeenCalledWith('/test');
+ });
+ });
+
+ describe('refresh', () => {
+ it('calls refresh with current path', () => {
+ spyOn(vm, 'visitPath');
+
+ vm.path = '/test';
+
+ vm.refresh();
+
+ expect(vm.visitPath).toHaveBeenCalledWith('/test');
+ });
+ });
+
+ describe('visitPath', () => {
+ it('updates iframe src with passed in path', () => {
+ vm.visitPath('/testpath');
+
+ expect(manager.iframe.src).toBe(`${gl.TEST_HOST}/testpath`);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js
index 70883e16b0d..9c135661997 100644
--- a/spec/javascripts/ide/stores/getters_spec.js
+++ b/spec/javascripts/ide/stores/getters_spec.js
@@ -179,4 +179,14 @@ describe('IDE store getters', () => {
});
});
});
+
+ describe('packageJson', () => {
+ it('returns package.json entry', () => {
+ localState.entries['package.json'] = { name: 'package.json' };
+
+ expect(getters.packageJson(localState)).toEqual({
+ name: 'package.json',
+ });
+ });
+ });
});
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
index 5f4f4c26d74..4452c470b82 100644
--- a/spec/javascripts/test_bundle.js
+++ b/spec/javascripts/test_bundle.js
@@ -95,7 +95,7 @@ beforeEach(() => {
let longRunningTestTimeoutHandle;
-beforeEach((done) => {
+beforeEach(done => {
longRunningTestTimeoutHandle = setTimeout(() => {
done.fail('Test is running too long!');
}, 2000);
diff --git a/yarn.lock b/yarn.lock
index f6e3b84c84b..c1e9d0ab73e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1258,6 +1258,10 @@ binary-extensions@^1.0.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
+binaryextensions@2:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935"
+
bitsyntax@~0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82"
@@ -1776,6 +1780,22 @@ code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+codesandbox-api@^0.0.18:
+ version "0.0.18"
+ resolved "https://registry.yarnpkg.com/codesandbox-api/-/codesandbox-api-0.0.18.tgz#56b96b37533f80d20c21861e5e477d3557e613ca"
+
+codesandbox-import-util-types@^1.2.11:
+ version "1.2.11"
+ resolved "https://registry.yarnpkg.com/codesandbox-import-util-types/-/codesandbox-import-util-types-1.2.11.tgz#68e812f21d6b309e9a52eec5cf027c3e63b4c703"
+
+codesandbox-import-utils@^1.2.3:
+ version "1.2.11"
+ resolved "https://registry.yarnpkg.com/codesandbox-import-utils/-/codesandbox-import-utils-1.2.11.tgz#b88423a4a7c785175c784c84e87f5950820280e1"
+ dependencies:
+ codesandbox-import-util-types "^1.2.11"
+ istextorbinary "^2.2.1"
+ lz-string "^1.4.4"
+
collection-visit@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
@@ -2645,6 +2665,10 @@ ecc-jsbn@~0.1.1:
dependencies:
jsbn "~0.1.0"
+editions@^1.3.3:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b"
+
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -3920,7 +3944,7 @@ https-proxy-agent@^2.2.1:
agent-base "^4.1.0"
debug "^3.1.0"
-iconv-lite@0.4:
+iconv-lite@0.4, iconv-lite@0.4.23, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
version "0.4.23"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
dependencies:
@@ -3934,12 +3958,6 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17:
version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
-iconv-lite@0.4.23, iconv-lite@^0.4.22, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
- version "0.4.23"
- resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
- dependencies:
- safer-buffer ">= 2.1.2 < 3"
-
icss-replace-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
@@ -4490,6 +4508,14 @@ istanbul@^0.4.5:
which "^1.1.1"
wordwrap "^1.0.0"
+istextorbinary@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.2.1.tgz#a5231a08ef6dd22b268d0895084cf8d58b5bec53"
+ dependencies:
+ binaryextensions "2"
+ editions "^1.3.3"
+ textextensions "2"
+
isurl@^1.0.0-alpha5:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
@@ -4839,6 +4865,10 @@ lodash.get@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+lodash.isequal@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+
lodash.kebabcase@4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
@@ -4948,6 +4978,10 @@ lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2:
pseudomap "^1.0.2"
yallist "^2.1.2"
+lz-string@^1.4.4:
+ version "1.4.4"
+ resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
+
mailcomposer@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4"
@@ -6735,6 +6769,14 @@ smart-buffer@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.1.tgz#07ea1ca8d4db24eb4cac86537d7d18995221ace3"
+smooshpack@^0.0.48:
+ version "0.0.48"
+ resolved "https://registry.yarnpkg.com/smooshpack/-/smooshpack-0.0.48.tgz#6fbeaaf59226a1fe500f56aa17185eed377d2823"
+ dependencies:
+ codesandbox-api "^0.0.18"
+ codesandbox-import-utils "^1.2.3"
+ lodash.isequal "^4.5.0"
+
smtp-connection@2.12.0:
version "2.12.0"
resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1"
@@ -7240,6 +7282,10 @@ text-table@~0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
+textextensions@2:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.2.0.tgz#38ac676151285b658654581987a0ce1a4490d286"
+
three-orbit-controls@^82.1.0:
version "82.1.0"
resolved "https://registry.yarnpkg.com/three-orbit-controls/-/three-orbit-controls-82.1.0.tgz#11a7f33d0a20ecec98f098b37780f6537374fab4"