diff options
author | Phil Hughes <me@iamphill.com> | 2018-03-20 14:12:48 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-03-20 14:12:48 +0000 |
commit | f527e6e1855f30cf5ca5cb834b2d20438299a70e (patch) | |
tree | 9d5dd3e33f86cf43c0b445822341896e20027f94 /app/assets/javascripts/ide/components | |
parent | 68b914c9371e6e3fffc6afde23ca77a77ca688f2 (diff) | |
download | gitlab-ce-f527e6e1855f30cf5ca5cb834b2d20438299a70e.tar.gz |
Move IDE to CE
This also makes the IDE generally available
Diffstat (limited to 'app/assets/javascripts/ide/components')
27 files changed, 2069 insertions, 0 deletions
diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue new file mode 100644 index 00000000000..0c54c992e51 --- /dev/null +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -0,0 +1,31 @@ +<script> + import icon from '~/vue_shared/components/icon.vue'; + + export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + changedIcon() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; + }, + changedIconClass() { + return `multi-${this.changedIcon}`; + }, + }, + }; +</script> + +<template> + <icon + :name="changedIcon" + :size="12" + :css-classes="`ide-file-changed-icon ${changedIconClass}`" + /> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue new file mode 100644 index 00000000000..2cbd982af19 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -0,0 +1,65 @@ +<script> + import { mapState } from 'vuex'; + import { sprintf, __ } from '~/locale'; + import * as consts from '../../stores/modules/commit/constants'; + import RadioGroup from './radio_group.vue'; + + export default { + components: { + RadioGroup, + }, + computed: { + ...mapState([ + 'currentBranchId', + ]), + newMergeRequestHelpText() { + return sprintf( + __('Creates a new branch from %{branchName} and re-directs to create a new merge request'), + { branchName: this.currentBranchId }, + ); + }, + commitToCurrentBranchText() { + return sprintf( + __('Commit to %{branchName} branch'), + { branchName: `<strong>${this.currentBranchId}</strong>` }, + false, + ); + }, + commitToNewBranchText() { + return sprintf( + __('Creates a new branch from %{branchName}'), + { branchName: this.currentBranchId }, + ); + }, + }, + commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH, + commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH, + commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR, + }; +</script> + +<template> + <div class="append-bottom-15 ide-commit-radios"> + <radio-group + :value="$options.commitToCurrentBranch" + :checked="true" + > + <span + v-html="commitToCurrentBranchText" + > + </span> + </radio-group> + <radio-group + :value="$options.commitToNewBranch" + :label="__('Create a new branch')" + :show-input="true" + :help-text="commitToNewBranchText" + /> + <radio-group + :value="$options.commitToNewBranchMR" + :label="__('Create a new branch and merge request')" + :show-input="true" + :help-text="newMergeRequestHelpText" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue new file mode 100644 index 00000000000..453208f3f19 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -0,0 +1,66 @@ +<script> + import { mapState } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import listItem from './list_item.vue'; + import listCollapsed from './list_collapsed.vue'; + + export default { + components: { + icon, + listItem, + listCollapsed, + }, + props: { + title: { + type: String, + required: true, + }, + fileList: { + type: Array, + required: true, + }, + }, + computed: { + ...mapState([ + 'currentProjectId', + 'currentBranchId', + 'rightPanelCollapsed', + ]), + isCommitInfoShown() { + return this.rightPanelCollapsed || this.fileList.length; + }, + }, + methods: { + toggleCollapsed() { + this.$emit('toggleCollapsed'); + }, + }, + }; +</script> + +<template> + <div + :class="{ + 'multi-file-commit-list': isCommitInfoShown + }" + > + <list-collapsed + v-if="rightPanelCollapsed" + /> + <template v-else> + <ul + v-if="fileList.length" + class="list-unstyled append-bottom-0" + > + <li + v-for="file in fileList" + :key="file.key" + > + <list-item + :file="file" + /> + </li> + </ul> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue new file mode 100644 index 00000000000..15918ac9631 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -0,0 +1,35 @@ +<script> + import { mapGetters } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + + export default { + components: { + icon, + }, + computed: { + ...mapGetters([ + 'addedFiles', + 'modifiedFiles', + ]), + }, + }; +</script> + +<template> + <div + class="multi-file-commit-list-collapsed text-center" + > + <icon + name="file-addition" + :size="18" + css-classes="multi-file-addition append-bottom-10" + /> + {{ addedFiles.length }} + <icon + name="file-modified" + :size="18" + css-classes="multi-file-modified prepend-top-10 append-bottom-10" + /> + {{ modifiedFiles.length }} + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue new file mode 100644 index 00000000000..18934af004a --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -0,0 +1,60 @@ +<script> + import { mapActions } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import router from '../../ide_router'; + + export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + iconName() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; + }, + iconClass() { + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; + }, + }, + methods: { + ...mapActions([ + 'discardFileChanges', + 'updateViewer', + ]), + openFileInEditor(file) { + this.updateViewer('diff'); + + router.push(`/project${file.url}`); + }, + }, + }; +</script> + +<template> + <div class="multi-file-commit-list-item"> + <button + type="button" + class="multi-file-commit-list-path" + @click="openFileInEditor(file)"> + <span class="multi-file-commit-list-file-path"> + <icon + :name="iconName" + :size="16" + :css-classes="iconClass" + />{{ file.path }} + </span> + </button> + <button + type="button" + class="btn btn-blank multi-file-discard-btn" + @click="discardFileChanges(file.path)" + > + Discard + </button> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue new file mode 100644 index 00000000000..4310d762c78 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -0,0 +1,94 @@ +<script> + import { mapActions, mapState, mapGetters } from 'vuex'; + import tooltip from '~/vue_shared/directives/tooltip'; + + export default { + directives: { + tooltip, + }, + props: { + value: { + type: String, + required: true, + }, + label: { + type: String, + required: false, + default: null, + }, + checked: { + type: Boolean, + required: false, + default: false, + }, + showInput: { + type: Boolean, + required: false, + default: false, + }, + helpText: { + type: String, + required: false, + default: null, + }, + }, + computed: { + ...mapState('commit', [ + 'commitAction', + ]), + ...mapGetters('commit', [ + 'newBranchName', + ]), + }, + methods: { + ...mapActions('commit', [ + 'updateCommitAction', + 'updateBranchName', + ]), + }, + }; +</script> + +<template> + <fieldset> + <label> + <input + type="radio" + name="commit-action" + :value="value" + @change="updateCommitAction($event.target.value)" + :checked="checked" + v-once + /> + <span class="prepend-left-10"> + <template v-if="label"> + {{ label }} + </template> + <slot v-else></slot> + <span + v-if="helpText" + v-tooltip + class="help-block inline" + :title="helpText" + > + <i + class="fa fa-question-circle" + aria-hidden="true" + > + </i> + </span> + </span> + </label> + <div + v-if="commitAction === value && showInput" + class="ide-commit-new-branch" + > + <input + type="text" + class="form-control" + :placeholder="newBranchName" + @input="updateBranchName($event.target.value)" + /> + </div> + </fieldset> +</template> diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue new file mode 100644 index 00000000000..170347881e0 --- /dev/null +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -0,0 +1,91 @@ +<script> + import Icon from '~/vue_shared/components/icon.vue'; + + export default { + components: { + Icon, + }, + props: { + hasChanges: { + type: Boolean, + required: false, + default: false, + }, + viewer: { + type: String, + required: true, + }, + showShadow: { + type: Boolean, + required: true, + }, + }, + methods: { + changeMode(mode) { + this.$emit('click', mode); + }, + }, + }; +</script> + +<template> + <div + class="dropdown" + :class="{ + shadow: showShadow, + }" + > + <button + type="button" + class="btn btn-primary btn-sm" + :class="{ + 'btn-inverted': hasChanges, + }" + data-toggle="dropdown" + > + <template v-if="viewer === 'editor'"> + {{ __('Editing') }} + </template> + <template v-else> + {{ __('Reviewing') }} + </template> + <icon + name="angle-down" + :size="12" + css-classes="caret-down" + /> + </button> + <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> + <ul> + <li> + <a + href="#" + @click.prevent="changeMode('editor')" + :class="{ + 'is-active': viewer === 'editor', + }" + > + <strong class="dropdown-menu-inner-title">{{ __('Editing') }}</strong> + <span class="dropdown-menu-inner-content"> + {{ __('View and edit lines') }} + </span> + </a> + </li> + <li> + <a + href="#" + @click.prevent="changeMode('diff')" + :class="{ + 'is-active': viewer === 'diff', + }" + > + <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong> + <span class="dropdown-menu-inner-content"> + {{ __('Compare changes with the last commit') }} + </span> + </a> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue new file mode 100644 index 00000000000..015e750525a --- /dev/null +++ b/app/assets/javascripts/ide/components/ide.vue @@ -0,0 +1,111 @@ +<script> + import { mapState, mapGetters } from 'vuex'; + import ideSidebar from './ide_side_bar.vue'; + import ideContextbar from './ide_context_bar.vue'; + import repoTabs from './repo_tabs.vue'; + import repoFileButtons from './repo_file_buttons.vue'; + import ideStatusBar from './ide_status_bar.vue'; + import repoEditor from './repo_editor.vue'; + + export default { + components: { + ideSidebar, + ideContextbar, + repoTabs, + repoFileButtons, + ideStatusBar, + repoEditor, + }, + props: { + emptyStateSvgPath: { + type: String, + required: true, + }, + noChangesStateSvgPath: { + type: String, + required: true, + }, + committedStateSvgPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['changedFiles', 'openFiles', 'viewer']), + ...mapGetters(['activeFile', 'hasChanges']), + }, + mounted() { + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = e => { + if (!this.changedFiles.length) return undefined; + + Object.assign(e, { + returnValue, + }); + return returnValue; + }; + }, + }; +</script> + +<template> + <div + class="ide-view" + > + <ide-sidebar /> + <div + class="multi-file-edit-pane" + > + <template + v-if="activeFile" + > + <repo-tabs + :files="openFiles" + :viewer="viewer" + :has-changes="hasChanges" + /> + <repo-editor + class="multi-file-edit-pane-content" + :file="activeFile" + /> + <repo-file-buttons + :file="activeFile" + /> + <ide-status-bar + :file="activeFile" + /> + </template> + <template + v-else + > + <div + v-once + class="ide-empty-state" + > + <div class="row js-empty-state"> + <div class="col-xs-12"> + <div class="svg-content svg-250"> + <img :src="emptyStateSvgPath" /> + </div> + </div> + <div class="col-xs-12"> + <div class="text-content text-center"> + <h4> + Welcome to the GitLab IDE + </h4> + <p> + You can select a file in the left sidebar to begin + editing and use the right sidebar to commit your changes. + </p> + </div> + </div> + </div> + </div> + </template> + </div> + <ide-contextbar + :no-changes-state-svg-path="noChangesStateSvgPath" + :committed-state-svg-path="committedStateSvgPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue new file mode 100644 index 00000000000..79a83b47994 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -0,0 +1,84 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import icon from '~/vue_shared/components/icon.vue'; +import panelResizer from '~/vue_shared/components/panel_resizer.vue'; +import repoCommitSection from './repo_commit_section.vue'; +import ResizablePanel from './resizable_panel.vue'; + +export default { + components: { + repoCommitSection, + icon, + panelResizer, + ResizablePanel, + }, + props: { + noChangesStateSvgPath: { + type: String, + required: true, + }, + committedStateSvgPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['changedFiles', 'rightPanelCollapsed']), + ...mapGetters(['currentIcon']), + }, + methods: { + ...mapActions(['setPanelCollapsedStatus']), + }, +}; +</script> + +<template> + <resizable-panel + :collapsible="true" + :initial-width="340" + side="right" + > + <div + class="multi-file-commit-panel-section" + > + <header + class="multi-file-commit-panel-header" + :class="{ + 'is-collapsed': rightPanelCollapsed, + }" + > + <div + class="multi-file-commit-panel-header-title" + v-if="!rightPanelCollapsed" + > + <div + v-if="changedFiles.length" + > + <icon + name="list-bulleted" + :size="18" + /> + Staged + </div> + </div> + <button + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + @click.stop="setPanelCollapsedStatus({ + side: 'right', + collapsed: !rightPanelCollapsed, + })" + > + <icon + :name="currentIcon" + :size="18" + /> + </button> + </header> + <repo-commit-section + :no-changes-state-svg-path="noChangesStateSvgPath" + :committed-state-svg-path="committedStateSvgPath" + /> + </div> + </resizable-panel> +</template> diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue new file mode 100644 index 00000000000..c6f6e0d2348 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_external_links.vue @@ -0,0 +1,43 @@ +<script> +import icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + icon, + }, + props: { + projectUrl: { + type: String, + required: true, + }, + }, + computed: { + goBackUrl() { + return document.referrer || this.projectUrl; + }, + }, +}; +</script> + +<template> + <nav + class="ide-external-links" + v-once + > + <p> + <a + :href="goBackUrl" + class="ide-sidebar-link" + > + <icon + :size="16" + class="append-right-8" + name="go-back" + /> + <span class="ide-external-links-text"> + {{ s__('Go back') }} + </span> + </a> + </p> + </nav> +</template> diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue new file mode 100644 index 00000000000..eb2749e6151 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue @@ -0,0 +1,47 @@ +<script> + import icon from '~/vue_shared/components/icon.vue'; + import repoTree from './ide_repo_tree.vue'; + import newDropdown from './new_dropdown/index.vue'; + + export default { + components: { + repoTree, + icon, + newDropdown, + }, + props: { + projectId: { + type: String, + required: true, + }, + branch: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <div class="branch-container"> + <div class="branch-header"> + <div class="branch-header-title str-truncated ref-name"> + <icon + name="branch" + :size="12" + /> + {{ branch.name }} + </div> + <div class="branch-header-btns"> + <new-dropdown + :project-id="projectId" + :branch="branch.name" + path="" + /> + </div> + </div> + <repo-tree + :tree="branch.tree" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue new file mode 100644 index 00000000000..220db1abfb0 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_tree.vue @@ -0,0 +1,54 @@ +<script> +import projectAvatarImage from '~/vue_shared/components/project_avatar/image.vue'; +import branchesTree from './ide_project_branches_tree.vue'; +import externalLinks from './ide_external_links.vue'; + +export default { + components: { + branchesTree, + externalLinks, + projectAvatarImage, + }, + props: { + project: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="projects-sidebar"> + <div class="context-header"> + <a + :title="project.name" + :href="project.web_url" + > + <div class="avatar-container s40 project-avatar"> + <project-avatar-image + class="avatar-container project-avatar" + :link-href="project.path" + :img-src="project.avatar_url" + :img-alt="project.name" + :img-size="40" + /> + </div> + <div class="sidebar-context-title"> + {{ project.name }} + </div> + </a> + </div> + <external-links + :project-url="project.web_url" + /> + <div class="multi-file-commit-panel-inner-scroll"> + <branches-tree + v-for="branch in project.branches" + :key="branch.name" + :project-id="project.path_with_namespace" + :branch="branch" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue new file mode 100644 index 00000000000..e6af88e04bc --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue @@ -0,0 +1,41 @@ +<script> +import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import RepoFile from './repo_file.vue'; + +export default { + components: { + RepoFile, + SkeletonLoadingContainer, + }, + props: { + tree: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div + class="ide-file-list" + > + <template v-if="tree.loading"> + <div + class="multi-file-loading-container" + v-for="n in 3" + :key="n" + > + <skeleton-loading-container /> + </div> + </template> + <template v-else> + <repo-file + v-for="file in tree.tree" + :key="file.key" + :file="file" + :level="0" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue new file mode 100644 index 00000000000..8cf1ccb4fce --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -0,0 +1,51 @@ +<script> + import { mapState, mapGetters } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import panelResizer from '~/vue_shared/components/panel_resizer.vue'; + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + import projectTree from './ide_project_tree.vue'; + import ResizablePanel from './resizable_panel.vue'; + + export default { + components: { + projectTree, + icon, + panelResizer, + skeletonLoadingContainer, + ResizablePanel, + }, + computed: { + ...mapState([ + 'loading', + ]), + ...mapGetters([ + 'projectsWithTrees', + ]), + }, + }; +</script> + +<template> + <resizable-panel + :collapsible="false" + :initial-width="290" + side="left" + > + <div class="multi-file-commit-panel-inner"> + <template v-if="loading"> + <div + class="multi-file-loading-container" + v-for="n in 3" + :key="n" + > + <skeleton-loading-container /> + </div> + </template> + <project-tree + v-for="project in projectsWithTrees" + :key="project.id" + :project="project" + /> + </div> + </resizable-panel> +</template> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue new file mode 100644 index 00000000000..9c386896448 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -0,0 +1,60 @@ +<script> + import icon from '~/vue_shared/components/icon.vue'; + import tooltip from '~/vue_shared/directives/tooltip'; + import timeAgoMixin from '~/vue_shared/mixins/timeago'; + + export default { + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [ + timeAgoMixin, + ], + props: { + file: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <div class="ide-status-bar"> + <div class="ref-name"> + <icon + name="branch" + :size="12" + /> + {{ file.branchId }} + </div> + <div> + <div v-if="file.lastCommit && file.lastCommit.id"> + Last commit: + <a + v-tooltip + :title="file.lastCommit.message" + :href="file.lastCommit.url" + > + {{ timeFormated(file.lastCommit.updatedAt) }} by + {{ file.lastCommit.author }} + </a> + </div> + </div> + <div class="text-right"> + {{ file.name }} + </div> + <div class="text-right"> + {{ file.eol }} + </div> + <div class="text-right"> + {{ file.editorRow }}:{{ file.editorColumn }} + </div> + <div class="text-right"> + {{ file.fileLanguage }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue new file mode 100644 index 00000000000..769e9b79cad --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -0,0 +1,111 @@ +<script> + import { mapActions } from 'vuex'; + import icon from '~/vue_shared/components/icon.vue'; + import newModal from './modal.vue'; + import upload from './upload.vue'; + + export default { + components: { + icon, + newModal, + upload, + }, + props: { + branch: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + data() { + return { + openModal: false, + modalType: '', + dropdownOpen: false, + }; + }, + methods: { + ...mapActions([ + 'createTempEntry', + ]), + createNewItem(type) { + this.modalType = type; + this.openModal = true; + this.dropdownOpen = false; + }, + hideModal() { + this.openModal = false; + }, + openDropdown() { + this.dropdownOpen = !this.dropdownOpen; + }, + }, + }; +</script> + +<template> + <div class="ide-new-btn"> + <div + class="dropdown" + :class="{ + open: dropdownOpen, + }" + > + <button + type="button" + class="btn btn-sm btn-default dropdown-toggle add-to-tree" + aria-label="Create new file or directory" + @click.stop="openDropdown()" + > + <icon + name="plus" + :size="12" + css-classes="pull-left" + /> + <icon + name="arrow-down" + :size="12" + css-classes="pull-left" + /> + </button> + <ul class="dropdown-menu dropdown-menu-right"> + <li> + <a + href="#" + role="button" + @click.stop.prevent="createNewItem('blob')" + > + {{ __('New file') }} + </a> + </li> + <li> + <upload + :branch-id="branch" + :path="path" + @create="createTempEntry" + /> + </li> + <li> + <a + href="#" + role="button" + @click.stop.prevent="createNewItem('tree')" + > + {{ __('New directory') }} + </a> + </li> + </ul> + </div> + <new-modal + v-if="openModal" + :type="modalType" + :branch-id="branch" + :path="path" + @hide="hideModal" + @create="createTempEntry" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue new file mode 100644 index 00000000000..5723891d130 --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -0,0 +1,99 @@ +<script> + import { __ } from '~/locale'; + import modal from '~/vue_shared/components/modal.vue'; + + export default { + components: { + modal, + }, + props: { + branchId: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + data() { + return { + entryName: this.path !== '' ? `${this.path}/` : '', + }; + }, + computed: { + modalTitle() { + if (this.type === 'tree') { + return __('Create new directory'); + } + + return __('Create new file'); + }, + buttonLabel() { + if (this.type === 'tree') { + return __('Create directory'); + } + + return __('Create file'); + }, + formLabelName() { + if (this.type === 'tree') { + return __('Directory name'); + } + + return __('File name'); + }, + }, + mounted() { + this.$refs.fieldName.focus(); + }, + methods: { + createEntryInStore() { + this.$emit('create', { + branchId: this.branchId, + name: this.entryName, + type: this.type, + }); + + this.hideModal(); + }, + hideModal() { + this.$emit('hide'); + }, + }, + }; +</script> + +<template> + <modal + :title="modalTitle" + :primary-button-label="buttonLabel" + kind="success" + @cancel="hideModal" + @submit="createEntryInStore" + > + <form + class="form-horizontal" + slot="body" + @submit.prevent="createEntryInStore" + > + <fieldset class="form-group append-bottom-0"> + <label class="label-light col-sm-3"> + {{ formLabelName }} + </label> + <div class="col-sm-9"> + <input + type="text" + class="form-control" + v-model="entryName" + ref="fieldName" + /> + </div> + </fieldset> + </form> + </modal> +</template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue new file mode 100644 index 00000000000..c165af5ce52 --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -0,0 +1,75 @@ +<script> + export default { + props: { + branchId: { + type: String, + required: true, + }, + path: { + type: String, + required: false, + default: '', + }, + }, + mounted() { + this.$refs.fileUpload.addEventListener('change', this.openFile); + }, + beforeDestroy() { + this.$refs.fileUpload.removeEventListener('change', this.openFile); + }, + methods: { + createFile(target, file, isText) { + const { name } = file; + let { result } = target; + + if (!isText) { + result = result.split('base64,')[1]; + } + + this.$emit('create', { + name: `${(this.path ? `${this.path}/` : '')}${name}`, + branchId: this.branchId, + type: 'blob', + content: result, + base64: !isText, + }); + }, + readFile(file) { + const reader = new FileReader(); + const isText = file.type.match(/text.*/) !== null; + + reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true }); + + if (isText) { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + }, + openFile() { + Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file)); + }, + startFileUpload() { + this.$refs.fileUpload.click(); + }, + }, + }; +</script> + +<template> + <div> + <a + href="#" + role="button" + @click.stop.prevent="startFileUpload" + > + {{ __('Upload file') }} + </a> + <input + id="file-upload" + type="file" + class="hidden" + ref="fileUpload" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue new file mode 100644 index 00000000000..d772cab2d0e --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -0,0 +1,174 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import tooltip from '~/vue_shared/directives/tooltip'; +import icon from '~/vue_shared/components/icon.vue'; +import modal from '~/vue_shared/components/modal.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import commitFilesList from './commit_sidebar/list.vue'; +import * as consts from '../stores/modules/commit/constants'; +import Actions from './commit_sidebar/actions.vue'; + +export default { + components: { + modal, + icon, + commitFilesList, + Actions, + LoadingButton, + }, + directives: { + tooltip, + }, + props: { + noChangesStateSvgPath: { + type: String, + required: true, + }, + committedStateSvgPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState([ + 'currentProjectId', + 'currentBranchId', + 'rightPanelCollapsed', + 'lastCommitMsg', + 'changedFiles', + ]), + ...mapState('commit', [ + 'commitMessage', + 'submitCommitLoading', + ]), + ...mapGetters('commit', [ + 'commitButtonDisabled', + 'discardDraftButtonDisabled', + 'branchName', + ]), + statusSvg() { + return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath; + }, + }, + methods: { + ...mapActions([ + 'setPanelCollapsedStatus', + ]), + ...mapActions('commit', [ + 'updateCommitMessage', + 'discardDraft', + 'commitChanges', + 'updateCommitAction', + ]), + toggleCollapsed() { + this.setPanelCollapsedStatus({ + side: 'right', + collapsed: !this.rightPanelCollapsed, + }); + }, + forceCreateNewBranch() { + return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH) + .then(() => this.commitChanges()); + }, + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel-section" + :class="{ + 'multi-file-commit-empty-state-container': !changedFiles.length + }" + > + <modal + id="ide-create-branch-modal" + :primary-button-label="__('Create new branch')" + kind="success" + :title="__('Branch has changed')" + @submit="forceCreateNewBranch" + > + <template slot="body"> + {{ __(`This branch has changed since you started editing. + Would you like to create a new branch?`) }} + </template> + </modal> + <commit-files-list + title="Staged" + :file-list="changedFiles" + :collapsed="rightPanelCollapsed" + @toggleCollapsed="toggleCollapsed" + /> + <template + v-if="changedFiles.length" + > + <form + class="form-horizontal multi-file-commit-form" + @submit.prevent.stop="commitChanges" + v-if="!rightPanelCollapsed" + > + <div class="multi-file-commit-fieldset"> + <textarea + class="form-control multi-file-commit-message" + name="commit-message" + :value="commitMessage" + :placeholder="__('Write a commit message...')" + @input="updateCommitMessage($event.target.value)" + > + </textarea> + </div> + <div class="clearfix prepend-top-15"> + <actions /> + <loading-button + :loading="submitCommitLoading" + :disabled="commitButtonDisabled" + container-class="btn btn-success btn-sm pull-left" + :label="__('Commit')" + @click="commitChanges" + /> + <button + v-if="!discardDraftButtonDisabled" + type="button" + class="btn btn-default btn-sm pull-right" + @click="discardDraft" + > + {{ __('Discard draft') }} + </button> + </div> + </form> + </template> + <div + v-else-if="!rightPanelCollapsed" + class="row js-empty-state" + > + <div class="col-xs-10 col-xs-offset-1"> + <div class="svg-content svg-80"> + <img :src="statusSvg" /> + </div> + </div> + <div class="col-xs-10 col-xs-offset-1"> + <div + class="text-content text-center" + v-if="!lastCommitMsg" + > + <h4> + {{ __('No changes') }} + </h4> + <p> + {{ __('Edit files in the editor and commit changes here') }} + </p> + </div> + <div + class="text-content text-center" + v-else + > + <h4> + {{ __('All changes are committed') }} + </h4> + <p v-html="lastCommitMsg"> + </p> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue new file mode 100644 index 00000000000..e73d1ce839f --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -0,0 +1,161 @@ +<script> +/* global monaco */ +import { mapState, mapActions } from 'vuex'; +import flash from '~/flash'; +import monacoLoader from '../monaco_loader'; +import Editor from '../lib/editor'; + +export default { + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState([ + 'leftPanelCollapsed', + 'rightPanelCollapsed', + 'viewer', + 'delayViewerUpdated', + ]), + shouldHideEditor() { + return this.file && this.file.binary && !this.file.raw; + }, + }, + watch: { + file(oldVal, newVal) { + if (newVal.path !== this.file.path) { + this.initMonaco(); + } + }, + leftPanelCollapsed() { + this.editor.updateDimensions(); + }, + rightPanelCollapsed() { + this.editor.updateDimensions(); + }, + viewer() { + this.createEditorInstance(); + }, + }, + beforeDestroy() { + this.editor.dispose(); + }, + mounted() { + if (this.editor && monaco) { + this.initMonaco(); + } else { + monacoLoader(['vs/editor/editor.main'], () => { + this.editor = Editor.create(monaco); + + this.initMonaco(); + }); + } + }, + methods: { + ...mapActions([ + 'getRawFileData', + 'changeFileContent', + 'setFileLanguage', + 'setEditorPosition', + 'setFileEOL', + 'updateViewer', + 'updateDelayViewerUpdated', + ]), + initMonaco() { + if (this.shouldHideEditor) return; + + this.editor.clearEditor(); + + this.getRawFileData(this.file) + .then(() => { + const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve(); + + return viewerPromise; + }) + .then(() => { + this.updateDelayViewerUpdated(false); + this.createEditorInstance(); + }) + .catch((err) => { + flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true); + throw err; + }); + }, + createEditorInstance() { + this.editor.dispose(); + + this.$nextTick(() => { + if (this.viewer === 'editor') { + this.editor.createInstance(this.$refs.editor); + } else { + this.editor.createDiffInstance(this.$refs.editor); + } + + this.setupEditor(); + }); + }, + setupEditor() { + if (!this.file || !this.editor.instance) return; + + this.model = this.editor.createModel(this.file); + + this.editor.attachModel(this.model); + + this.model.onChange((model) => { + const { file } = model; + + if (file.active) { + this.changeFileContent({ + path: file.path, + content: model.getModel().getValue(), + }); + } + }); + + // Handle Cursor Position + this.editor.onPositionChange((instance, e) => { + this.setEditorPosition({ + editorRow: e.position.lineNumber, + editorColumn: e.position.column, + }); + }); + + this.editor.setPosition({ + lineNumber: this.file.editorRow, + column: this.file.editorColumn, + }); + + // Handle File Language + this.setFileLanguage({ + fileLanguage: this.model.language, + }); + + // Get File eol + this.setFileEOL({ + eol: this.model.eol, + }); + }, + }, +}; +</script> + +<template> + <div + id="ide" + class="blob-viewer-container blob-editor-container" + > + <div + v-if="shouldHideEditor" + v-html="file.html" + > + </div> + <div + v-show="!shouldHideEditor" + ref="editor" + class="multi-file-editor-holder" + > + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue new file mode 100644 index 00000000000..03a40096bb0 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -0,0 +1,127 @@ +<script> +import { mapActions } from 'vuex'; +import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; +import fileIcon from '~/vue_shared/components/file_icon.vue'; +import router from '../ide_router'; +import newDropdown from './new_dropdown/index.vue'; +import fileStatusIcon from './repo_file_status_icon.vue'; +import changedFileIcon from './changed_file_icon.vue'; + +export default { + name: 'RepoFile', + components: { + skeletonLoadingContainer, + newDropdown, + fileStatusIcon, + fileIcon, + changedFileIcon, + }, + props: { + file: { + type: Object, + required: true, + }, + level: { + type: Number, + required: true, + }, + }, + computed: { + isTree() { + return this.file.type === 'tree'; + }, + isBlob() { + return this.file.type === 'blob'; + }, + levelIndentation() { + return { + marginLeft: `${this.level * 16}px`, + }; + }, + fileClass() { + return { + 'file-open': this.isBlob && this.file.opened, + 'file-active': this.isBlob && this.file.active, + folder: this.isTree, + }; + }, + }, + updated() { + if (this.file.type === 'blob' && this.file.active) { + this.$el.scrollIntoView(); + } + }, + methods: { + ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']), + clickFile() { + // Manual Action if a tree is selected/opened + if ( + this.isTree && + this.$router.currentRoute.path === `/project${this.file.url}` + ) { + this.toggleTreeOpen(this.file.path); + } + + const delayPromise = this.file.changed + ? Promise.resolve() + : this.updateDelayViewerUpdated(true); + + return delayPromise.then(() => { + router.push(`/project${this.file.url}`); + }); + }, + }, +}; +</script> + +<template> + <div> + <div + class="file" + :class="fileClass" + > + <div + class="file-name" + @click="clickFile" + role="button" + > + <span + class="ide-file-name str-truncated" + :style="levelIndentation" + > + <file-icon + :file-name="file.name" + :loading="file.loading" + :folder="isTree" + :opened="file.opened" + :size="16" + /> + {{ file.name }} + <file-status-icon + :file="file" + /> + </span> + <changed-file-icon + :file="file" + v-if="file.changed || file.tempFile" + class="prepend-top-5 pull-right" + /> + <new-dropdown + v-if="isTree" + :project-id="file.projectId" + :branch="file.branchId" + :path="file.path" + class="pull-right prepend-left-8" + /> + </div> + </div> + <template v-if="file.opened"> + <repo-file + v-for="childFile in file.tree" + :key="childFile.key" + :file="childFile" + :level="level + 1" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue new file mode 100644 index 00000000000..4ea8cf7504b --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue @@ -0,0 +1,61 @@ +<script> +export default { + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + showButtons() { + return this.file.rawPath || + this.file.blamePath || + this.file.commitsPath || + this.file.permalink; + }, + rawDownloadButtonLabel() { + return this.file.binary ? 'Download' : 'Raw'; + }, + }, +}; +</script> + +<template> + <div + v-if="showButtons" + class="multi-file-editor-btn-group" + > + <a + :href="file.rawPath" + target="_blank" + class="btn btn-default btn-sm raw" + rel="noopener noreferrer"> + {{ rawDownloadButtonLabel }} + </a> + + <div + class="btn-group" + role="group" + aria-label="File actions" + > + <a + :href="file.blamePath" + class="btn btn-default btn-sm blame" + > + Blame + </a> + <a + :href="file.commitsPath" + class="btn btn-default btn-sm history" + > + History + </a> + <a + :href="file.permalink" + class="btn btn-default btn-sm permalink" + > + Permalink + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue new file mode 100644 index 00000000000..25d311142d5 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -0,0 +1,39 @@ +<script> + import icon from '~/vue_shared/components/icon.vue'; + import tooltip from '~/vue_shared/directives/tooltip'; + import '~/lib/utils/datetime_utility'; + + export default { + components: { + icon, + }, + directives: { + tooltip, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + lockTooltip() { + return `Locked by ${this.file.file_lock.user.name}`; + }, + }, + }; +</script> + +<template> + <span + v-if="file.file_lock" + v-tooltip + :title="lockTooltip" + data-container="body" + > + <icon + name="lock" + css-classes="file-status-icon" + /> + </span> +</template> diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue new file mode 100644 index 00000000000..79af8c0b0c7 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_loading_file.vue @@ -0,0 +1,42 @@ +<script> + import { mapState } from 'vuex'; + import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue'; + + export default { + components: { + skeletonLoadingContainer, + }, + computed: { + ...mapState([ + 'leftPanelCollapsed', + ]), + }, + }; +</script> + +<template> + <tr + class="loading-file" + aria-label="Loading files" + > + <td class="multi-file-table-col-name"> + <skeleton-loading-container + :small="true" + /> + </td> + <template v-if="!leftPanelCollapsed"> + <td class="hidden-sm hidden-xs"> + <skeleton-loading-container + :small="true" + /> + </td> + + <td class="hidden-xs"> + <skeleton-loading-container + class="animation-container-right" + :small="true" + /> + </td> + </template> + </tr> +</template> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue new file mode 100644 index 00000000000..c337bc813e6 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -0,0 +1,98 @@ +<script> + import { mapActions } from 'vuex'; + + import fileIcon from '~/vue_shared/components/file_icon.vue'; + import icon from '~/vue_shared/components/icon.vue'; + import fileStatusIcon from './repo_file_status_icon.vue'; + import changedFileIcon from './changed_file_icon.vue'; + + export default { + components: { + fileStatusIcon, + fileIcon, + icon, + changedFileIcon, + }, + props: { + tab: { + type: Object, + required: true, + }, + }, + data() { + return { + tabMouseOver: false, + }; + }, + computed: { + closeLabel() { + if (this.tab.changed || this.tab.tempFile) { + return `${this.tab.name} changed`; + } + return `Close ${this.tab.name}`; + }, + showChangedIcon() { + return this.tab.changed ? !this.tabMouseOver : false; + }, + }, + + methods: { + ...mapActions([ + 'closeFile', + ]), + clickFile(tab) { + this.$router.push(`/project${tab.url}`); + }, + mouseOverTab() { + if (this.tab.changed) { + this.tabMouseOver = true; + } + }, + mouseOutTab() { + if (this.tab.changed) { + this.tabMouseOver = false; + } + }, + }, + }; +</script> + +<template> + <li + @click="clickFile(tab)" + @mouseover="mouseOverTab" + @mouseout="mouseOutTab" + > + <button + type="button" + class="multi-file-tab-close" + @click.stop.prevent="closeFile(tab.path)" + :aria-label="closeLabel" + > + <icon + v-if="!showChangedIcon" + name="close" + :size="12" + /> + <changed-file-icon + v-else + :file="tab" + /> + </button> + + <div + class="multi-file-tab" + :class="{active : tab.active }" + :title="tab.url" + > + <file-icon + :file-name="tab.name" + :size="16" + /> + {{ tab.name }} + <file-status-icon + :file="tab" + /> + </div> + </li> +</template> diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue new file mode 100644 index 00000000000..8ea64ddf84a --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -0,0 +1,61 @@ +<script> + import { mapActions } from 'vuex'; + import RepoTab from './repo_tab.vue'; + import EditorMode from './editor_mode_dropdown.vue'; + + export default { + components: { + RepoTab, + EditorMode, + }, + props: { + files: { + type: Array, + required: true, + }, + viewer: { + type: String, + required: true, + }, + hasChanges: { + type: Boolean, + required: true, + }, + }, + data() { + return { + showShadow: false, + }; + }, + updated() { + if (!this.$refs.tabsScroller) return; + + this.showShadow = + this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + }, + methods: { + ...mapActions(['updateViewer']), + }, + }; +</script> + +<template> + <div class="multi-file-tabs"> + <ul + class="list-unstyled append-bottom-0" + ref="tabsScroller" + > + <repo-tab + v-for="tab in files" + :key="tab.key" + :tab="tab" + /> + </ul> + <editor-mode + :viewer="viewer" + :show-shadow="showShadow" + :has-changes="hasChanges" + @click="updateViewer" + /> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue new file mode 100644 index 00000000000..faa690ecba0 --- /dev/null +++ b/app/assets/javascripts/ide/components/resizable_panel.vue @@ -0,0 +1,88 @@ +<script> + import { mapActions, mapState } from 'vuex'; + import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; + + export default { + components: { + PanelResizer, + }, + props: { + collapsible: { + type: Boolean, + required: true, + }, + initialWidth: { + type: Number, + required: true, + }, + minSize: { + type: Number, + required: false, + default: 200, + }, + side: { + type: String, + required: true, + }, + }, + data() { + return { + width: this.initialWidth, + }; + }, + computed: { + ...mapState({ + collapsed(state) { + return state[`${this.side}PanelCollapsed`]; + }, + }), + panelStyle() { + if (!this.collapsed) { + return { + width: `${this.width}px`, + }; + } + + return {}; + }, + }, + methods: { + ...mapActions([ + 'setPanelCollapsedStatus', + 'setResizingStatus', + ]), + toggleFullbarCollapsed() { + if (this.collapsed && this.collapsible) { + this.setPanelCollapsedStatus({ + side: this.side, + collapsed: !this.collapsed, + }); + } + }, + }, + maxSize: (window.innerWidth / 2), + }; +</script> + +<template> + <div + class="multi-file-commit-panel" + :class="{ + 'is-collapsed': collapsed && collapsible, + }" + :style="panelStyle" + @click="toggleFullbarCollapsed" + > + <slot></slot> + <panel-resizer + :size.sync="width" + :enabled="!collapsed" + :start-size="initialWidth" + :min-size="minSize" + :max-size="$options.maxSize" + @resize-start="setResizingStatus(true)" + @resize-end="setResizingStatus(false)" + :side="side === 'right' ? 'left' : 'right'" + /> + </div> +</template> |