diff options
author | Phil Hughes <me@iamphill.com> | 2018-06-13 17:06:35 +0100 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-08-07 14:45:55 +0100 |
commit | 7b4b9e1cc453c2620502daceb94d3e2248b58dcb (patch) | |
tree | 96f79cdace962466a0a27d31c91dbdbc82d4bd88 /app/assets | |
parent | f3b36ac1171f6d170d008c52a0a324a438f3e886 (diff) | |
download | gitlab-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.vue | 33 | ||||
-rw-r--r-- | app/assets/javascripts/ide/components/preview/clientside.vue | 171 | ||||
-rw-r--r-- | app/assets/javascripts/ide/components/preview/navigator.vue | 147 | ||||
-rw-r--r-- | app/assets/javascripts/ide/constants.js | 3 | ||||
-rw-r--r-- | app/assets/javascripts/ide/index.js | 8 | ||||
-rw-r--r-- | app/assets/javascripts/ide/stores/getters.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/ide/stores/mutations.js | 9 | ||||
-rw-r--r-- | app/assets/javascripts/ide/stores/mutations/file.js | 2 | ||||
-rw-r--r-- | app/assets/javascripts/ide/stores/state.js | 1 | ||||
-rw-r--r-- | app/assets/javascripts/ide/utils.js | 7 | ||||
-rw-r--r-- | app/assets/stylesheets/page_bundles/ide.scss | 41 |
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%); +} |