diff options
Diffstat (limited to 'app/assets')
53 files changed, 1475 insertions, 576 deletions
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 842a4255f08..4164149dd06 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -2,7 +2,9 @@ import $ from 'jquery'; import Pikaday from 'pikaday'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; +import { timeFor } from './lib/utils/datetime_utility'; import { parsePikadayDate, pikadayToString } from './lib/utils/datefix'; class DueDateSelect { @@ -14,6 +16,7 @@ class DueDateSelect { this.$dropdownParent = $dropdownParent; this.$datePicker = $dropdownParent.find('.js-due-date-calendar'); this.$block = $block; + this.$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); this.$selectbox = $dropdown.closest('.selectbox'); this.$value = $block.find('.value'); this.$valueContent = $block.find('.value-content'); @@ -128,7 +131,8 @@ class DueDateSelect { submitSelectedDate(isDropdown) { const selectedDateValue = this.datePayload[this.abilityName].due_date; - const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value'; + const hasDueDate = this.displayedDate !== 'No due date'; + const displayedDateStyle = hasDueDate ? 'bold' : 'no-value'; this.$loading.removeClass('hidden').fadeIn(); @@ -145,10 +149,13 @@ class DueDateSelect { return axios.put(this.issueUpdateURL, this.datePayload) .then(() => { + const tooltipText = hasDueDate ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})` : __('Due date'); if (isDropdown) { this.$dropdown.trigger('loaded.gl.dropdown'); this.$dropdown.dropdown('toggle'); } + this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); + return this.$loading.fadeOut(); }); } diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue index 037e3efb4ce..1fc11c84639 100644 --- a/app/assets/javascripts/ide/components/changed_file_icon.vue +++ b/app/assets/javascripts/ide/components/changed_file_icon.vue @@ -1,31 +1,87 @@ <script> -import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; +import { pluralize } from '~/lib/utils/text_utility'; +import { __, sprintf } from '~/locale'; export default { components: { - icon, + Icon, + }, + directives: { + tooltip, }, props: { file: { type: Object, required: true, }, + showTooltip: { + type: Boolean, + required: false, + default: false, + }, + showStagedIcon: { + type: Boolean, + required: false, + default: false, + }, }, computed: { changedIcon() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; + const suffix = this.file.staged && !this.showStagedIcon ? '-solid' : ''; + return this.file.tempFile ? `file-addition${suffix}` : `file-modified${suffix}`; + }, + stagedIcon() { + return `${this.changedIcon}-solid`; }, changedIconClass() { - return `multi-${this.changedIcon}`; + return `multi-${this.changedIcon} prepend-left-5 pull-left`; + }, + tooltipTitle() { + if (!this.showTooltip) return undefined; + + const type = this.file.tempFile ? 'addition' : 'modification'; + + if (this.file.changed && !this.file.staged) { + return sprintf(__('Unstaged %{type}'), { + type, + }); + } else if (!this.file.changed && this.file.staged) { + return sprintf(__('Staged %{type}'), { + type, + }); + } else if (this.file.changed && this.file.staged) { + return sprintf(__('Unstaged and staged %{type}'), { + type: pluralize(type), + }); + } + + return undefined; }, }, }; </script> <template> - <icon - :name="changedIcon" - :size="12" - :css-classes="`ide-file-changed-icon ${changedIconClass}`" - /> + <span + v-tooltip + :title="tooltipTitle" + data-container="body" + data-placement="right" + class="ide-file-changed-icon" + > + <icon + v-if="file.staged && showStagedIcon" + :name="stagedIcon" + :size="12" + :css-classes="changedIconClass" + /> + <icon + v-if="file.changed || file.tempFile || (file.staged && !showStagedIcon)" + :name="changedIcon" + :size="12" + :css-classes="changedIconClass" + /> + </span> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue new file mode 100644 index 00000000000..6424b93ce54 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue @@ -0,0 +1,93 @@ +<script> +import { mapActions, mapState, mapGetters } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + noChangesStateSvgPath: { + type: String, + required: true, + }, + committedStateSvgPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['lastCommitMsg', 'rightPanelCollapsed']), + ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']), + statusSvg() { + return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath; + }, + }, + methods: { + ...mapActions(['toggleRightPanelCollapsed']), + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state" + > + <header + class="multi-file-commit-panel-header" + :class="{ + 'is-collapsed': rightPanelCollapsed, + }" + > + <button + v-tooltip + :title="collapseButtonTooltip" + data-container="body" + data-placement="left" + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + :aria-label="__('Toggle sidebar')" + @click.stop="toggleRightPanelCollapsed" + > + <icon + :name="collapseButtonIcon" + :size="18" + /> + </button> + </header> + <div + class="ide-commit-empty-state-container" + v-if="!rightPanelCollapsed" + > + <div class="svg-content svg-80"> + <img :src="statusSvg" /> + </div> + <div class="append-right-default prepend-left-default"> + <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/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 453208f3f19..ff05ee8682a 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,56 +1,132 @@ <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'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import ListItem from './list_item.vue'; +import ListCollapsed from './list_collapsed.vue'; - export default { - components: { - icon, - listItem, - listCollapsed, +export default { + components: { + Icon, + ListItem, + ListCollapsed, + }, + directives: { + tooltip, + }, + props: { + title: { + type: String, + required: true, }, - props: { - title: { - type: String, - required: true, - }, - fileList: { - type: Array, - required: true, - }, + fileList: { + type: Array, + required: true, }, - computed: { - ...mapState([ - 'currentProjectId', - 'currentBranchId', - 'rightPanelCollapsed', - ]), - isCommitInfoShown() { - return this.rightPanelCollapsed || this.fileList.length; - }, + showToggle: { + type: Boolean, + required: false, + default: true, }, - methods: { - toggleCollapsed() { - this.$emit('toggleCollapsed'); - }, + iconName: { + type: String, + required: true, }, - }; + action: { + type: String, + required: true, + }, + actionBtnText: { + type: String, + required: true, + }, + itemActionComponent: { + type: String, + required: true, + }, + stagedList: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState(['rightPanelCollapsed']), + ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']), + titleText() { + return sprintf(__('%{title} changes'), { + title: this.title, + }); + }, + }, + methods: { + ...mapActions(['toggleRightPanelCollapsed', 'stageAllChanges', 'unstageAllChanges']), + actionBtnClicked() { + this[this.action](); + }, + }, +}; </script> <template> <div + class="ide-commit-list-container" :class="{ - 'multi-file-commit-list': isCommitInfoShown + 'is-collapsed': rightPanelCollapsed, }" > + <header + class="multi-file-commit-panel-header" + > + <div + v-if="!rightPanelCollapsed" + class="multi-file-commit-panel-header-title" + :class="{ + 'append-right-10': showToggle, + }" + > + <icon + v-once + :name="iconName" + :size="18" + /> + {{ titleText }} + <button + type="button" + class="btn btn-blank btn-link ide-staged-action-btn" + @click="actionBtnClicked" + > + {{ actionBtnText }} + </button> + </div> + <button + v-if="showToggle" + v-tooltip + :title="collapseButtonTooltip" + data-container="body" + data-placement="left" + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + :aria-label="__('Toggle sidebar')" + @click.stop="toggleRightPanelCollapsed" + > + <icon + :name="collapseButtonIcon" + :size="18" + /> + </button> + </header> <list-collapsed v-if="rightPanelCollapsed" + :files="fileList" + :icon-name="iconName" + :title="title" /> <template v-else> <ul v-if="fileList.length" - class="list-unstyled append-bottom-0" + class="multi-file-commit-list list-unstyled append-bottom-0" > <li v-for="file in fileList" @@ -58,9 +134,18 @@ > <list-item :file="file" + :action-component="itemActionComponent" + :key-prefix="title" + :staged-list="stagedList" /> </li> </ul> + <p + v-else + class="multi-file-commit-list help-block" + > + {{ __('No changes') }} + </p> </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 index 15918ac9631..2254271c679 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -1,35 +1,110 @@ <script> - import { mapGetters } from 'vuex'; - import icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { sprintf, n__, __ } from '~/locale'; - export default { - components: { - icon, +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + files: { + type: Array, + required: true, }, - computed: { - ...mapGetters([ - 'addedFiles', - 'modifiedFiles', - ]), + iconName: { + type: String, + required: true, }, - }; + title: { + type: String, + required: true, + }, + }, + computed: { + addedFilesLength() { + return this.files.filter(f => f.tempFile).length; + }, + modifiedFilesLength() { + return this.files.filter(f => !f.tempFile).length; + }, + addedFilesIconClass() { + return this.addedFilesLength ? 'multi-file-addition' : ''; + }, + modifiedFilesClass() { + return this.modifiedFilesLength ? 'multi-file-modified' : ''; + }, + additionsTooltip() { + return sprintf(n__('1 %{type} addition', '%d %{type} additions', this.addedFilesLength), { + type: this.title.toLowerCase(), + }); + }, + modifiedTooltip() { + return sprintf( + n__('1 %{type} modification', '%d %{type} modifications', this.modifiedFilesLength), + { type: this.title.toLowerCase() }, + ); + }, + titleTooltip() { + return sprintf(__('%{title} changes'), { title: this.title }); + }, + additionIconName() { + return this.title.toLowerCase() === 'staged' ? 'file-addition-solid' : 'file-addition'; + }, + modifiedIconName() { + return this.title.toLowerCase() === 'staged' ? 'file-modified-solid' : 'file-modified'; + }, + }, +}; </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 + v-tooltip + :title="titleTooltip" + data-container="body" + data-placement="left" + class="append-bottom-15" + > + <icon + v-once + :name="iconName" + :size="18" + /> + </div> + <div + v-tooltip + :title="additionsTooltip" + data-container="body" + data-placement="left" + class="append-bottom-10" + > + <icon + :name="additionIconName" + :size="18" + :css-classes="addedFilesIconClass" + /> + </div> + {{ addedFilesLength }} + <div + v-tooltip + :title="modifiedTooltip" + data-container="body" + data-placement="left" + class="prepend-top-10 append-bottom-10" + > + <icon + :name="modifiedIconName" + :size="18" + :css-classes="modifiedFilesClass" + /> + </div> + {{ modifiedFilesLength }} </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 index 560cdd941cd..ad4713c40d5 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -1,34 +1,69 @@ <script> import { mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; +import StageButton from './stage_button.vue'; +import UnstageButton from './unstage_button.vue'; export default { components: { Icon, + StageButton, + UnstageButton, }, props: { file: { type: Object, required: true, }, + actionComponent: { + type: String, + required: true, + }, + keyPrefix: { + type: String, + required: false, + default: '', + }, + stagedList: { + type: Boolean, + required: false, + default: false, + }, }, computed: { iconName() { - return this.file.tempFile ? 'file-addition' : 'file-modified'; + const prefix = this.stagedList ? '-solid' : ''; + return this.file.tempFile ? `file-addition${prefix}` : `file-modified${prefix}`; }, iconClass() { - return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; + return `multi-file-${this.file.tempFile ? 'additions' : 'modified'} append-right-8`; }, }, methods: { - ...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']), - openFileInEditor(file) { - return this.openPendingTab(file).then(changeViewer => { + ...mapActions([ + 'discardFileChanges', + 'updateViewer', + 'openPendingTab', + 'unstageChange', + 'stageChange', + ]), + openFileInEditor() { + return this.openPendingTab({ + file: this.file, + keyPrefix: this.keyPrefix.toLowerCase(), + }).then(changeViewer => { if (changeViewer) { this.updateViewer('diff'); } }); }, + fileAction() { + if (this.file.staged) { + this.unstageChange(this.file.path); + } else { + this.stageChange(this.file.path); + } + }, }, }; </script> @@ -38,7 +73,9 @@ export default { <button type="button" class="multi-file-commit-list-path" - @click="openFileInEditor(file)"> + @dblclick="fileAction" + @click="openFileInEditor" + > <span class="multi-file-commit-list-file-path"> <icon :name="iconName" @@ -47,12 +84,9 @@ export default { />{{ file.path }} </span> </button> - <button - type="button" - class="btn btn-blank multi-file-discard-btn" - @click="discardFileChanges(file.path)" - > - Discard - </button> + <component + :is="actionComponent" + :path="file.path" + /> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue new file mode 100644 index 00000000000..52dce8412ab --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue @@ -0,0 +1,59 @@ +<script> +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + path: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions(['stageChange', 'discardFileChanges']), + }, +}; +</script> + +<template> + <div + v-once + class="multi-file-discard-btn" + > + <button + v-tooltip + type="button" + class="btn btn-blank append-right-5" + :aria-label="__('Stage changes')" + :title="__('Stage changes')" + data-container="body" + @click.stop="stageChange(path)" + > + <icon + name="mobile-issue-close" + :size="12" + /> + </button> + <button + v-tooltip + type="button" + class="btn btn-blank" + :aria-label="__('Discard changes')" + :title="__('Discard changes')" + data-container="body" + @click.stop="discardFileChanges(path)" + > + <icon + name="remove" + :size="12" + /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue new file mode 100644 index 00000000000..123d60da47e --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue @@ -0,0 +1,45 @@ +<script> +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + Icon, + }, + directives: { + tooltip, + }, + props: { + path: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions(['unstageChange']), + }, +}; +</script> + +<template> + <div + v-once + class="multi-file-discard-btn" + > + <button + v-tooltip + type="button" + class="btn btn-blank" + :aria-label="__('Unstage changes')" + :title="__('Unstage changes')" + data-container="body" + @click="unstageChange(path)" + > + <icon + name="history" + :size="12" + /> + </button> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue index 79a83b47994..627fbeb9adf 100644 --- a/app/assets/javascripts/ide/components/ide_context_bar.vue +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -1,5 +1,4 @@ <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'; @@ -22,13 +21,6 @@ export default { required: true, }, }, - computed: { - ...mapState(['changedFiles', 'rightPanelCollapsed']), - ...mapGetters(['currentIcon']), - }, - methods: { - ...mapActions(['setPanelCollapsedStatus']), - }, }; </script> @@ -41,40 +33,6 @@ export default { <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" diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 152a5f632ad..c13eeeace3f 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -22,13 +22,6 @@ export default { <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: diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 1e4adf1a373..1152de85e5d 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,10 +1,11 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; -import icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; -import commitFilesList from './commit_sidebar/list.vue'; +import CommitFilesList from './commit_sidebar/list.vue'; +import EmptyState from './commit_sidebar/empty_state.vue'; import CommitMessageField from './commit_sidebar/message_field.vue'; import * as consts from '../stores/modules/commit/constants'; import Actions from './commit_sidebar/actions.vue'; @@ -12,8 +13,9 @@ import Actions from './commit_sidebar/actions.vue'; export default { components: { DeprecatedModal, - icon, - commitFilesList, + Icon, + CommitFilesList, + EmptyState, Actions, LoadingButton, CommitMessageField, @@ -32,33 +34,17 @@ export default { }, }, computed: { - ...mapState([ - 'currentProjectId', - 'currentBranchId', - 'rightPanelCollapsed', - 'lastCommitMsg', - 'changedFiles', - ]), + ...mapState(['changedFiles', 'stagedFiles', 'rightPanelCollapsed']), ...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()); }, @@ -69,9 +55,6 @@ export default { <template> <div class="multi-file-commit-panel-section" - :class="{ - 'multi-file-commit-empty-state-container': !changedFiles.length - }" > <deprecated-modal id="ide-create-branch-modal" @@ -85,15 +68,27 @@ export default { Would you like to create a new branch?`) }} </template> </deprecated-modal> - <commit-files-list - title="Staged" - :file-list="changedFiles" - :collapsed="rightPanelCollapsed" - @toggleCollapsed="toggleCollapsed" - /> <template - v-if="changedFiles.length" + v-if="changedFiles.length || stagedFiles.length" > + <commit-files-list + icon-name="unstaged" + :title="__('Unstaged')" + :file-list="changedFiles" + action="stageAllChanges" + :action-btn-text="__('Stage all')" + item-action-component="stage-button" + /> + <commit-files-list + icon-name="staged" + :title="__('Staged')" + :file-list="stagedFiles" + action="unstageAllChanges" + :action-btn-text="__('Unstage all')" + item-action-component="unstage-button" + :show-toggle="false" + :staged-list="true" + /> <form class="multi-file-commit-form" @submit.prevent.stop="commitChanges" @@ -123,38 +118,10 @@ export default { </div> </form> </template> - <div - v-else-if="!rightPanelCollapsed" - class="row js-empty-state" - > - <div class="col-10 col-offset-1"> - <div class="svg-content svg-80"> - <img :src="statusSvg" /> - </div> - </div> - <div class="col-10 col-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> + <empty-state + v-else + :no-changes-state-svg-path="noChangesStateSvgPath" + :committed-state-svg-path="committedStateSvgPath" + /> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index e18972f4298..22d5aaeae11 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -20,7 +20,7 @@ export default { }, computed: { ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']), - ...mapGetters(['currentMergeRequest']), + ...mapGetters(['currentMergeRequest', 'getStagedFile']), shouldHideEditor() { return this.file && this.file.binary && !this.file.content; }, @@ -120,7 +120,12 @@ export default { setupEditor() { if (!this.file || !this.editor.instance) return; - this.model = this.editor.createModel(this.file); + const head = this.getStagedFile(this.file.path); + + this.model = this.editor.createModel( + this.file, + this.file.staged && this.file.key.indexOf('unstaged-') === 0 ? head : null, + ); if (this.viewer === 'mrdiff') { this.editor.attachMergeRequestModel(this.model); diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index f1d14440de6..ec4ce14e5b4 100644 --- a/app/assets/javascripts/ide/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -102,8 +102,11 @@ export default { v-if="file.mrChange" /> <changed-file-icon + v-if="file.changed || file.tempFile || file.staged" :file="file" - v-if="file.changed || file.tempFile" + :show-tooltip="true" + :show-staged-icon="true" + class="prepend-top-5 pull-right" /> </span> <new-dropdown diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 304a73ed1ad..35a362b01e0 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -26,13 +26,16 @@ export default { }, computed: { closeLabel() { - if (this.tab.changed || this.tab.tempFile) { + if (this.fileHasChanged) { return `${this.tab.name} changed`; } return `Close ${this.tab.name}`; }, showChangedIcon() { - return this.tab.changed ? !this.tabMouseOver : false; + return this.fileHasChanged ? !this.tabMouseOver : false; + }, + fileHasChanged() { + return this.tab.changed || this.tab.tempFile || this.tab.staged; }, }, @@ -42,18 +45,18 @@ export default { this.updateDelayViewerUpdated(true); if (tab.pending) { - this.openPendingTab(tab); + this.openPendingTab({ file: tab, keyPrefix: tab.staged ? 'staged' : 'unstaged' }); } else { this.$router.push(`/project${tab.url}`); } }, mouseOverTab() { - if (this.tab.changed) { + if (this.fileHasChanged) { this.tabMouseOver = true; } }, mouseOutTab() { - if (this.tab.changed) { + if (this.fileHasChanged) { this.tabMouseOver = false; } }, diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index e47adae99ed..016dcda1fa1 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -3,15 +3,16 @@ import Disposable from './disposable'; import eventHub from '../../eventhub'; export default class Model { - constructor(monaco, file) { + constructor(monaco, file, head = null) { this.monaco = monaco; this.disposable = new Disposable(); this.file = file; + this.head = head; this.content = file.content !== '' ? file.content : file.raw; this.disposable.add( (this.originalModel = this.monaco.editor.createModel( - this.file.raw, + head ? head.content : this.file.raw, undefined, new this.monaco.Uri(null, null, `original/${this.file.key}`), )), @@ -31,13 +32,15 @@ export default class Model { ); } - this.events = new Map(); + this.events = new Set(); this.updateContent = this.updateContent.bind(this); + this.updateNewContent = this.updateNewContent.bind(this); this.dispose = this.dispose.bind(this); eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose); - eventHub.$on(`editor.update.model.content.${this.file.path}`, this.updateContent); + eventHub.$on(`editor.update.model.content.${this.file.key}`, this.updateContent); + eventHub.$on(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent); } get url() { @@ -73,22 +76,36 @@ export default class Model { } onChange(cb) { - this.events.set( - this.path, - this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))), - ); + this.events.add(this.disposable.add(this.model.onDidChangeContent(e => cb(this, e)))); + } + + onDispose(cb) { + this.events.add(cb); } - updateContent(content) { + updateContent({ content, changed }) { this.getOriginalModel().setValue(content); + + if (!changed) { + this.getModel().setValue(content); + } + } + + updateNewContent(content) { this.getModel().setValue(content); } dispose() { this.disposable.dispose(); + + this.events.forEach(cb => { + if (typeof cb === 'function') cb(); + }); + this.events.clear(); eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); - eventHub.$off(`editor.update.model.content.${this.file.path}`, this.updateContent); + eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent); + eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent); } } diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js index 0e7b563b5d6..7f643969480 100644 --- a/app/assets/javascripts/ide/lib/common/model_manager.js +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -17,12 +17,12 @@ export default class ModelManager { return this.models.get(key); } - addModel(file) { + addModel(file, head = null) { if (this.hasCachedModel(file.key)) { return this.getModel(file.key); } - const model = new Model(this.monaco, file); + const model = new Model(this.monaco, file, head); this.models.set(model.path, model); this.disposable.add(model); diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js index 42904774747..13d477bb2cf 100644 --- a/app/assets/javascripts/ide/lib/decorations/controller.js +++ b/app/assets/javascripts/ide/lib/decorations/controller.js @@ -38,6 +38,15 @@ export default class DecorationsController { ); } + hasDecorations(model) { + return this.decorations.has(model.url); + } + + removeDecorations(model) { + this.decorations.delete(model.url); + this.editorDecorations.delete(model.url); + } + dispose() { this.decorations.clear(); this.editorDecorations.clear(); diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index b136545ad11..f579424cf33 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -3,7 +3,7 @@ import { throttle } from 'underscore'; import DirtyDiffWorker from './diff_worker'; import Disposable from '../common/disposable'; -export const getDiffChangeType = (change) => { +export const getDiffChangeType = change => { if (change.modified) { return 'modified'; } else if (change.added) { @@ -16,12 +16,7 @@ export const getDiffChangeType = (change) => { }; export const getDecorator = change => ({ - range: new monaco.Range( - change.lineNumber, - 1, - change.endLineNumber, - 1, - ), + range: new monaco.Range(change.lineNumber, 1, change.endLineNumber, 1), options: { isWholeLine: true, linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`, @@ -31,6 +26,7 @@ export const getDecorator = change => ({ export default class DirtyDiffController { constructor(modelManager, decorationsController) { this.disposable = new Disposable(); + this.models = new Map(); this.editorSimpleWorker = null; this.modelManager = modelManager; this.decorationsController = decorationsController; @@ -42,7 +38,15 @@ export default class DirtyDiffController { } attachModel(model) { + if (this.models.has(model.url)) return; + model.onChange(() => this.throttledComputeDiff(model)); + model.onDispose(() => { + this.decorationsController.removeDecorations(model); + this.models.delete(model.url); + }); + + this.models.set(model.url, model); } computeDiff(model) { @@ -54,7 +58,11 @@ export default class DirtyDiffController { } reDecorate(model) { - this.decorationsController.decorate(model); + if (this.decorationsController.hasDecorations(model)) { + this.decorationsController.decorate(model); + } else { + this.computeDiff(model); + } } decorate({ data }) { @@ -65,6 +73,7 @@ export default class DirtyDiffController { dispose() { this.disposable.dispose(); + this.models.clear(); this.dirtyDiffWorker.removeEventListener('message', this.decorate); this.dirtyDiffWorker.terminate(); diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 001737d6ee8..2d3ee7d4f48 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -77,8 +77,8 @@ export default class Editor { } } - createModel(file) { - return this.modelManager.addModel(file); + createModel(file, head = null) { + return this.modelManager.addModel(file, head); } attachModel(model) { diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index c6ba679d99c..cecb4d215ba 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; @@ -32,6 +33,22 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { } }; +export const toggleRightPanelCollapsed = ( + { dispatch, state }, + e = undefined, +) => { + if (e) { + $(e.currentTarget) + .tooltip('hide') + .blur(); + } + + dispatch('setPanelCollapsedStatus', { + side: 'right', + collapsed: !state.rightPanelCollapsed, + }); +}; + export const setResizingStatus = ({ commit }, resizing) => { commit(types.SET_RESIZING_STATUS, resizing); }; @@ -104,6 +121,14 @@ export const scrollToTab = () => { }); }; +export const stageAllChanges = ({ state, commit }) => { + state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path)); +}; + +export const unstageAllChanges = ({ state, commit }) => { + state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path)); +}; + export const updateViewer = ({ commit }, viewer) => { commit(types.UPDATE_VIEWER, viewer); }; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 1a17320a1ea..d782e0a84d2 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -24,7 +24,10 @@ export const closeFile = ({ commit, state, dispatch }, file) => { if (nextFileToOpen.pending) { dispatch('updateViewer', 'diff'); - dispatch('openPendingTab', nextFileToOpen); + dispatch('openPendingTab', { + file: nextFileToOpen, + keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged', + }); } else { dispatch('updateDelayViewerUpdated', true); router.push(`/project${nextFileToOpen.url}`); @@ -153,7 +156,7 @@ export const setFileViewMode = ({ state, commit }, { file, viewMode }) => { commit(types.SET_FILE_VIEWMODE, { file, viewMode }); }; -export const discardFileChanges = ({ state, commit }, path) => { +export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => { const file = state.entries[path]; commit(types.DISCARD_FILE_CHANGES, path); @@ -161,17 +164,40 @@ export const discardFileChanges = ({ state, commit }, path) => { if (file.tempFile && file.opened) { commit(types.TOGGLE_FILE_OPEN, path); + } else if (getters.activeFile && file.path === getters.activeFile.path) { + dispatch('updateDelayViewerUpdated', true) + .then(() => { + router.push(`/project${file.url}`); + }) + .catch(e => { + throw e; + }); + } + + eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.content); + eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content); +}; + +export const stageChange = ({ commit, state }, path) => { + const stagedFile = state.stagedFiles.find(f => f.path === path); + + commit(types.STAGE_CHANGE, path); + + if (stagedFile) { + eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content); } +}; - eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw); +export const unstageChange = ({ commit }, path) => { + commit(types.UNSTAGE_CHANGE, path); }; -export const openPendingTab = ({ commit, getters, dispatch, state }, file) => { - if (getters.activeFile && getters.activeFile.path === file.path && state.viewer === 'diff') { +export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => { + if (getters.activeFile && getters.activeFile === file && state.viewer === 'diff') { return false; } - commit(types.ADD_PENDING_TAB, { file }); + commit(types.ADD_PENDING_TAB, { file, keyPrefix }); dispatch('scrollToTab'); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index a77cdbc13c8..8518d2f6f06 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const activeFile = state => state.openFiles.find(file => file.active) || null; export const addedFiles = state => state.changedFiles.filter(f => f.tempFile); @@ -29,9 +31,15 @@ export const currentMergeRequest = state => { }; // eslint-disable-next-line no-confusing-arrow -export const currentIcon = state => +export const collapseButtonIcon = state => state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; -export const hasChanges = state => !!state.changedFiles.length; +export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length; + +// eslint-disable-next-line no-confusing-arrow +export const collapseButtonTooltip = state => + state.rightPanelCollapsed ? __('Expand sidebar') : __('Collapse sidebar'); export const hasMergeRequest = state => !!state.currentMergeRequestId; + +export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path); diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 367c45f7e2d..b26512e213a 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -98,40 +98,25 @@ export const updateFilesAfterCommit = ( { root: true }, ); - rootState.changedFiles.forEach(entry => { - commit( - rootTypes.SET_LAST_COMMIT_DATA, - { - entry, - lastCommit, - }, - { root: true }, - ); - - eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content); + rootState.stagedFiles.forEach(file => { + const changedFile = rootState.changedFiles.find(f => f.path === file.path); commit( - rootTypes.SET_FILE_RAW_DATA, + rootTypes.UPDATE_FILE_AFTER_COMMIT, { - file: entry, - raw: entry.content, + file, + lastCommit, }, { root: true }, ); - commit( - rootTypes.TOGGLE_FILE_CHANGED, - { - file: entry, - changed: false, - }, - { root: true }, - ); + eventHub.$emit(`editor.update.model.content.${file.key}`, { + content: file.content, + changed: !!changedFile, + }); }); - commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true }); - - if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { + if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH && rootGetters.activeFile) { router.push( `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`, ); @@ -184,6 +169,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState }) = { root: true }, ); } + + commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true }); }) .then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH)); }) diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index f7cdd6adb0c..9c3905a0b0d 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -1,12 +1,17 @@ import * as consts from './constants'; -export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; +const BRANCH_SUFFIX_COUNT = 5; + +export const discardDraftButtonDisabled = state => + state.commitMessage === '' || state.submitCommitLoading; export const commitButtonDisabled = (state, getters, rootState) => - getters.discardDraftButtonDisabled || !rootState.changedFiles.length; + getters.discardDraftButtonDisabled || !rootState.stagedFiles.length; export const newBranchName = (state, _, rootState) => - `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`; + `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr( + -BRANCH_SUFFIX_COUNT, + )}`; export const branchName = (state, getters, rootState) => { if ( diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index e3f504e5ab0..f5f95b755c8 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -51,5 +51,10 @@ export const SET_FILE_MERGE_REQUEST_CHANGE = 'SET_FILE_MERGE_REQUEST_CHANGE'; export const UPDATE_VIEWER = 'UPDATE_VIEWER'; export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; +export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES'; +export const STAGE_CHANGE = 'STAGE_CHANGE'; +export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE'; + +export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 5e5eb831662..fbe342f9126 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -49,6 +49,11 @@ export default { lastCommitMsg, }); }, + [types.CLEAR_STAGED_CHANGES](state) { + Object.assign(state, { + stagedFiles: [], + }); + }, [types.SET_ENTRIES](state, entries) { Object.assign(state, { entries, @@ -95,6 +100,22 @@ export default { delayViewerUpdated, }); }, + [types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) { + const changedFile = state.changedFiles.find(f => f.path === file.path); + + Object.assign(state.entries[file.path], { + raw: file.content, + changed: !!changedFile, + staged: false, + lastCommit: Object.assign(state.entries[file.path].lastCommit, { + id: lastCommit.commit.id, + url: lastCommit.commit_path, + message: lastCommit.commit.message, + author: lastCommit.commit.author_name, + updatedAt: lastCommit.commit.authored_date, + }), + }); + }, ...projectMutations, ...mergeRequestMutation, ...fileMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index eeb14b5490c..dd7dcba8ac7 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -57,7 +57,9 @@ export default { }); }, [types.UPDATE_FILE_CONTENT](state, { path, content }) { - const changed = content !== state.entries[path].raw; + const stagedFile = state.stagedFiles.find(f => f.path === path); + const rawContent = stagedFile ? stagedFile.content : state.entries[path].raw; + const changed = content !== rawContent; Object.assign(state.entries[path], { content, @@ -91,8 +93,10 @@ export default { }); }, [types.DISCARD_FILE_CHANGES](state, path) { + const stagedFile = state.stagedFiles.find(f => f.path === path); + Object.assign(state.entries[path], { - content: state.entries[path].raw, + content: stagedFile ? stagedFile.content : state.entries[path].raw, changed: false, }); }, @@ -106,16 +110,67 @@ export default { changedFiles: state.changedFiles.filter(f => f.path !== path), }); }, + [types.STAGE_CHANGE](state, path) { + const stagedFile = state.stagedFiles.find(f => f.path === path); + + Object.assign(state, { + changedFiles: state.changedFiles.filter(f => f.path !== path), + entries: Object.assign(state.entries, { + [path]: Object.assign(state.entries[path], { + staged: true, + changed: false, + }), + }), + }); + + if (stagedFile) { + Object.assign(stagedFile, { + ...state.entries[path], + }); + } else { + Object.assign(state, { + stagedFiles: state.stagedFiles.concat({ + ...state.entries[path], + }), + }); + } + }, + [types.UNSTAGE_CHANGE](state, path) { + const changedFile = state.changedFiles.find(f => f.path === path); + const stagedFile = state.stagedFiles.find(f => f.path === path); + + if (!changedFile && stagedFile) { + Object.assign(state.entries[path], { + ...stagedFile, + key: state.entries[path].key, + active: state.entries[path].active, + opened: state.entries[path].opened, + changed: true, + }); + + Object.assign(state, { + changedFiles: state.changedFiles.concat(state.entries[path]), + }); + } + + Object.assign(state, { + stagedFiles: state.stagedFiles.filter(f => f.path !== path), + entries: Object.assign(state.entries, { + [path]: Object.assign(state.entries[path], { + staged: false, + }), + }), + }); + }, [types.TOGGLE_FILE_CHANGED](state, { file, changed }) { Object.assign(state.entries[file.path], { changed, }); }, [types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) { - const pendingTab = state.openFiles.find(f => f.path === file.path && f.pending); - let openFiles = state.openFiles.map(f => - Object.assign(f, { active: f.path === file.path, opened: false }), - ); + const key = `${keyPrefix}-${file.key}`; + const pendingTab = state.openFiles.find(f => f.key === key && f.pending); + let openFiles = state.openFiles.map(f => Object.assign(f, { active: false, opened: false })); if (!pendingTab) { const openFile = openFiles.find(f => f.path === file.path); @@ -126,10 +181,11 @@ export default { if (f.path === file.path) { return acc.concat({ ...f, + content: file.content, active: true, pending: true, opened: true, - key: `${keyPrefix}-${f.key}`, + key, }); } diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index e5cc8814000..34975ac3144 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -3,6 +3,7 @@ export default () => ({ currentBranchId: '', currentMergeRequestId: '', changedFiles: [], + stagedFiles: [], endpoints: {}, lastCommitMsg: '', lastCommitPath: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 05a019de54f..8a222da14c0 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -15,6 +15,7 @@ export const dataStructure = () => ({ opened: false, active: false, changed: false, + staged: false, lastCommitPath: '', lastCommit: { id: '', @@ -101,7 +102,7 @@ export const setPageTitle = title => { export const createCommitPayload = (branch, newBranch, state, rootState) => ({ branch, commit_message: state.commitMessage, - actions: rootState.changedFiles.map(f => ({ + actions: rootState.stagedFiles.map(f => ({ action: f.tempFile ? 'create' : 'update', file_path: f.path, content: f.content, diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 7470d634b99..f3d722409b0 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -30,10 +30,10 @@ export default class IssuableContext { const $selectbox = $block.find('.selectbox'); if ($selectbox.is(':visible')) { $selectbox.hide(); - $block.find('.value').show(); + $block.find('.value:not(.dont-hide)').show(); } else { $selectbox.show(); - $block.find('.value').hide(); + $block.find('.value:not(.dont-hide)').hide(); } if ($selectbox.is(':visible')) { diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue index 203135c11ab..588a4b879dc 100644 --- a/app/assets/javascripts/jobs/components/header.vue +++ b/app/assets/javascripts/jobs/components/header.vue @@ -1,82 +1,94 @@ <script> - import ciHeader from '../../vue_shared/components/header_ci_component.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import ciHeader from '../../vue_shared/components/header_ci_component.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import callout from '../../vue_shared/components/callout.vue'; - export default { - name: 'JobHeaderSection', - components: { - ciHeader, - loadingIcon, +export default { + name: 'JobHeaderSection', + components: { + ciHeader, + loadingIcon, + callout, + }, + props: { + job: { + type: Object, + required: true, }, - props: { - job: { - type: Object, - required: true, - }, - isLoading: { - type: Boolean, - required: true, - }, + isLoading: { + type: Boolean, + required: true, }, - data() { - return { - actions: this.getActions(), - }; + }, + data() { + return { + actions: this.getActions(), + }; + }, + computed: { + status() { + return this.job && this.job.status; }, - computed: { - status() { - return this.job && this.job.status; - }, - shouldRenderContent() { - return !this.isLoading && Object.keys(this.job).length; - }, - /** - * When job has not started the key will be `false` - * When job started the key will be a string with a date. - */ - jobStarted() { - return !this.job.started === false; - }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.job).length; }, - watch: { - job() { - this.actions = this.getActions(); - }, + shouldRenderReason() { + return !!(this.job.status && this.job.callout_message); }, - methods: { - getActions() { - const actions = []; + /** + * When job has not started the key will be `false` + * When job started the key will be a string with a date. + */ + jobStarted() { + return !this.job.started === false; + }, + }, + watch: { + job() { + this.actions = this.getActions(); + }, + }, + methods: { + getActions() { + const actions = []; - if (this.job.new_issue_path) { - actions.push({ - label: 'New issue', - path: this.job.new_issue_path, - cssClass: 'js-new-issue btn btn-new btn-inverted d-block d-lg-none d-xl-none', - type: 'link', - }); - } - return actions; - }, + if (this.job.new_issue_path) { + actions.push({ + label: 'New issue', + path: this.job.new_issue_path, + cssClass: 'js-new-issue btn btn-new btn-inverted d-block d-lg-none d-xl-none', + type: 'link', + }); + } + return actions; }, - }; + }, +}; </script> <template> - <div class="js-build-header build-header top-area"> - <ci-header - v-if="shouldRenderContent" - :status="status" - item-name="Job" - :item-id="job.id" - :time="job.created_at" - :user="job.user" - :actions="actions" - :has-sidebar-button="true" - :should-render-triggered-label="jobStarted" - /> - <loading-icon - v-if="isLoading" - size="2" - class="prepend-top-default append-bottom-default" + <header> + <div class="js-build-header build-header top-area"> + <ci-header + v-if="shouldRenderContent" + :status="status" + item-name="Job" + :item-id="job.id" + :time="job.created_at" + :user="job.user" + :actions="actions" + :has-sidebar-button="true" + :should-render-triggered-label="jobStarted" + /> + <loading-icon + v-if="isLoading" + size="2" + class="prepend-top-default append-bottom-default" + /> + </div> + + <callout + v-if="shouldRenderReason" + :message="job.callout_message" /> - </div> + </header> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 191286a2c53..025e848603a 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -1,80 +1,119 @@ <script> - import detailRow from './sidebar_detail_row.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - import timeagoMixin from '../../vue_shared/mixins/timeago'; - import { timeIntervalInWords } from '../../lib/utils/datetime_utility'; +import detailRow from './sidebar_detail_row.vue'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import timeagoMixin from '../../vue_shared/mixins/timeago'; +import { timeIntervalInWords } from '../../lib/utils/datetime_utility'; - export default { - name: 'SidebarDetailsBlock', - components: { - detailRow, - loadingIcon, +export default { + name: 'SidebarDetailsBlock', + components: { + detailRow, + loadingIcon, + }, + mixins: [timeagoMixin], + props: { + job: { + type: Object, + required: true, }, - mixins: [ - timeagoMixin, - ], - props: { - job: { - type: Object, - required: true, - }, - isLoading: { - type: Boolean, - required: true, - }, - runnerHelpUrl: { - type: String, - required: false, - default: '', - }, + isLoading: { + type: Boolean, + required: true, }, - computed: { - shouldRenderContent() { - return !this.isLoading && Object.keys(this.job).length > 0; - }, - coverage() { - return `${this.job.coverage}%`; - }, - duration() { - return timeIntervalInWords(this.job.duration); - }, - queued() { - return timeIntervalInWords(this.job.queued); - }, - runnerId() { - return `#${this.job.runner.id}`; - }, - hasTimeout() { - return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; - }, - timeout() { - if (this.job.metadata == null) { - return ''; - } + canUserRetry: { + type: Boolean, + required: false, + default: false, + }, + runnerHelpUrl: { + type: String, + required: false, + default: '', + }, + }, + computed: { + shouldRenderContent() { + return !this.isLoading && Object.keys(this.job).length > 0; + }, + coverage() { + return `${this.job.coverage}%`; + }, + duration() { + return timeIntervalInWords(this.job.duration); + }, + queued() { + return timeIntervalInWords(this.job.queued); + }, + runnerId() { + return `#${this.job.runner.id}`; + }, + retryButtonClass() { + let className = 'js-retry-button pull-right btn btn-retry visible-md-block visible-lg-block'; + className += + this.job.status && this.job.recoverable + ? ' btn-primary' + : ' btn-inverted-secondary'; + return className; + }, + hasTimeout() { + return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; + }, + timeout() { + if (this.job.metadata == null) { + return ''; + } - let t = this.job.metadata.timeout_human_readable; - if (this.job.metadata.timeout_source !== '') { - t += ` (from ${this.job.metadata.timeout_source})`; - } + let t = this.job.metadata.timeout_human_readable; + if (this.job.metadata.timeout_source !== '') { + t += ` (from ${this.job.metadata.timeout_source})`; + } - return t; - }, - renderBlock() { - return this.job.merge_request || - this.job.duration || - this.job.finished_data || - this.job.erased_at || - this.job.queued || - this.job.runner || - this.job.coverage || - this.job.tags.length || - this.job.cancel_path; - }, + return t; }, - }; + renderBlock() { + return ( + this.job.merge_request || + this.job.duration || + this.job.finished_data || + this.job.erased_at || + this.job.queued || + this.job.runner || + this.job.coverage || + this.job.tags.length || + this.job.cancel_path + ); + }, + }, +}; </script> <template> <div> + <div class="block"> + <strong class="inline prepend-top-8"> + {{ job.name }} + </strong> + <a + v-if="canUserRetry" + :class="retryButtonClass" + :href="job.retry_path" + data-method="post" + rel="nofollow" + > + {{ __('Retry') }} + </a> + <button + type="button" + :aria-label="__('Toggle Sidebar')" + class="btn btn-blank gutter-toggle pull-right + visible-xs-block visible-sm-block js-sidebar-build-toggle" + > + <i + aria-hidden="true" + data-hidden="true" + class="fa fa-angle-double-right" + ></i> + </button> + </div> <template v-if="shouldRenderContent"> <div class="block retry-link" @@ -85,16 +124,16 @@ class="js-new-issue btn btn-new btn-inverted" :href="job.new_issue_path" > - New issue + {{ __('New issue') }} </a> <a - v-if="job.retry_path" + v-if="canUserRetry" class="js-retry-job btn btn-inverted-secondary" :href="job.retry_path" data-method="post" rel="nofollow" > - Retry + {{ __('Retry') }} </a> </div> <div :class="{block : renderBlock }"> @@ -103,7 +142,7 @@ v-if="job.merge_request" > <span class="build-light-text"> - Merge Request: + {{ __('Merge Request:') }} </span> <a :href="job.merge_request.path"> !{{ job.merge_request.iid }} @@ -158,7 +197,7 @@ v-if="job.tags.length" > <span class="build-light-text"> - Tags: + {{ __('Tags:') }} </span> <span v-for="(tag, i) in job.tags" @@ -178,7 +217,7 @@ data-method="post" rel="nofollow" > - Cancel + {{ __('Cancel') }} </a> </div> </div> diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js index 656676ead91..f2939ad4dbe 100644 --- a/app/assets/javascripts/jobs/job_details_bundle.js +++ b/app/assets/javascripts/jobs/job_details_bundle.js @@ -35,9 +35,11 @@ export default () => { }); // Sidebar information block + const detailsBlockElement = document.getElementById('js-details-block-vue'); + const detailsBlockDataset = detailsBlockElement.dataset; // eslint-disable-next-line new Vue({ - el: '#js-details-block-vue', + el: detailsBlockElement, components: { detailsBlock, }, @@ -50,6 +52,7 @@ export default () => { return createElement('details-block', { props: { isLoading: this.mediator.state.isLoading, + canUserRetry: !!('canUserRetry' in detailsBlockDataset), job: this.mediator.store.state.job, runnerHelpUrl: dataset.runnerHelpUrl, }, diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 6d49167dfc4..eafdaf4a672 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -83,7 +83,7 @@ export default class LabelsSelect { $dropdown.trigger('loading.gl.dropdown'); axios.put(issueUpdateURL, data) .then(({ data }) => { - var labelCount, template, labelTooltipTitle, labelTitles; + var labelCount, template, labelTooltipTitle, labelTitles, formattedLabels; $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); @@ -115,8 +115,7 @@ export default class LabelsSelect { labelTooltipTitle = labelTitles.join(', '); } else { - labelTooltipTitle = ''; - $sidebarLabelTooltip.tooltip('destroy'); + labelTooltipTitle = __('Labels'); } $sidebarLabelTooltip diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d0a2b27b0e6..7e9a50a885d 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import _ from 'underscore'; +import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; import { timeFor } from './lib/utils/datetime_utility'; import ModalStore from './boards/stores/modal_store'; @@ -25,7 +26,7 @@ export default class MilestoneSelect { } $els.each((i, dropdown) => { - let collapsedSidebarLabelTemplate, milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault; + let milestoneLinkNoneTemplate, milestoneLinkTemplate, selectedMilestone, selectedMilestoneDefault; const $dropdown = $(dropdown); const projectId = $dropdown.data('projectId'); const milestonesUrl = $dropdown.data('milestones'); @@ -52,7 +53,6 @@ export default class MilestoneSelect { if (issueUpdateURL) { milestoneLinkTemplate = _.template('<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>'); milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; - collapsedSidebarLabelTemplate = _.template('<span class="has-tooltip" data-container="body" title="<%- name %><br /><%- remaining %>" data-placement="left" data-html="true"> <%- title %> </span>'); } return $dropdown.glDropdown({ showMenuAbove: showMenuAbove, @@ -214,10 +214,16 @@ export default class MilestoneSelect { data.milestone.remaining = timeFor(data.milestone.due_date); data.milestone.name = data.milestone.title; $value.html(milestoneLinkTemplate(data.milestone)); - return $sidebarCollapsedValue.find('span').html(collapsedSidebarLabelTemplate(data.milestone)); + return $sidebarCollapsedValue + .attr('data-original-title', `${data.milestone.name}<br />${data.milestone.remaining}`) + .find('span') + .text(data.milestone.title); } else { $value.html(milestoneLinkNoneTemplate); - return $sidebarCollapsedValue.find('span').text('No'); + return $sidebarCollapsedValue + .attr('data-original-title', __('Milestone')) + .find('span') + .text(__('None')); } }) .catch(() => { diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 8ce938c958b..cbc2d80ee18 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -19,7 +19,7 @@ function getSystemDate(systemUtcOffsetSeconds) { const date = new Date(); const localUtcOffsetMinutes = 0 - date.getTimezoneOffset(); const systemUtcOffsetMinutes = systemUtcOffsetSeconds / 60; - date.setMinutes((date.getMinutes() - localUtcOffsetMinutes) + systemUtcOffsetMinutes); + date.setMinutes(date.getMinutes() - localUtcOffsetMinutes + systemUtcOffsetMinutes); return date; } @@ -35,18 +35,36 @@ function formatTooltipText({ date, count }) { return `${contribText}<br />${dateDayName} ${dateText}`; } -const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]); +const initColorKey = () => + d3 + .scaleLinear() + .range(['#acd5f2', '#254e77']) + .domain([0, 3]); export default class ActivityCalendar { - constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0) { + constructor(container, timestamps, calendarActivitiesPath, utcOffset = 0, firstDayOfWeek = 0) { this.calendarActivitiesPath = calendarActivitiesPath; this.clickDay = this.clickDay.bind(this); this.currentSelectedDate = ''; this.daySpace = 1; this.daySize = 15; - this.daySizeWithSpace = this.daySize + (this.daySpace * 2); - this.monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + this.daySizeWithSpace = this.daySize + this.daySpace * 2; + this.monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; this.months = []; + this.firstDayOfWeek = firstDayOfWeek; // Loop through the timestamps to create a group of objects // The group of objects will be grouped based on the day of the week they are @@ -70,7 +88,7 @@ export default class ActivityCalendar { // Create a new group array if this is the first day of the week // or if is first object - if ((day === 0 && i !== 0) || i === 0) { + if ((day === this.firstDayOfWeek && i !== 0) || i === 0) { this.timestampsTmp.push([]); group += 1; } @@ -109,21 +127,30 @@ export default class ActivityCalendar { } renderSvg(container, group) { - const width = ((group + 1) * this.daySizeWithSpace) + this.getExtraWidthPadding(group); - return d3.select(container) + const width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group); + return d3 + .select(container) .append('svg') - .attr('width', width) - .attr('height', 167) - .attr('class', 'contrib-calendar'); + .attr('width', width) + .attr('height', 167) + .attr('class', 'contrib-calendar'); + } + + dayYPos(day) { + return this.daySizeWithSpace * ((day + 7 - this.firstDayOfWeek) % 7); } renderDays() { - this.svg.selectAll('g').data(this.timestampsTmp).enter().append('g') + this.svg + .selectAll('g') + .data(this.timestampsTmp) + .enter() + .append('g') .attr('transform', (group, i) => { _.each(group, (stamp, a) => { if (a === 0 && stamp.day === 0) { const month = stamp.date.getMonth(); - const x = (this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace; + const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace; const lastMonth = _.last(this.months); if ( lastMonth == null || @@ -133,86 +160,113 @@ export default class ActivityCalendar { } } }); - return `translate(${(this.daySizeWithSpace * i) + 1 + this.daySizeWithSpace}, 18)`; + return `translate(${this.daySizeWithSpace * i + 1 + this.daySizeWithSpace}, 18)`; }) .selectAll('rect') - .data(stamp => stamp) - .enter() - .append('rect') - .attr('x', '0') - .attr('y', stamp => this.daySizeWithSpace * stamp.day) - .attr('width', this.daySize) - .attr('height', this.daySize) - .attr('fill', stamp => ( - stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed' - )) - .attr('title', stamp => formatTooltipText(stamp)) - .attr('class', 'user-contrib-cell js-tooltip') - .attr('data-container', 'body') - .on('click', this.clickDay); + .data(stamp => stamp) + .enter() + .append('rect') + .attr('x', '0') + .attr('y', stamp => this.dayYPos(stamp.day)) + .attr('width', this.daySize) + .attr('height', this.daySize) + .attr( + 'fill', + stamp => (stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed'), + ) + .attr('title', stamp => formatTooltipText(stamp)) + .attr('class', 'user-contrib-cell js-tooltip') + .attr('data-container', 'body') + .on('click', this.clickDay); } renderDayTitles() { const days = [ { text: 'M', - y: 29 + (this.daySizeWithSpace * 1), - }, { + y: 29 + this.dayYPos(1), + }, + { text: 'W', - y: 29 + (this.daySizeWithSpace * 3), - }, { + y: 29 + this.dayYPos(2), + }, + { text: 'F', - y: 29 + (this.daySizeWithSpace * 5), + y: 29 + this.dayYPos(3), }, ]; - this.svg.append('g') + this.svg + .append('g') .selectAll('text') - .data(days) - .enter() - .append('text') - .attr('text-anchor', 'middle') - .attr('x', 8) - .attr('y', day => day.y) - .text(day => day.text) - .attr('class', 'user-contrib-text'); + .data(days) + .enter() + .append('text') + .attr('text-anchor', 'middle') + .attr('x', 8) + .attr('y', day => day.y) + .text(day => day.text) + .attr('class', 'user-contrib-text'); } renderMonths() { - this.svg.append('g') + this.svg + .append('g') .attr('direction', 'ltr') .selectAll('text') - .data(this.months) - .enter() - .append('text') - .attr('x', date => date.x) - .attr('y', 10) - .attr('class', 'user-contrib-text') - .text(date => this.monthNames[date.month]); + .data(this.months) + .enter() + .append('text') + .attr('x', date => date.x) + .attr('y', 10) + .attr('class', 'user-contrib-text') + .text(date => this.monthNames[date.month]); } renderKey() { - const keyValues = ['no contributions', '1-9 contributions', '10-19 contributions', '20-29 contributions', '30+ contributions']; - const keyColors = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; + const keyValues = [ + 'no contributions', + '1-9 contributions', + '10-19 contributions', + '20-29 contributions', + '30+ contributions', + ]; + const keyColors = [ + '#ededed', + this.colorKey(0), + this.colorKey(1), + this.colorKey(2), + this.colorKey(3), + ]; - this.svg.append('g') - .attr('transform', `translate(18, ${(this.daySizeWithSpace * 8) + 16})`) + this.svg + .append('g') + .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`) .selectAll('rect') - .data(keyColors) - .enter() - .append('rect') - .attr('width', this.daySize) - .attr('height', this.daySize) - .attr('x', (color, i) => this.daySizeWithSpace * i) - .attr('y', 0) - .attr('fill', color => color) - .attr('class', 'js-tooltip') - .attr('title', (color, i) => keyValues[i]) - .attr('data-container', 'body'); + .data(keyColors) + .enter() + .append('rect') + .attr('width', this.daySize) + .attr('height', this.daySize) + .attr('x', (color, i) => this.daySizeWithSpace * i) + .attr('y', 0) + .attr('fill', color => color) + .attr('class', 'js-tooltip') + .attr('title', (color, i) => keyValues[i]) + .attr('data-container', 'body'); } initColor() { - const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; - return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange); + const colorRange = [ + '#ededed', + this.colorKey(0), + this.colorKey(1), + this.colorKey(2), + this.colorKey(3), + ]; + return d3 + .scaleThreshold() + .domain([0, 10, 20, 30]) + .range(colorRange); } clickDay(stamp) { @@ -227,14 +281,15 @@ export default class ActivityCalendar { $('.user-calendar-activities').html(LOADING_HTML); - axios.get(this.calendarActivitiesPath, { - params: { - date, - }, - responseType: 'text', - }) - .then(({ data }) => $('.user-calendar-activities').html(data)) - .catch(() => flash(__('An error occurred while retrieving calendar activity'))); + axios + .get(this.calendarActivitiesPath, { + params: { + date, + }, + responseType: 'text', + }) + .then(({ data }) => $('.user-calendar-activities').html(data)) + .catch(() => flash(__('An error occurred while retrieving calendar activity'))); } else { this.currentSelectedDate = ''; $('.user-calendar-activities').html(''); diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index bb56e21d2f5..2afcf4626b8 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; import flash from './flash'; import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; function Sidebar(currentUser) { this.toggleTodo = this.toggleTodo.bind(this); @@ -41,12 +42,14 @@ Sidebar.prototype.addEventListeners = function() { }; Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { - var $allGutterToggleIcons, $this, $thisIcon; + var $allGutterToggleIcons, $this, isExpanded, tooltipLabel; e.preventDefault(); $this = $(this); - $thisIcon = $this.find('i'); + isExpanded = $this.find('i').hasClass('fa-angle-double-right'); + tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); $allGutterToggleIcons = $('.js-sidebar-toggle i'); - if ($thisIcon.hasClass('fa-angle-double-right')) { + + if (isExpanded) { $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); $('aside.right-sidebar').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); $('.layout-page').removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); @@ -57,6 +60,9 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) { if (gl.lazyLoader) gl.lazyLoader.loadCheck(); } + + $this.attr('data-original-title', tooltipLabel); + if (!triggered) { Cookies.set("collapsed_gutter", $('.right-sidebar').hasClass('right-sidebar-collapsed')); } diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 1e7f46454bf..2d00e8ac7e0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,6 +1,12 @@ <script> +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; + export default { name: 'Assignees', + directives: { + tooltip, + }, props: { rootPath: { type: String, @@ -14,6 +20,11 @@ export default { type: Boolean, required: true, }, + issuableType: { + type: String, + require: true, + default: 'issue', + }, }, data() { return { @@ -62,6 +73,12 @@ export default { names.push(`+ ${this.users.length - maxRender} more`); } + if (!this.users.length) { + const emptyTooltipLabel = this.issuableType === 'issue' ? + __('Assignee(s)') : __('Assignee'); + names.push(emptyTooltipLabel); + } + return names.join(', '); }, sidebarAvatarCounter() { @@ -109,7 +126,8 @@ export default { <div> <div class="sidebar-collapsed-icon sidebar-collapsed-user" - :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }" + :class="{ 'multiple-users': hasMoreThanOneAssignee }" + v-tooltip data-container="body" data-placement="left" :title="collapsedTooltipTitle" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 3c6b9c27814..b04a2eff798 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,9 +1,9 @@ <script> -import Flash from '../../../flash'; +import Flash from '~/flash'; +import eventHub from '~/sidebar/event_hub'; +import Store from '~/sidebar/stores/sidebar_store'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; -import Store from '../../stores/sidebar_store'; -import eventHub from '../../event_hub'; export default { name: 'SidebarAssignees', @@ -25,6 +25,11 @@ export default { required: false, default: false, }, + issuableType: { + type: String, + require: true, + default: 'issue', + }, }, data() { return { @@ -90,6 +95,7 @@ export default { :users="store.assignees" :editable="store.editable" @assign-self="assignSelf" + :issuable-type="issuableType" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index a7b8df68f42..dc30175d4f0 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,15 +1,19 @@ <script> -import Flash from '../../../flash'; +import { __ } from '~/locale'; +import Flash from '~/flash'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; +import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; -import Icon from '../../../vue_shared/components/icon.vue'; -import { __ } from '../../../locale'; -import eventHub from '../../event_hub'; export default { components: { editForm, Icon, }, + directives: { + tooltip, + }, props: { isConfidential: { required: true, @@ -33,6 +37,9 @@ export default { confidentialityIcon() { return this.isConfidential ? 'eye-slash' : 'eye'; }, + tooltipLabel() { + return this.isConfidential ? __('Confidential') : __('Not confidential'); + }, }, created() { eventHub.$on('closeConfidentialityForm', this.toggleForm); @@ -65,6 +72,10 @@ export default { <div class="sidebar-collapsed-icon" @click="toggleForm" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <icon :name="confidentialityIcon" diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index 676eb6d1677..4763570ae48 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -1,15 +1,22 @@ <script> +import { __ } from '~/locale'; import Flash from '~/flash'; +import tooltip from '~/vue_shared/directives/tooltip'; +import issuableMixin from '~/vue_shared/mixins/issuable'; +import Icon from '~/vue_shared/components/icon.vue'; +import eventHub from '~/sidebar/event_hub'; import editForm from './edit_form.vue'; -import issuableMixin from '../../../vue_shared/mixins/issuable'; -import Icon from '../../../vue_shared/components/icon.vue'; -import eventHub from '../../event_hub'; export default { components: { editForm, Icon, }, + + directives: { + tooltip, + }, + mixins: [issuableMixin], props: { @@ -44,6 +51,10 @@ export default { isLockDialogOpen() { return this.mediator.store.isLockDialogOpen; }, + + tooltipLabel() { + return this.isLocked ? __('Locked') : __('Unlocked'); + }, }, created() { @@ -85,6 +96,10 @@ export default { <div class="sidebar-collapsed-icon" @click="toggleForm" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <icon :name="lockIcon" diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 006a6d2905d..6d95153af28 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -1,9 +1,13 @@ <script> - import { __, n__, sprintf } from '../../../locale'; - import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; - import userAvatarImage from '../../../vue_shared/components/user_avatar/user_avatar_image.vue'; + import { __, n__, sprintf } from '~/locale'; + import tooltip from '~/vue_shared/directives/tooltip'; + import loadingIcon from '~/vue_shared/components/loading_icon.vue'; + import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; export default { + directives: { + tooltip, + }, components: { loadingIcon, userAvatarImage, @@ -72,7 +76,13 @@ <template> <div> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + v-tooltip + data-container="body" + data-placement="left" + :title="participantLabel" + > <i class="fa fa-users" aria-hidden="true" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index 3b86f1145d1..9d9ee9dea4d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -1,12 +1,17 @@ <script> - import icon from '../../../vue_shared/components/icon.vue'; - import { abbreviateTime } from '../../../lib/utils/pretty_time'; + import { __, sprintf } from '~/locale'; + import { abbreviateTime } from '~/lib/utils/pretty_time'; + import icon from '~/vue_shared/components/icon.vue'; + import tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'TimeTrackingCollapsedState', components: { icon, }, + directives: { + tooltip, + }, props: { showComparisonState: { type: Boolean, @@ -79,6 +84,21 @@ return ''; }, + timeTrackedTooltipText() { + let title; + if (this.showComparisonState) { + title = __('Time remaining'); + } else if (this.showEstimateOnlyState) { + title = __('Estimated'); + } else if (this.showSpentOnlyState) { + title = __('Time spent'); + } + + return sprintf('%{title}: %{text}', ({ title, text: this.text })); + }, + tooltipText() { + return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText; + }, }, methods: { abbreviateTime(timeStr) { @@ -89,7 +109,13 @@ </script> <template> - <div class="sidebar-collapsed-icon"> + <div + class="sidebar-collapsed-icon" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipText" + > <icon name="timer" /> <div class="time-tracking-collapsed-summary"> <div :class="divClass"> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 9f5d852260e..26eb4cffba3 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -27,6 +27,7 @@ function mountAssigneesComponent(mediator) { mediator, field: el.dataset.field, signedIn: el.hasAttribute('data-signed-in'), + issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', }, }), }); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index b627f71ffb3..cd954f75613 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -5,6 +5,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; +import { __ } from './locale'; import ModalStore from './boards/stores/modal_store'; // TODO: remove eventHub hack after code splitting refactor @@ -182,7 +183,7 @@ function UsersSelect(currentUser, els, options = {}) { return axios.put(issueURL, data) .then(({ data }) => { - var user; + var user, tooltipTitle; $dropdown.trigger('loaded.gl.dropdown'); $loading.fadeOut(); if (data.assignee) { @@ -191,15 +192,17 @@ function UsersSelect(currentUser, els, options = {}) { username: data.assignee.username, avatar: data.assignee.avatar_url }; + tooltipTitle = _.escape(user.name); } else { user = { name: 'Unassigned', username: '', avatar: '' }; + tooltipTitle = __('Assignee'); } $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', _.escape(user.name)).tooltip('_fixTitle'); + $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle'); return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); }); }; diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue new file mode 100644 index 00000000000..ccf802c456c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/callout.vue @@ -0,0 +1,27 @@ +<script> +const calloutVariants = ['danger', 'success', 'info', 'warning']; + +export default { + props: { + category: { + type: String, + required: false, + default: calloutVariants[0], + validator: value => calloutVariants.includes(value), + }, + message: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div + :class="`bs-callout bs-callout-${category}`" + role="alert" + aria-live="assertive" + > + {{ message }} + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 8211d425b1f..de6f8c32e74 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -1,18 +1,29 @@ <script> - export default { - name: 'ToggleSidebar', - props: { - collapsed: { - type: Boolean, - required: true, - }, +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + name: 'ToggleSidebar', + directives: { + tooltip, + }, + props: { + collapsed: { + type: Boolean, + required: true, + }, + }, + computed: { + tooltipLabel() { + return this.collapsed ? __('Expand sidebar') : __('Collapse sidebar'); }, - methods: { - toggle() { - this.$emit('toggle'); - }, + }, + methods: { + toggle() { + this.$emit('toggle'); }, - }; + }, +}; </script> <template> @@ -20,6 +31,10 @@ type="button" class="btn btn-blank gutter-toggle btn-sidebar-action" @click="toggle" + v-tooltip + data-container="body" + data-placement="left" + :title="tooltipLabel" > <i aria-label="toggle collapse" diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 561e24ca8ed..8c716400913 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -16,7 +16,7 @@ .nav-header-btn { padding: 10px $gl-sidebar-padding; color: inherit; - transition-duration: .3s; + transition-duration: 0.3s; position: absolute; top: 0; cursor: pointer; @@ -137,6 +137,12 @@ } } +.issuable-sidebar .labels { + .value.dont-hide ~ .selectbox { + padding-top: $gl-padding-8; + } +} + .pikaday-container { .pika-single { margin-top: 2px; @@ -151,4 +157,3 @@ .sidebar-collapsed-icon .sidebar-collapsed-value { font-size: 12px; } - diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 37223175199..8c44ebc85ef 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -247,6 +247,7 @@ $btn-sm-side-margin: 7px; $btn-xs-side-margin: 5px; $issue-status-expired: $orange-500; $issuable-sidebar-color: $gl-text-color-secondary; +$sidebar-block-hover-color: #ebebeb; $group-path-color: #999; $namespace-kind-color: #aaa; $panel-heading-link-color: #777; @@ -373,6 +374,8 @@ $dropdown-hover-color: $blue-400; $link-active-background: rgba(0, 0, 0, 0.04); $link-hover-background: rgba(0, 0, 0, 0.06); $inactive-badge-background: rgba(0, 0, 0, 0.08); +$sidebar-toggle-height: 60px; +$sidebar-milestone-toggle-bottom-margin: 10px; /* * Buttons diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 0994785b4ca..a0fc478b3e6 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -1,39 +1,56 @@ @keyframes fade-out-status { - 0%, 50% { opacity: 1; } - 100% { opacity: 0; } + 0%, + 50% { + opacity: 1; + } + + 100% { + opacity: 0; + } } @keyframes blinking-dots { 0% { background-color: rgba($white-light, 1); box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 0.2); + 24px 0 0 0 rgba($white-light, 0.2); } 25% { background-color: rgba($white-light, 0.4); box-shadow: 12px 0 0 0 rgba($white-light, 2), - 24px 0 0 0 rgba($white-light, 0.2); + 24px 0 0 0 rgba($white-light, 0.2); } 75% { background-color: rgba($white-light, 0.4); box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 1); + 24px 0 0 0 rgba($white-light, 1); } 100% { background-color: rgba($white-light, 1); box-shadow: 12px 0 0 0 rgba($white-light, 0.2), - 24px 0 0 0 rgba($white-light, 0.2); + 24px 0 0 0 rgba($white-light, 0.2); } } @keyframes blinking-scroll-button { - 0% { opacity: 0.2; } - 25% { opacity: 0.5; } - 50% { opacity: 0.7; } - 100% { opacity: 1; } + 0% { + opacity: 0.2; + } + + 25% { + opacity: 0.5; + } + + 50% { + opacity: 0.7; + } + + 100% { + opacity: 1; + } } .build-page { @@ -125,12 +142,12 @@ .btn-scroll.animate { .first-triangle { animation: blinking-scroll-button 1s ease infinite; - animation-delay: .3s; + animation-delay: 0.3s; } .second-triangle { animation: blinking-scroll-button 1s ease infinite; - animation-delay: .2s; + animation-delay: 0.2s; } .third-triangle { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 9c5d55b6c8b..6fd7ae1491f 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -187,7 +187,12 @@ padding-left: 10px; &:hover { - color: $gray-darkest; + color: $gl-text-color; + } + + &:hover, + &:focus { + text-decoration: none; } } @@ -368,6 +373,14 @@ padding: 15px 0 0; border-bottom: 0; overflow: hidden; + + &:hover { + background-color: $sidebar-block-hover-color; + } + + &.issuable-sidebar-header { + padding-top: 0; + } } .participants { @@ -380,8 +393,17 @@ .gutter-toggle { width: 100%; + height: $sidebar-toggle-height; margin-left: 0; - padding-left: 25px; + padding-left: 0; + border-bottom: 1px solid $border-gray-dark; + } + + a.gutter-toggle { + display: flex; + justify-content: center; + flex-direction: column; + text-align: center; } .sidebar-collapsed-icon { @@ -428,10 +450,10 @@ .btn-clipboard { border: 0; + background: transparent; color: $issuable-sidebar-color; &:hover { - background: transparent; color: $gl-text-color; } } diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index a3dd20e08eb..dba83e56d72 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -53,10 +53,6 @@ } .milestone-sidebar { - .gutter-toggle { - margin-bottom: 10px; - } - .milestone-progress { .title { padding-top: 5px; @@ -102,7 +98,17 @@ margin-right: 0; } + .right-sidebar-expanded & { + .gutter-toggle { + margin-bottom: $sidebar-milestone-toggle-bottom-margin; + } + } + .right-sidebar-collapsed & { + .milestone-progress { + padding-top: 0; + } + .reference { border-top: 1px solid $border-gray-normal; } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index ae6cd3ab3f7..578fae5ce01 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -68,6 +68,10 @@ .ide-file-changed-icon { margin-left: auto; + + > svg { + display: block; + } } .ide-new-btn { @@ -378,7 +382,11 @@ padding: $gl-bar-padding $gl-padding; background: $white-light; display: flex; - justify-content: space-between; + justify-content: flex-end; + + > div + div { + padding-left: $gl-padding; + } svg { vertical-align: middle; @@ -521,9 +529,13 @@ overflow: auto; } -.multi-file-commit-empty-state-container { - align-items: center; - justify-content: center; +.ide-commit-empty-state { + padding: 0 $gl-padding; +} + +.ide-commit-empty-state-container { + margin-top: auto; + margin-bottom: auto; } .multi-file-commit-panel-header { @@ -532,35 +544,22 @@ margin-bottom: 0; border-bottom: 1px solid $white-dark; padding: $gl-btn-padding 0; - - &.is-collapsed { - border-bottom: 1px solid $white-dark; - - svg { - margin-left: auto; - margin-right: auto; - } - - .multi-file-commit-panel-collapse-btn { - margin-right: auto; - margin-left: auto; - border-left: 0; - } - } } .multi-file-commit-panel-header-title { display: flex; flex: 1; - padding: 0 $gl-btn-padding; + padding-left: $grid-size; svg { margin-right: $gl-btn-padding; + color: $theme-gray-700; } } .multi-file-commit-panel-collapse-btn { border-left: 1px solid $white-dark; + margin-left: auto; } .multi-file-commit-list { @@ -574,12 +573,14 @@ display: flex; padding: 0; align-items: center; + border-radius: $border-radius-default; .multi-file-discard-btn { display: none; + margin-top: -2px; margin-left: auto; + margin-right: $grid-size; color: $gl-link-color; - padding: 0 2px; &:focus, &:hover { @@ -591,26 +592,31 @@ background: $white-normal; .multi-file-discard-btn { - display: block; + display: flex; } } } -.multi-file-addition { +.multi-file-additions, +.multi-file-additions-solid { fill: $green-500; } -.multi-file-modified { +.multi-file-modified, +.multi-file-modified-solid { fill: $orange-500; } .multi-file-commit-list-collapsed { display: flex; flex-direction: column; + padding: $gl-padding 0; - > svg { + svg { + display: block; margin-left: auto; margin-right: auto; + color: $theme-gray-700; } .file-status-icon { @@ -622,7 +628,7 @@ .multi-file-commit-list-path { padding: $grid-size / 2; - padding-left: $gl-padding; + padding-left: $grid-size; background: none; border: 0; text-align: left; @@ -807,6 +813,41 @@ } } +.ide-commit-list-container { + display: flex; + flex-direction: column; + width: 100%; + padding: 0 16px; + + &:not(.is-collapsed) { + flex: 1; + min-height: 140px; + } + + &.is-collapsed { + .multi-file-commit-panel-header { + margin-left: -$gl-padding; + margin-right: -$gl-padding; + + svg { + margin-left: auto; + margin-right: auto; + } + + .multi-file-commit-panel-collapse-btn { + margin-right: auto; + margin-left: auto; + border-left: 0; + } + } + } +} + +.ide-staged-action-btn { + margin-left: auto; + color: $gl-link-color; +} + .ide-commit-radios { label { font-weight: normal; |