Verified 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
Pipeline #27351282 passed with stages
in 42 minutes and 20 seconds
<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>
......
<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 = {
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';
......@@ -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');
......
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 () => {};
......@@ -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) {
......
......@@ -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,
......
......@@ -31,4 +31,5 @@ export default () => ({
path: '',
entry: {},
},
clientsidePreviewEnabled: false,
});
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'}`;
};
......@@ -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%);
}
......@@ -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
......@@ -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
......@@ -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...')
---
title: Added live preview for JavaScript projects in the Web IDE
merge_request: 19764
author:
type: added
......@@ -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
# 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
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|
......
......@@ -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"