Commit 7b4b9e1c authored by Phil Hughes's avatar Phil Hughes

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
parent f3b36ac1
<script> <script>
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import tooltip from '../../../vue_shared/directives/tooltip'; import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue'; import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants'; import { rightSidebarViews } from '../../constants';
...@@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue'; ...@@ -7,6 +7,7 @@ import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue'; import JobsDetail from '../jobs/detail.vue';
import MergeRequestInfo from '../merge_requests/info.vue'; import MergeRequestInfo from '../merge_requests/info.vue';
import ResizablePanel from '../resizable_panel.vue'; import ResizablePanel from '../resizable_panel.vue';
import Clientside from '../preview/clientside.vue';
export default { export default {
directives: { directives: {
...@@ -18,15 +19,20 @@ export default { ...@@ -18,15 +19,20 @@ export default {
JobsDetail, JobsDetail,
ResizablePanel, ResizablePanel,
MergeRequestInfo, MergeRequestInfo,
Clientside,
}, },
computed: { computed: {
...mapState(['rightPane', 'currentMergeRequestId']), ...mapState(['rightPane', 'currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapGetters(['packageJson']),
pipelinesActive() { pipelinesActive() {
return ( return (
this.rightPane === rightSidebarViews.pipelines || this.rightPane === rightSidebarViews.pipelines ||
this.rightPane === rightSidebarViews.jobsDetail this.rightPane === rightSidebarViews.jobsDetail
); );
}, },
showLivePreview() {
return this.packageJson && this.clientsidePreviewEnabled;
},
}, },
methods: { methods: {
...mapActions(['setRightPane']), ...mapActions(['setRightPane']),
...@@ -49,8 +55,9 @@ export default { ...@@ -49,8 +55,9 @@ export default {
:collapsible="false" :collapsible="false"
:initial-width="350" :initial-width="350"
:min-size="350" :min-size="350"
class="multi-file-commit-panel-inner" :class="`ide-right-sidebar-${rightPane}`"
side="right" side="right"
class="multi-file-commit-panel-inner"
> >
<component :is="rightPane" /> <component :is="rightPane" />
</resizable-panel> </resizable-panel>
...@@ -98,6 +105,26 @@ export default { ...@@ -98,6 +105,26 @@ export default {
/> />
</button> </button>
</li> </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> </ul>
</nav> </nav>
</div> </div>
......
<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>
<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>
...@@ -32,6 +32,7 @@ export const rightSidebarViews = { ...@@ -32,6 +32,7 @@ export const rightSidebarViews = {
pipelines: 'pipelines-list', pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail', jobsDetail: 'jobs-detail',
mergeRequestInfo: 'merge-request-info', mergeRequestInfo: 'merge-request-info',
clientSidePreview: 'clientside',
}; };
export const stageKeys = { export const stageKeys = {
...@@ -58,3 +59,5 @@ export const modalTypes = { ...@@ -58,3 +59,5 @@ export const modalTypes = {
rename: 'rename', rename: 'rename',
tree: 'tree', tree: 'tree',
}; };
export const packageJsonPath = 'package.json';
...@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate'; ...@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue'; import ide from './components/ide.vue';
import store from './stores'; import store from './stores';
import router from './ide_router'; import router from './ide_router';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
Vue.use(Translate); Vue.use(Translate);
...@@ -23,13 +24,18 @@ export function initIde(el) { ...@@ -23,13 +24,18 @@ export function initIde(el) {
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath, committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath, pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
promotionSvgPath: el.dataset.promotionSvgPath,
}); });
this.setLinks({ this.setLinks({
ciHelpPagePath: el.dataset.ciHelpPagePath, ciHelpPagePath: el.dataset.ciHelpPagePath,
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
});
this.setInitialData({
clientsidePreviewEnabled: convertPermissionToBoolean(el.dataset.clientsidePreviewEnabled),
}); });
}, },
methods: { methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks']), ...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
}, },
render(createElement) { render(createElement) {
return createElement('ide'); return createElement('ide');
......
import { getChangesCountForFiles, filePathMatches } from './utils'; 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; export const activeFile = state => state.openFiles.find(file => file.active) || null;
...@@ -90,5 +90,7 @@ export const lastCommit = (state, getters) => { ...@@ -90,5 +90,7 @@ export const lastCommit = (state, getters) => {
export const currentBranch = (state, getters) => export const currentBranch = (state, getters) =>
getters.currentProject && getters.currentProject.branches[state.currentBranchId]; 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 // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -115,13 +115,20 @@ export default { ...@@ -115,13 +115,20 @@ export default {
}, },
[types.SET_EMPTY_STATE_SVGS]( [types.SET_EMPTY_STATE_SVGS](
state, state,
{ emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath }, {
emptyStateSvgPath,
noChangesStateSvgPath,
committedStateSvgPath,
pipelinesEmptyStateSvgPath,
promotionSvgPath,
},
) { ) {
Object.assign(state, { Object.assign(state, {
emptyStateSvgPath, emptyStateSvgPath,
noChangesStateSvgPath, noChangesStateSvgPath,
committedStateSvgPath, committedStateSvgPath,
pipelinesEmptyStateSvgPath, pipelinesEmptyStateSvgPath,
promotionSvgPath,
}); });
}, },
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) { [types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
......
...@@ -44,7 +44,7 @@ export default { ...@@ -44,7 +44,7 @@ export default {
rawPath: data.raw_path, rawPath: data.raw_path,
binary: data.binary, binary: data.binary,
renderError: data.render_error, renderError: data.render_error,
raw: null, raw: (state.entries[file.path] && state.entries[file.path].raw) || null,
baseRaw: null, baseRaw: null,
html: data.html, html: data.html,
size: data.size, size: data.size,
......
...@@ -31,4 +31,5 @@ export default () => ({ ...@@ -31,4 +31,5 @@ export default () => ({
path: '', path: '',
entry: {}, entry: {},
}, },
clientsidePreviewEnabled: false,
}); });
import { commitItemIconMap } from './constants'; import { commitItemIconMap } from './constants';
// eslint-disable-next-line import/prefer-default-export
export const getCommitIconMap = file => { export const getCommitIconMap = file => {
if (file.deleted) { if (file.deleted) {
return commitItemIconMap.deleted; return commitItemIconMap.deleted;
...@@ -10,3 +9,9 @@ export const getCommitIconMap = file => { ...@@ -10,3 +9,9 @@ export const getCommitIconMap = file => {
return commitItemIconMap.modified; 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'}`;
};
...@@ -1229,6 +1229,10 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; ...@@ -1229,6 +1229,10 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
background-color: $white-light; background-color: $white-light;
border-left: 1px solid $white-dark; border-left: 1px solid $white-dark;
} }
.ide-right-sidebar-clientside {
padding: 0;
}
} }
.ide-pipeline { .ide-pipeline {
...@@ -1412,3 +1416,40 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; ...@@ -1412,3 +1416,40 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding;
color: $white-normal; color: $white-normal;
background-color: $blue-500; 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%);
}
...@@ -255,7 +255,8 @@ module ApplicationSettingsHelper ...@@ -255,7 +255,8 @@ module ApplicationSettingsHelper
:instance_statistics_visibility_private, :instance_statistics_visibility_private,
:user_default_external, :user_default_external,
:user_oauth_applications, :user_oauth_applications,
:version_check_enabled :version_check_enabled,
:web_ide_clientside_preview_enabled
] ]
end end
end end
...@@ -338,4 +338,27 @@ ...@@ -338,4 +338,27 @@
= render_if_exists 'admin/application_settings/custom_templates_form', expanded: expanded = 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 = render_if_exists 'admin/application_settings/pseudonymizer_settings', expanded: expanded
...@@ -8,7 +8,10 @@ ...@@ -8,7 +8,10 @@
"no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'), "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'), "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'), "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 .text-center
= icon('spinner spin 2x') = icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...') %h2.clgray= _('Loading the GitLab IDE...')
---
title: Added live preview for JavaScript projects in the Web IDE
merge_request: 19764
author:
type: added
...@@ -546,3 +546,27 @@ ...@@ -546,3 +546,27 @@
:why: Our own library :why: Our own library
:versions: [] :versions: []
:when: 2018-07-17 21:02:54.529227000 Z :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
# 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
...@@ -169,6 +169,7 @@ ActiveRecord::Schema.define(version: 20180726172057) do ...@@ -169,6 +169,7 @@ ActiveRecord::Schema.define(version: 20180726172057) do
t.boolean "mirror_available", default: true, null: false t.boolean "mirror_available", default: true, null: false
t.boolean "hide_third_party_offers", default: false, null: false t.boolean "hide_third_party_offers", default: false, null: false
t.boolean "instance_statistics_visibility_private", 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 end
create_table "audit_events", force: :cascade do |t| create_table "audit_events", force: :cascade do |t|
......
...@@ -2905,18 +2905,39 @@ msgstr "" ...@@ -2905,18 +2905,39 @@ msgstr ""
msgid "ID" msgid "ID"
msgstr "" 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" msgid "IDE|Commit"
msgstr "" msgstr ""
msgid "IDE|Edit" msgid "IDE|Edit"
msgstr "" msgstr ""
msgid "IDE|Get started with Live Preview"
msgstr ""
msgid "IDE|Go to project" msgid "IDE|Go to project"
msgstr "" msgstr ""
msgid "IDE|Live Preview"
msgstr ""
msgid "IDE|Open in file view" msgid "IDE|Open in file view"
msgstr "" msgstr ""
msgid "IDE|Preview your web application using Web IDE client-side evaluation."
msgstr ""
msgid "IDE|Refresh preview"
msgstr ""
msgid "IDE|Review" msgid "IDE|Review"
msgstr "" msgstr ""
...@@ -3234,6 +3255,9 @@ msgstr "" ...@@ -3234,6 +3255,9 @@ msgstr ""
msgid "List your GitHub repositories" msgid "List your GitHub repositories"
msgstr "" msgstr ""
msgid "Live preview"
msgstr ""
msgid "Loading the GitLab IDE..." msgid "Loading the GitLab IDE..."
msgstr "" msgstr ""
...@@ -3267,6 +3291,9 @@ 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." 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 "" msgstr ""
msgid "Manage Web IDE features"
msgstr ""
msgid "Manage access" msgid "Manage access"
msgstr "" msgstr ""
......
...@@ -69,4 +69,17 @@ describe('IDE right pane', () => { ...@@ -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();
});
});
});
}); });
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();
});
});
});
});
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`);
});
});
});
...@@ -179,4 +179,14 @@ describe('IDE store getters', () => { ...@@ -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',
});
});
});
}); });
...@@ -95,7 +95,7 @@ beforeEach(() => { ...@@ -95,7 +95,7 @@ beforeEach(() => {
let longRunningTestTimeoutHandle; let longRunningTestTimeoutHandle;
beforeEach((done) => { beforeEach(done => {
longRunningTestTimeoutHandle = setTimeout(() => { longRunningTestTimeoutHandle = setTimeout(() => {
done.fail('Test is running too long!'); done.fail('Test is running too long!');
}, 2000); }, 2000);
......
...@@ -1258,6 +1258,10 @@ binary-extensions@^1.0.0: ...@@ -1258,6 +1258,10 @@ binary-extensions@^1.0.0:
version "1.11.0" version "1.11.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" 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: bitsyntax@~0.0.4:
version "0.0.4" version "0.0.4"
resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82" resolved "https://registry.yarnpkg.com/bitsyntax/-/bitsyntax-0.0.4.tgz#eb10cc6f82b8c490e3e85698f07e83d46e0cba82"
...@@ -1776,6 +1780,22 @@ code-point-at@^1.0.0: ...@@ -1776,6 +1780,22 @@ code-point-at@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" 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: collection-visit@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
...@@ -2645,6 +2665,10 @@ ecc-jsbn@~0.1.1: ...@@ -2645,6 +2665,10 @@ ecc-jsbn@~0.1.1:
dependencies: dependencies:
jsbn "~0.1.0" 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: ee-first@1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
...@@ -3920,7 +3944,7 @@ https-proxy-agent@^2.2.1: ...@@ -3920,7 +3944,7 @@ https-proxy-agent@^2.2.1:
agent-base "^4.1.0" agent-base "^4.1.0"
debug "^3.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" version "0.4.23"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
dependencies: dependencies:
...@@ -3934,12 +3958,6 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17: ...@@ -3934,12 +3958,6 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17:
version "0.4.19" version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" 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: icss-replace-symbols@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
...@@ -4490,6 +4508,14 @@ istanbul@^0.4.5: ...@@ -4490,6 +4508,14 @@ istanbul@^0.4.5:
which "^1.1.1" which "^1.1.1"
wordwrap "^1.0.0" 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: isurl@^1.0.0-alpha5:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67" resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
...@@ -4839,6 +4865,10 @@ lodash.get@^4.4.2: ...@@ -4839,6 +4865,10 @@ lodash.get@^4.4.2:
version "4.4.2" version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" 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: lodash.kebabcase@4.1.1:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" 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: ...@@ -4948,6 +4978,10 @@ lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2:
pseudomap "^1.0.2" pseudomap "^1.0.2"
yallist "^2.1.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: mailcomposer@4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4" resolved "https://registry.yarnpkg.com/mailcomposer/-/mailcomposer-4.0.1.tgz#0e1c44b2a07cf740ee17dc149ba009f19cadfeb4"
...@@ -6735,6 +6769,14 @@ smart-buffer@^4.0.1: ...@@ -6735,6 +6769,14 @@ smart-buffer@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.0.1.tgz#07ea1ca8d4db24eb4cac86537d7d18995221ace3" 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: smtp-connection@2.12.0:
version "2.12.0" version "2.12.0"
resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1" resolved "https://registry.yarnpkg.com/smtp-connection/-/smtp-connection-2.12.0.tgz#d76ef9127cb23c2259edb1e8349c2e8d5e2d74c1"
...@@ -7240,6 +7282,10 @@ text-table@~0.2.0: ...@@ -7240,6 +7282,10 @@ text-table@~0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" 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: three-orbit-controls@^82.1.0:
version "82.1.0" version "82.1.0"
resolved "https://registry.yarnpkg.com/three-orbit-controls/-/three-orbit-controls-82.1.0.tgz#11a7f33d0a20ecec98f098b37780f6537374fab4" resolved "https://registry.yarnpkg.com/three-orbit-controls/-/three-orbit-controls-82.1.0.tgz#11a7f33d0a20ecec98f098b37780f6537374fab4"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment