summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2018-06-13 17:06:35 +0100
committerPhil Hughes <me@iamphill.com>2018-08-07 14:45:55 +0100
commit7b4b9e1cc453c2620502daceb94d3e2248b58dcb (patch)
tree96f79cdace962466a0a27d31c91dbdbc82d4bd88 /app/assets
parentf3b36ac1171f6d170d008c52a0a324a438f3e886 (diff)
downloadgitlab-ce-7b4b9e1cc453c2620502daceb94d3e2248b58dcb.tar.gz
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
Diffstat (limited to 'app/assets')
-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
11 files changed, 418 insertions, 8 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%);
+}