diff options
author | Clement Ho <ClemMakesApps@gmail.com> | 2017-12-21 09:42:28 -0600 |
---|---|---|
committer | Clement Ho <ClemMakesApps@gmail.com> | 2017-12-21 09:42:28 -0600 |
commit | c46f7b2a2620a5b90003b40727bb3c1705dd1a0f (patch) | |
tree | 5808b9617af781c8cfb614d11540bdefc872851e /app | |
parent | 0e50e9d9d48752a58b640064075f7786f86e7433 (diff) | |
parent | 450317298ce177722b721353a0161d4347536120 (diff) | |
download | gitlab-ce-c46f7b2a2620a5b90003b40727bb3c1705dd1a0f.tar.gz |
Merge branch 'master' into fix-webpack-chunk-naming
Diffstat (limited to 'app')
159 files changed, 2668 insertions, 1453 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index d963101028a..21d8c790e90 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import axios from './lib/utils/axios_utils'; const Api = { groupsPath: '/api/:version/groups.json', @@ -6,6 +7,7 @@ const Api = { namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', projectsPath: '/api/:version/projects.json', + projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', groupLabelsPath: '/groups/:namespace_path/labels', licensePath: '/api/:version/templates/licenses/:key', @@ -76,6 +78,14 @@ const Api = { .done(projects => callback(projects)); }, + // Return single project + project(projectPath) { + const url = Api.buildUrl(Api.projectPath) + .replace(':id', encodeURIComponent(projectPath)); + + return axios.get(url); + }, + newLabel(namespacePath, projectPath, data, callback) { let url; @@ -115,7 +125,7 @@ const Api = { commitMultiple(id, data) { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions const url = Api.buildUrl(Api.commitPath) - .replace(':id', id); + .replace(':id', encodeURIComponent(id)); return this.wrapAjaxCall({ url, type: 'POST', @@ -127,7 +137,7 @@ const Api = { branchSingle(id, branch) { const url = Api.buildUrl(Api.branchSinglePath) - .replace(':id', id) + .replace(':id', encodeURIComponent(id)) .replace(':branch', branch); return this.wrapAjaxCall({ diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 5662802525e..b6a0ece7907 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -176,6 +176,7 @@ export default class ImageFile { left: dragTrackWidth }); + $frameAdded.css('opacity', 1); framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); _this.initDraggable($dragger, framePadding, function(e, left) { diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 13fa553047a..118437b82a3 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -73,7 +73,6 @@ import initLegacyFilters from './init_legacy_filters'; import initIssuableSidebar from './init_issuable_sidebar'; import initProjectVisibilitySelector from './project_visibility'; import GpgBadges from './gpg_badges'; -import UserFeatureHelper from './helpers/user_feature_helper'; import initChangesDropdown from './init_changes_dropdown'; import NewGroupChild from './groups/new_group_child'; import AbuseReports from './abuse_reports'; @@ -449,9 +448,6 @@ import Activities from './activities'; break; case 'projects:tree:show': shortcut_handler = new ShortcutsNavigation(); - - if (UserFeatureHelper.isNewRepoEnabled()) break; - new TreeView(); new BlobViewer(); new NewCommitForm($('.js-create-dir-form')); @@ -470,7 +466,6 @@ import Activities from './activities'; shortcut_handler = true; break; case 'projects:blob:show': - if (UserFeatureHelper.isNewRepoEnabled()) break; new BlobViewer(); initBlob(); break; diff --git a/app/assets/javascripts/docs/docs_bundle.js b/app/assets/javascripts/docs/docs_bundle.js new file mode 100644 index 00000000000..a32bd6d0fc7 --- /dev/null +++ b/app/assets/javascripts/docs/docs_bundle.js @@ -0,0 +1,13 @@ +import Mousetrap from 'mousetrap'; + +function addMousetrapClick(el, key) { + el.addEventListener('click', () => Mousetrap.trigger(key)); +} + +function domContentLoaded() { + addMousetrapClick(document.querySelector('.js-trigger-shortcut'), '?'); + addMousetrapClick(document.querySelector('.js-trigger-search-bar'), 's'); +} + +document.addEventListener('DOMContentLoaded', domContentLoaded); + diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 6110d961609..abb04d77f8f 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -161,13 +161,16 @@ export default () => { const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')]; - sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => { - clearTimeout(timeoutId); - - timeoutId = setTimeout(() => { - if (currentOpenMenu) hideMenu(currentOpenMenu); - }, getHideSubItemsInterval()); - }); + const topItems = sidebar.querySelector('.sidebar-top-level-items'); + if (topItems) { + sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => { + clearTimeout(timeoutId); + + timeoutId = setTimeout(() => { + if (currentOpenMenu) hideMenu(currentOpenMenu); + }, getHideSubItemsInterval()); + }); + } headerHeight = document.querySelector('.nav-sidebar').offsetTop; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index cf4a70e321e..64f258aed64 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -300,7 +300,7 @@ GitLabDropdown = (function() { return function(data) { _this.fullData = data; _this.parseData(_this.fullData); - _this.focusTextInput(true); + _this.focusTextInput(); if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') { return _this.filter.input.trigger('input'); } @@ -790,24 +790,16 @@ GitLabDropdown = (function() { return [selectedObject, isMarking]; }; - GitLabDropdown.prototype.focusTextInput = function(triggerFocus = false) { + GitLabDropdown.prototype.focusTextInput = function() { if (this.options.filterable) { - this.dropdown.one('transitionend', () => { - const initialScrollTop = $(window).scrollTop(); + const initialScrollTop = $(window).scrollTop(); - if (this.dropdown.is('.open')) { - this.filterInput.focus(); - } - - if ($(window).scrollTop() < initialScrollTop) { - $(window).scrollTop(initialScrollTop); - } - }); + if (this.dropdown.is('.open')) { + this.filterInput.focus(); + } - if (triggerFocus) { - // This triggers after a ajax request - // in case of slow requests, the dropdown transition could already be finished - this.dropdown.trigger('transitionend'); + if ($(window).scrollTop() < initialScrollTop) { + $(window).scrollTop(initialScrollTop); } } }; diff --git a/app/assets/javascripts/graphs/stat_graph_contributors.js b/app/assets/javascripts/graphs/stat_graph_contributors.js index 743c049e9fb..151a4ce012c 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors.js @@ -84,9 +84,12 @@ export default (function() { return _.each(author_commits, (function(_this) { return function(d) { _this.redraw_author_commit_info(d); - $(_this.authors[d.author_name].list_item).appendTo("ol"); - _this.authors[d.author_name].set_data(d.dates); - return _this.authors[d.author_name].redraw(); + if (_this.authors[d.author_name] != null) { + $(_this.authors[d.author_name].list_item).appendTo("ol"); + _this.authors[d.author_name].set_data(d.dates); + return _this.authors[d.author_name].redraw(); + } + return ''; }; })(this)); }; @@ -108,10 +111,14 @@ export default (function() { }; ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) { - var author_commit_info, author_list_item; - author_list_item = $(this.authors[author.author_name].list_item); - author_commit_info = this.format_author_commit_info(author); - return author_list_item.find("span").html(author_commit_info); + var author_commit_info, author_list_item, $author; + $author = this.authors[author.author_name]; + if ($author != null) { + author_list_item = $(this.authors[author.author_name].list_item); + author_commit_info = this.format_author_commit_info(author); + return author_list_item.find("span").html(author_commit_info); + } + return ''; }; return ContributorsStatGraph; diff --git a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js index 187f3c008e8..9a4012232a0 100644 --- a/app/assets/javascripts/graphs/stat_graph_contributors_graph.js +++ b/app/assets/javascripts/graphs/stat_graph_contributors_graph.js @@ -1,8 +1,16 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, max-len, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, comma-dangle, no-return-assign, prefer-arrow-callback, quotes, prefer-template, newline-per-chained-call, no-else-return, no-shadow */ import _ from 'underscore'; -import d3 from 'd3'; +import { extent, max } from 'd3-array'; +import { select, event as d3Event } from 'd3-selection'; +import { scaleTime, scaleLinear } from 'd3-scale'; +import { axisLeft, axisBottom } from 'd3-axis'; +import { area } from 'd3-shape'; +import { brushX } from 'd3-brush'; +import { timeParse } from 'd3-time-format'; import { dateTickFormat } from '../lib/utils/tick_formats'; +const d3 = { extent, max, select, scaleTime, scaleLinear, axisLeft, axisBottom, area, brushX, timeParse }; + const extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; const hasProp = {}.hasOwnProperty; @@ -71,8 +79,8 @@ export const ContributorsGraph = (function() { }; ContributorsGraph.prototype.create_scale = function(width, height) { - this.x = d3.time.scale().range([0, width]).clamp(true); - return this.y = d3.scale.linear().range([height, 0]).nice(); + this.x = d3.scaleTime().range([0, width]).clamp(true); + return this.y = d3.scaleLinear().range([height, 0]).nice(); }; ContributorsGraph.prototype.draw_x_axis = function() { @@ -124,7 +132,7 @@ export const ContributorsMasterGraph = (function(superClass) { ContributorsMasterGraph.prototype.parse_dates = function(data) { var parseDate; - parseDate = d3.time.format("%Y-%m-%d").parse; + parseDate = d3.timeParse("%Y-%m-%d"); return data.forEach(function(d) { return d.date = parseDate(d.date); }); @@ -135,11 +143,10 @@ export const ContributorsMasterGraph = (function(superClass) { }; ContributorsMasterGraph.prototype.create_axes = function() { - this.x_axis = d3.svg.axis() + this.x_axis = d3.axisBottom() .scale(this.x) - .orient('bottom') .tickFormat(dateTickFormat); - return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5); + return this.y_axis = d3.axisLeft().scale(this.y).ticks(5); }; ContributorsMasterGraph.prototype.create_svg = function() { @@ -147,16 +154,16 @@ export const ContributorsMasterGraph = (function(superClass) { }; ContributorsMasterGraph.prototype.create_area = function(x, y) { - return this.area = d3.svg.area().x(function(d) { + return this.area = d3.area().x(function(d) { return x(d.date); }).y0(this.height).y1(function(d) { d.commits = d.commits || d.additions || d.deletions; return y(d.commits); - }).interpolate("basis"); + }); }; ContributorsMasterGraph.prototype.create_brush = function() { - return this.brush = d3.svg.brush().x(this.x).on("brushend", this.update_content); + return this.brush = d3.brushX(this.x).extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]).on("end", this.update_content); }; ContributorsMasterGraph.prototype.draw_path = function(data) { @@ -168,7 +175,12 @@ export const ContributorsMasterGraph = (function(superClass) { }; ContributorsMasterGraph.prototype.update_content = function() { - ContributorsGraph.set_x_domain(this.brush.empty() ? this.x_max_domain : this.brush.extent()); + // d3Event.selection replaces the function brush.empty() calls + if (d3Event.selection != null) { + ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert)); + } else { + ContributorsGraph.set_x_domain(this.x_max_domain); + } return $("#brush_change").trigger('change'); }; @@ -226,18 +238,17 @@ export const ContributorsAuthorGraph = (function(superClass) { }; ContributorsAuthorGraph.prototype.create_axes = function() { - this.x_axis = d3.svg.axis() + this.x_axis = d3.axisBottom() .scale(this.x) - .orient('bottom') .ticks(8) .tickFormat(dateTickFormat); - return this.y_axis = d3.svg.axis().scale(this.y).orient("left").ticks(5); + return this.y_axis = d3.axisLeft().scale(this.y).ticks(5); }; ContributorsAuthorGraph.prototype.create_area = function(x, y) { - return this.area = d3.svg.area().x(function(d) { + return this.area = d3.area().x(function(d) { var parseDate; - parseDate = d3.time.format("%Y-%m-%d").parse; + parseDate = d3.timeParse("%Y-%m-%d"); return x(parseDate(d)); }).y0(this.height).y1((function(_this) { return function(d) { @@ -247,11 +258,12 @@ export const ContributorsAuthorGraph = (function(superClass) { return y(0); } }; - })(this)).interpolate("basis"); + })(this)); }; ContributorsAuthorGraph.prototype.create_svg = function() { - this.list_item = d3.selectAll(".person")[0].pop(); + var persons = document.querySelectorAll('.person'); + this.list_item = persons[persons.length - 1]; return this.svg = d3.select(this.list_item).append("svg").attr("width", this.width + this.MARGIN.left + this.MARGIN.right).attr("height", this.height + this.MARGIN.top + this.MARGIN.bottom).attr("class", "spark").append("g").attr("transform", "translate(" + this.MARGIN.left + "," + this.MARGIN.top + ")"); }; diff --git a/app/assets/javascripts/helpers/user_feature_helper.js b/app/assets/javascripts/helpers/user_feature_helper.js deleted file mode 100644 index 638118a5204..00000000000 --- a/app/assets/javascripts/helpers/user_feature_helper.js +++ /dev/null @@ -1,7 +0,0 @@ -import Cookies from 'js-cookie'; - -export default { - isNewRepoEnabled() { - return Cookies.get('new_repo') === 'true'; - }, -}; 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..704dff981df --- /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', + ]), + }, + methods: { + toggleCollapsed() { + this.$emit('toggleCollapsed'); + }, + }, + + }; +</script> + +<template> + <div class="multi-file-commit-list"> + <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> + <div + v-else + class="help-block prepend-top-0" + > + No changes + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue index 6a0262f271b..6a0262f271b 100644 --- a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 742f746e02f..742f746e02f 100644 --- a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue new file mode 100644 index 00000000000..7f29a355eca --- /dev/null +++ b/app/assets/javascripts/ide/components/ide.vue @@ -0,0 +1,73 @@ +<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 repoPreview from './repo_preview.vue'; +import repoEditor from './repo_editor.vue'; + +export default { + computed: { + ...mapState([ + 'currentBlobView', + 'selectedFile', + ]), + ...mapGetters([ + 'changedFiles', + 'activeFile', + ]), + }, + components: { + ideSidebar, + ideContextbar, + repoTabs, + repoFileButtons, + ideStatusBar, + repoEditor, + repoPreview, + }, + 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/> + <component + class="multi-file-edit-pane-content" + :is="currentBlobView" + /> + <repo-file-buttons/> + <ide-status-bar + :file="selectedFile"/> + </template> + <template + v-else> + <div class="ide-empty-state"> + <h2 class="clgray">Welcome to the GitLab IDE</h2> + </div> + </template> + </div> + <ide-contextbar/> + </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..5a08718e386 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -0,0 +1,75 @@ +<script> +import { mapGetters, mapState, mapActions } from 'vuex'; +import repoCommitSection from './repo_commit_section.vue'; +import icon from '../../vue_shared/components/icon.vue'; + +export default { + components: { + repoCommitSection, + icon, + }, + computed: { + ...mapState([ + 'rightPanelCollapsed', + ]), + ...mapGetters([ + 'changedFiles', + ]), + currentIcon() { + return this.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; + }, + }, + methods: { + ...mapActions([ + 'setPanelCollapsedStatus', + ]), + toggleCollapsed() { + this.setPanelCollapsedStatus({ + side: 'right', + collapsed: !this.rightPanelCollapsed, + }); + }, + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel" + :class="{ + 'is-collapsed': rightPanelCollapsed, + }" + > + <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"> + <icon + name="list-bulleted" + :size="18" + /> + Staged + </div> + <button + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + @click="toggleCollapsed" + > + <icon + :name="currentIcon" + :size="18" + /> + </button> + </header> + <repo-commit-section + class=""/> + </div> + </div> +</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..bd3a521ff43 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue @@ -0,0 +1,47 @@ +<script> +import repoTree from './ide_repo_tree.vue'; +import icon from '../../vue_shared/components/icon.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"> + <icon + name="branch" + :size="12"> + </icon> + {{ branch.name }} + </div> + <div class="branch-header-btns"> + <new-dropdown + :project-id="projectId" + :branch="branch.name" + path=""/> + </div> + </div> + <div> + <repo-tree + :treeId="branch.treeId"/> + </div> + </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..61daba6d176 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_tree.vue @@ -0,0 +1,47 @@ +<script> +import branchesTree from './ide_project_branches_tree.vue'; +import projectAvatarImage from '../../vue_shared/components/project_avatar/image.vue'; + +export default { + components: { + branchesTree, + 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> + <div class="multi-file-commit-panel-inner-scroll"> + <branches-tree + v-for="(branch, index) 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..b6b089e6b25 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue @@ -0,0 +1,66 @@ +<script> +import { mapState } from 'vuex'; +import RepoPreviousDirectory from './repo_prev_directory.vue'; +import RepoFile from './repo_file.vue'; +import RepoLoadingFile from './repo_loading_file.vue'; +import { treeList } from '../stores/utils'; + +export default { + components: { + 'repo-previous-directory': RepoPreviousDirectory, + 'repo-file': RepoFile, + 'repo-loading-file': RepoLoadingFile, + }, + props: { + treeId: { + type: String, + required: true, + }, + }, + computed: { + ...mapState([ + 'loading', + 'isRoot', + ]), + ...mapState({ + projectName(state) { + return state.project.name; + }, + }), + fetchedList() { + return treeList(this.$store.state, this.treeId); + }, + hasPreviousDirectory() { + return !this.isRoot && this.fetchedList.length; + }, + showLoading() { + return this.loading; + }, + }, +}; +</script> + +<template> +<div> + <div class="ide-file-list"> + <table class="table"> + <tbody + v-if="treeId"> + <repo-previous-directory + v-if="hasPreviousDirectory" + /> + <repo-loading-file + v-if="showLoading" + v-for="n in 5" + :key="n" + /> + <repo-file + v-for="file in fetchedList" + :key="file.key" + :file="file" + /> + </tbody> + </table> + </div> +</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..535398d98c2 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -0,0 +1,62 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import projectTree from './ide_project_tree.vue'; +import icon from '../../vue_shared/components/icon.vue'; + +export default { + components: { + projectTree, + icon, + }, + computed: { + ...mapState([ + 'projects', + 'leftPanelCollapsed', + ]), + currentIcon() { + return this.leftPanelCollapsed ? 'angle-double-right' : 'angle-double-left'; + }, + }, + methods: { + ...mapActions([ + 'setPanelCollapsedStatus', + ]), + toggleCollapsed() { + this.setPanelCollapsedStatus({ + side: 'left', + collapsed: !this.leftPanelCollapsed, + }); + }, + }, +}; +</script> + +<template> + <div + class="multi-file-commit-panel" + :class="{ + 'is-collapsed': leftPanelCollapsed, + }" + > + <div class="multi-file-commit-panel-inner"> + <project-tree + v-for="(project, index) in projects" + :key="project.id" + :project="project"/> + </div> + <button + type="button" + class="btn btn-transparent left-collapse-btn" + @click="toggleCollapsed" + > + <icon + :name="currentIcon" + :size="18" + /> + <span + v-if="!leftPanelCollapsed" + class="collapse-text" + >Collapse sidebar</span> + </button> + </div> +</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..a24abadd936 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -0,0 +1,71 @@ +<script> +import { mapState } from 'vuex'; +import icon from '../../vue_shared/components/icon.vue'; +import tooltip from '../../vue_shared/directives/tooltip'; +import timeAgoMixin from '../../vue_shared/mixins/timeago'; + +export default { + props: { + file: { + type: Object, + required: true, + }, + }, + components: { + icon, + }, + directives: { + tooltip, + }, + mixins: [ + timeAgoMixin, + ], + computed: { + ...mapState([ + 'selectedFile', + ]), + }, +}; +</script> + +<template> + <div + class="ide-status-bar"> + <div> + <icon + name="branch" + :size="12"> + </icon> + {{ selectedFile.branchId }} + </div> + <div> + <div + v-if="selectedFile.lastCommit && selectedFile.lastCommit.id"> + Last commit: + <a + v-tooltip + :title="selectedFile.lastCommit.message" + :href="selectedFile.lastCommit.url"> + {{ timeFormated(selectedFile.lastCommit.updatedAt) }} by + {{ selectedFile.lastCommit.author }} + </a> + </div> + </div> + <div + class="text-right"> + {{ selectedFile.name }} + </div> + <div + class="text-right"> + {{ selectedFile.eol }} + </div> + <div + class="text-right"> + {{ file.editorRow }}:{{ file.editorColumn }} + </div> + <div + class="text-right"> + {{ selectedFile.fileLanguage }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/new_branch_form.vue b/app/assets/javascripts/ide/components/new_branch_form.vue index ba7090e4a9d..2119d373d31 100644 --- a/app/assets/javascripts/repo/components/new_branch_form.vue +++ b/app/assets/javascripts/ide/components/new_branch_form.vue @@ -44,7 +44,7 @@ this.branchName = ''; if (this.dropdownText) { - this.dropdownText.textContent = this.currentBranch; + this.dropdownText.textContent = this.currentBranchId; } this.toggleDropdown(); 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..6e67e99a70f --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -0,0 +1,101 @@ +<script> + import newModal from './modal.vue'; + import upload from './upload.vue'; + import icon from '../../../vue_shared/components/icon.vue'; + + export default { + props: { + branch: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + parent: { + type: Object, + default: null, + }, + }, + components: { + icon, + newModal, + upload, + }, + data() { + return { + openModal: false, + modalType: '', + }; + }, + methods: { + createNewItem(type) { + this.modalType = type; + this.toggleModalOpen(); + }, + toggleModalOpen() { + this.openModal = !this.openModal; + }, + }, + }; +</script> + +<template> + <div class="repo-new-btn pull-right"> + <div class="dropdown"> + <button + type="button" + class="btn btn-sm btn-default dropdown-toggle add-to-tree" + data-toggle="dropdown" + aria-label="Create new file or directory" + > + <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.prevent="createNewItem('blob')" + > + {{ __('New file') }} + </a> + </li> + <li> + <upload + :branch-id="branch" + :path="path" + :parent="parent" + /> + </li> + <li> + <a + href="#" + role="button" + @click.prevent="createNewItem('tree')" + > + {{ __('New directory') }} + </a> + </li> + </ul> + </div> + <new-modal + v-if="openModal" + :type="modalType" + :branch-id="branch" + :path="path" + :parent="parent" + @toggle="toggleModalOpen" + /> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index c191af7dec3..a0650d37690 100644 --- a/app/assets/javascripts/repo/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,10 +1,18 @@ <script> - import { mapActions } from 'vuex'; + import { mapActions, mapState } from 'vuex'; import { __ } from '../../../locale'; import modal from '../../../vue_shared/components/modal.vue'; export default { props: { + branchId: { + type: String, + required: true, + }, + parent: { + type: Object, + default: null, + }, type: { type: String, required: true, @@ -28,6 +36,9 @@ ]), createEntryInStore() { this.createTempEntry({ + projectId: this.currentProjectId, + branchId: this.branchId, + parent: this.parent, name: this.entryName.replace(new RegExp(`^${this.path}/`), ''), type: this.type, }); @@ -39,6 +50,9 @@ }, }, computed: { + ...mapState([ + 'currentProjectId', + ]), modalTitle() { if (this.type === 'tree') { return __('Create new directory'); diff --git a/app/assets/javascripts/repo/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 14ad32f4ae0..2a2f2a241fc 100644 --- a/app/assets/javascripts/repo/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -1,12 +1,22 @@ <script> - import { mapActions } from 'vuex'; + import { mapActions, mapState } from 'vuex'; export default { props: { - path: { + branchId: { type: String, required: true, }, + parent: { + type: Object, + default: null, + }, + }, + computed: { + ...mapState([ + 'trees', + 'currentProjectId', + ]), }, methods: { ...mapActions([ @@ -22,6 +32,9 @@ this.createTempEntry({ name, + projectId: this.currentProjectId, + branchId: this.branchId, + parent: this.parent, type: 'blob', content: result, base64: !isText, @@ -42,6 +55,9 @@ openFile() { Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file)); }, + startFileUpload() { + this.$refs.fileUpload.click(); + }, }, mounted() { this.$refs.fileUpload.addEventListener('change', this.openFile); @@ -53,16 +69,19 @@ </script> <template> - <label - role="button" - class="menu-item" - > - {{ __('Upload file') }} + <div> + <a + href="#" + role="button" + @click.prevent="startFileUpload" + > + {{ __('Upload file') }} + </a> <input id="file-upload" type="file" class="hidden" ref="fileUpload" /> - </label> + </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 4e0178072cb..470db2c9650 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -20,12 +20,13 @@ export default { submitCommitsLoading: false, startNewMR: false, commitMessage: '', - collapsed: true, }; }, computed: { ...mapState([ - 'currentBranch', + 'currentProjectId', + 'currentBranchId', + 'rightPanelCollapsed', ]), ...mapGetters([ 'changedFiles', @@ -42,12 +43,13 @@ export default { 'checkCommitStatus', 'commitChanges', 'getTreeData', + 'setPanelCollapsedStatus', ]), makeCommit(newBranch = false) { const createNewBranch = newBranch || this.startNewMR; const payload = { - branch: createNewBranch ? `${this.currentBranch}-${new Date().getTime().toString()}` : this.currentBranch, + branch: createNewBranch ? `${this.currentBranchId}-${new Date().getTime().toString()}` : this.currentBranchId, commit_message: this.commitMessage, actions: this.changedFiles.map(f => ({ action: f.tempFile ? 'create' : 'update', @@ -55,7 +57,7 @@ export default { content: f.content, encoding: f.base64 ? 'base64' : 'text', })), - start_branch: createNewBranch ? this.currentBranch : undefined, + start_branch: createNewBranch ? this.currentBranchId : undefined, }; this.showNewBranchModal = false; @@ -64,7 +66,12 @@ export default { this.commitChanges({ payload, newMr: this.startNewMR }) .then(() => { this.submitCommitsLoading = false; - this.getTreeData(); + this.$store.dispatch('getTreeData', { + projectId: this.currentProjectId, + branch: this.currentBranchId, + endpoint: `/tree/${this.currentBranchId}`, + force: true, + }); }) .catch(() => { this.submitCommitsLoading = false; @@ -86,19 +93,17 @@ export default { }); }, toggleCollapsed() { - this.collapsed = !this.collapsed; + this.setPanelCollapsedStatus({ + side: 'right', + collapsed: !this.rightPanelCollapsed, + }); }, }, }; </script> <template> -<div - class="multi-file-commit-panel" - :class="{ - 'is-collapsed': collapsed, - }" -> +<div class="multi-file-commit-panel-section"> <modal v-if="showNewBranchModal" :primary-button-label="__('Create new branch')" @@ -108,28 +113,16 @@ export default { @toggle="showNewBranchModal = false" @submit="makeCommit(true)" /> - <button - v-if="collapsed" - type="button" - class="btn btn-transparent multi-file-commit-panel-collapse-btn is-collapsed prepend-top-10 append-bottom-10" - @click="toggleCollapsed" - > - <i - aria-hidden="true" - class="fa fa-angle-double-left" - > - </i> - </button> <commit-files-list title="Staged" :file-list="changedFiles" - :collapsed="collapsed" + :collapsed="rightPanelCollapsed" @toggleCollapsed="toggleCollapsed" /> <form class="form-horizontal multi-file-commit-form" @submit.prevent="tryCommit" - v-if="!collapsed" + v-if="!rightPanelCollapsed" > <div class="multi-file-commit-fieldset"> <textarea diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/ide/components/repo_edit_button.vue index 37bd9003e96..37bd9003e96 100644 --- a/app/assets/javascripts/repo/components/repo_edit_button.vue +++ b/app/assets/javascripts/ide/components/repo_edit_button.vue diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index f37cbd1e961..221be4b9074 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,6 +1,6 @@ <script> /* global monaco */ -import { mapGetters, mapActions } from 'vuex'; +import { mapState, mapGetters, mapActions } from 'vuex'; import flash from '../../flash'; import monacoLoader from '../monaco_loader'; import Editor from '../lib/editor'; @@ -24,6 +24,9 @@ export default { ...mapActions([ 'getRawFileData', 'changeFileContent', + 'setFileLanguage', + 'setEditorPosition', + 'setFileEOL', ]), initMonaco() { if (this.shouldHideEditor) return; @@ -43,12 +46,36 @@ export default { const model = this.editor.createModel(this.activeFile); this.editor.attachModel(model); + model.onChange((m) => { this.changeFileContent({ file: this.activeFile, content: m.getValue(), }); }); + + // Handle Cursor Position + this.editor.onPositionChange((instance, e) => { + this.setEditorPosition({ + editorRow: e.position.lineNumber, + editorColumn: e.position.column, + }); + }); + + this.editor.setPosition({ + lineNumber: this.activeFile.editorRow, + column: this.activeFile.editorColumn, + }); + + // Handle File Language + this.setFileLanguage({ + fileLanguage: model.language, + }); + + // Get File eol + this.setFileEOL({ + eol: model.eol, + }); }, }, watch: { @@ -57,12 +84,22 @@ export default { this.initMonaco(); } }, + leftPanelCollapsed() { + this.editor.updateDimensions(); + }, + rightPanelCollapsed() { + this.editor.updateDimensions(); + }, }, computed: { ...mapGetters([ 'activeFile', 'activeFileExtension', ]), + ...mapState([ + 'leftPanelCollapsed', + 'rightPanelCollapsed', + ]), shouldHideEditor() { return this.activeFile.binary && !this.activeFile.raw; }, @@ -76,13 +113,14 @@ export default { class="blob-viewer-container blob-editor-container" > <div - v-show="shouldHideEditor" + v-if="shouldHideEditor" v-html="activeFile.html" > </div> <div v-show="!shouldHideEditor" ref="editor" + class="multi-file-editor-holder" > </div> </div> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue index 75787ad6103..09ca11531b1 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -1,7 +1,8 @@ <script> - import { mapActions, mapGetters } from 'vuex'; + import { mapState } from 'vuex'; import timeAgoMixin from '../../vue_shared/mixins/timeago'; import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; + import newDropdown from './new_dropdown/index.vue'; export default { mixins: [ @@ -9,20 +10,22 @@ ], components: { skeletonLoadingContainer, + newDropdown, }, props: { file: { type: Object, required: true, }, + showExtraColumns: { + type: Boolean, + default: false, + }, }, computed: { - ...mapGetters([ - 'isCollapsed', + ...mapState([ + 'leftPanelCollapsed', ]), - isSubmodule() { - return this.file.type === 'submodule'; - }, fileIcon() { return { 'fa-spinner fa-spin': this.file.loading, @@ -30,6 +33,12 @@ 'fa-folder-open': !this.file.loading && this.file.opened, }; }, + isSubmodule() { + return this.file.type === 'submodule'; + }, + isTree() { + return this.file.type === 'tree'; + }, levelIndentation() { return { marginLeft: `${this.file.level * 16}px`, @@ -39,13 +48,39 @@ return this.file.id.substr(0, 8); }, submoduleColSpan() { - return !this.isCollapsed && this.isSubmodule ? 3 : 1; + return !this.leftPanelCollapsed && this.isSubmodule ? 3 : 1; + }, + fileClass() { + if (this.file.type === 'blob') { + if (this.file.active) { + return 'file-open file-active'; + } + return this.file.opened ? 'file-open' : ''; + } + return ''; + }, + changedClass() { + return { + 'fa-circle unsaved-icon': this.file.changed || this.file.tempFile, + }; }, }, methods: { - ...mapActions([ - 'clickedTreeRow', - ]), + clickFile(row) { + // Manual Action if a tree is selected/opened + if (this.file.type === 'tree' && this.$router.currentRoute.path === `/project${row.url}`) { + this.$store.dispatch('toggleTreeOpen', { + endpoint: this.file.url, + tree: this.file, + }); + } + this.$router.push(`/project${row.url}`); + }, + }, + updated() { + if (this.file.type === 'blob' && this.file.active) { + this.$el.scrollIntoView(); + } }, }; </script> @@ -53,7 +88,8 @@ <template> <tr class="file" - @click.prevent="clickedTreeRow(file)"> + :class="fileClass" + @click="clickFile(file)"> <td class="multi-file-table-name" :colspan="submoduleColSpan" @@ -66,11 +102,23 @@ > </i> <a - :href="file.url" class="repo-file-name" > {{ file.name }} </a> + <new-dropdown + v-if="isTree" + :project-id="file.projectId" + :branch="file.branchId" + :path="file.path" + :parent="file"/> + <i + class="fa" + v-if="changedClass" + :class="changedClass" + aria-hidden="true" + > + </i> <template v-if="isSubmodule && file.id"> @ <span class="commit-sha"> @@ -84,7 +132,7 @@ </template> </td> - <template v-if="!isCollapsed && !isSubmodule"> + <template v-if="showExtraColumns && !isSubmodule"> <td class="multi-file-table-col-commit-message hidden-sm hidden-xs"> <a v-if="file.lastCommit.message" diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue index 34f0d51819a..34f0d51819a 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue index 8fa637d771f..7eb840c7608 100644 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ b/app/assets/javascripts/ide/components/repo_loading_file.vue @@ -1,5 +1,5 @@ <script> - import { mapGetters } from 'vuex'; + import { mapState } from 'vuex'; import skeletonLoadingContainer from '../../vue_shared/components/skeleton_loading_container.vue'; export default { @@ -7,8 +7,8 @@ skeletonLoadingContainer, }, computed: { - ...mapGetters([ - 'isCollapsed', + ...mapState([ + 'leftPanelCollapsed', ]), }, }; @@ -24,7 +24,7 @@ :small="true" /> </td> - <template v-if="!isCollapsed"> + <template v-if="!leftPanelCollapsed"> <td class="hidden-sm hidden-xs"> <skeleton-loading-container diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/ide/components/repo_prev_directory.vue index a2b305bbd05..7cd359ea4ed 100644 --- a/app/assets/javascripts/repo/components/repo_prev_directory.vue +++ b/app/assets/javascripts/ide/components/repo_prev_directory.vue @@ -1,16 +1,14 @@ <script> - import { mapGetters, mapState, mapActions } from 'vuex'; + import { mapState, mapActions } from 'vuex'; export default { computed: { ...mapState([ 'parentTreeUrl', - ]), - ...mapGetters([ - 'isCollapsed', + 'leftPanelCollapsed', ]), colSpanCondition() { - return this.isCollapsed ? undefined : 3; + return this.leftPanelCollapsed ? undefined : 3; }, }, methods: { diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/ide/components/repo_preview.vue index 3d1e0297bd5..3d1e0297bd5 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/ide/components/repo_preview.vue diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index fb29a60df66..5bd63ac9ec5 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -27,16 +27,18 @@ export default { methods: { ...mapActions([ - 'setFileActive', 'closeFile', ]), + clickFile(tab) { + this.$router.push(`/project${tab.url}`); + }, }, }; </script> <template> <li - @click="setFileActive(tab)" + @click="clickFile(tab)" > <button type="button" diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index ab0bef4f0ac..ab0bef4f0ac 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js new file mode 100644 index 00000000000..a9cbf8e370f --- /dev/null +++ b/app/assets/javascripts/ide/ide_router.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import store from './stores'; +import flash from '../flash'; +import { + getTreeEntry, +} from './stores/utils'; + +Vue.use(VueRouter); + +/** + * Routes below /-/ide/: + +/project/h5bp/html5-boilerplate/blob/master +/project/h5bp/html5-boilerplate/blob/master/app/js/test.js + +/project/h5bp/html5-boilerplate/mr/123 +/project/h5bp/html5-boilerplate/mr/123/app/js/test.js + +/workspace/123 +/workspace/project/h5bp/html5-boilerplate/blob/my-special-branch +/workspace/project/h5bp/html5-boilerplate/mr/123 + +/ = /workspace + +/settings +*/ + +// Unfortunately Vue Router doesn't work without at least a fake component +// If you do only data handling +const EmptyRouterComponent = { + render(createElement) { + return createElement('div'); + }, +}; + +const router = new VueRouter({ + mode: 'history', + base: `${gon.relative_url_root}/-/ide/`, + routes: [ + { + path: '/project/:namespace/:project', + component: EmptyRouterComponent, + children: [ + { + path: ':targetmode/:branch/*', + component: EmptyRouterComponent, + }, + { + path: 'mr/:mrid', + component: EmptyRouterComponent, + }, + ], + }, + ], +}); + +router.beforeEach((to, from, next) => { + if (to.params.namespace && to.params.project) { + store.dispatch('getProjectData', { + namespace: to.params.namespace, + projectId: to.params.project, + }) + .then(() => { + const fullProjectId = `${to.params.namespace}/${to.params.project}`; + + if (to.params.branch) { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: to.params.branch, + }); + + store.dispatch('getTreeData', { + projectId: fullProjectId, + branch: to.params.branch, + endpoint: `/tree/${to.params.branch}`, + }) + .then(() => { + if (to.params[0]) { + const treeEntry = getTreeEntry(store, `${to.params.namespace}/${to.params.project}/${to.params.branch}`, to.params[0]); + if (treeEntry) { + store.dispatch('handleTreeEntryAction', treeEntry); + } + } + }) + .catch((e) => { + flash('Error while loading the branch files. Please try again.'); + throw e; + }); + } + }) + .catch((e) => { + flash('Error while loading the project data. Please try again.'); + throw e; + }); + } + + next(); +}); + +export default router; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js new file mode 100644 index 00000000000..a96bd339f51 --- /dev/null +++ b/app/assets/javascripts/ide/index.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import { mapActions } from 'vuex'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; +import ide from './components/ide.vue'; + +import store from './stores'; +import router from './ide_router'; +import Translate from '../vue_shared/translate'; +import ContextualSidebar from '../contextual_sidebar'; + +function initIde(el) { + if (!el) return null; + + return new Vue({ + el, + store, + router, + components: { + ide, + }, + methods: { + ...mapActions([ + 'setInitialData', + ]), + }, + created() { + const data = el.dataset; + + this.setInitialData({ + endpoints: { + rootEndpoint: data.url, + newMergeRequestUrl: data.newMergeRequestUrl, + rootUrl: data.rootUrl, + }, + canCommit: convertPermissionToBoolean(data.canCommit), + onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch), + path: data.currentPath, + isRoot: convertPermissionToBoolean(data.root), + isInitialRoot: convertPermissionToBoolean(data.root), + }); + }, + render(createElement) { + return createElement('ide'); + }, + }); +} + +const ideElement = document.getElementById('ide'); + +Vue.use(Translate); + +initIde(ideElement); + +const contextualSidebar = new ContextualSidebar(); +contextualSidebar.bindEvents(); diff --git a/app/assets/javascripts/repo/lib/common/disposable.js b/app/assets/javascripts/ide/lib/common/disposable.js index 84b29bdb600..84b29bdb600 100644 --- a/app/assets/javascripts/repo/lib/common/disposable.js +++ b/app/assets/javascripts/ide/lib/common/disposable.js diff --git a/app/assets/javascripts/repo/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 23c4811e6c0..14d9fe4771e 100644 --- a/app/assets/javascripts/repo/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -28,6 +28,14 @@ export default class Model { return this.model.uri.toString(); } + get language() { + return this.model.getModeId(); + } + + get eol() { + return this.model.getEOL() === '\n' ? 'LF' : 'CRLF'; + } + get path() { return this.file.path; } diff --git a/app/assets/javascripts/repo/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js index fd462252795..fd462252795 100644 --- a/app/assets/javascripts/repo/lib/common/model_manager.js +++ b/app/assets/javascripts/ide/lib/common/model_manager.js diff --git a/app/assets/javascripts/repo/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js index 0954b7973c4..0954b7973c4 100644 --- a/app/assets/javascripts/repo/lib/decorations/controller.js +++ b/app/assets/javascripts/ide/lib/decorations/controller.js diff --git a/app/assets/javascripts/repo/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index dc0b1c95e59..dc0b1c95e59 100644 --- a/app/assets/javascripts/repo/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js diff --git a/app/assets/javascripts/repo/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js index 0e37f5c4704..0e37f5c4704 100644 --- a/app/assets/javascripts/repo/lib/diff/diff.js +++ b/app/assets/javascripts/ide/lib/diff/diff.js diff --git a/app/assets/javascripts/repo/lib/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js index e74c4046330..e74c4046330 100644 --- a/app/assets/javascripts/repo/lib/diff/diff_worker.js +++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js diff --git a/app/assets/javascripts/repo/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index db499444402..51e202b9348 100644 --- a/app/assets/javascripts/repo/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -22,6 +22,11 @@ export default class Editor { this.modelManager = new ModelManager(this.monaco), this.decorationsController = new DecorationsController(this), ); + + this.debouncedUpdate = _.debounce(() => { + this.updateDimensions(); + }, 200); + window.addEventListener('resize', this.debouncedUpdate, false); } createInstance(domElement) { @@ -32,6 +37,9 @@ export default class Editor { readOnly: false, contextmenu: true, scrollBeyondLastLine: false, + minimap: { + enabled: false, + }, }), this.dirtyDiffController = new DirtyDiffController( this.modelManager, this.decorationsController, @@ -70,10 +78,32 @@ export default class Editor { dispose() { this.disposable.dispose(); + window.removeEventListener('resize', this.debouncedUpdate); // dispose main monaco instance if (this.instance) { this.instance = null; } } + + updateDimensions() { + this.instance.layout(); + } + + setPosition({ lineNumber, column }) { + this.instance.revealPositionInCenter({ + lineNumber, + column, + }); + this.instance.setPosition({ + lineNumber, + column, + }); + } + + onPositionChange(cb) { + this.disposable.add( + this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)), + ); + } } diff --git a/app/assets/javascripts/repo/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index 701affc466e..701affc466e 100644 --- a/app/assets/javascripts/repo/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js diff --git a/app/assets/javascripts/repo/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js index af83a1ec0b4..af83a1ec0b4 100644 --- a/app/assets/javascripts/repo/monaco_loader.js +++ b/app/assets/javascripts/ide/monaco_loader.js diff --git a/app/assets/javascripts/repo/services/index.js b/app/assets/javascripts/ide/services/index.js index 994d325e991..1fb24e93f2e 100644 --- a/app/assets/javascripts/repo/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -23,8 +23,11 @@ export default { return Vue.http.get(file.rawPath, { params: { format: 'json' } }) .then(res => res.text()); }, - getBranchData(projectId, currentBranch) { - return Api.branchSingle(projectId, currentBranch); + getProjectData(namespace, project) { + return Api.project(`${namespace}/${project}`); + }, + getBranchData(projectId, currentBranchId) { + return Api.branchSingle(projectId, currentBranchId); }, createBranch(projectId, payload) { const url = Api.buildUrl(Api.createBranchPath).replace(':id', projectId); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js new file mode 100644 index 00000000000..c01046c8c76 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions.js @@ -0,0 +1,179 @@ +import Vue from 'vue'; +import { visitUrl } from '../../lib/utils/url_utility'; +import flash from '../../flash'; +import service from '../services'; +import * as types from './mutation_types'; + +export const redirectToUrl = (_, url) => visitUrl(url); + +export const setInitialData = ({ commit }, data) => + commit(types.SET_INITIAL_DATA, data); + +export const closeDiscardPopup = ({ commit }) => + commit(types.TOGGLE_DISCARD_POPUP, false); + +export const discardAllChanges = ({ commit, getters, dispatch }) => { + const changedFiles = getters.changedFiles; + + changedFiles.forEach((file) => { + commit(types.DISCARD_FILE_CHANGES, file); + + if (file.tempFile) { + dispatch('closeFile', { file, force: true }); + } + }); +}; + +export const closeAllFiles = ({ state, dispatch }) => { + state.openFiles.forEach(file => dispatch('closeFile', { file })); +}; + +export const toggleEditMode = ( + { state, commit, getters, dispatch }, + force = false, +) => { + const changedFiles = getters.changedFiles; + + if (changedFiles.length && !force) { + commit(types.TOGGLE_DISCARD_POPUP, true); + } else { + commit(types.TOGGLE_EDIT_MODE); + commit(types.TOGGLE_DISCARD_POPUP, false); + dispatch('toggleBlobView'); + + if (!state.editMode) { + dispatch('discardAllChanges'); + } + } +}; + +export const toggleBlobView = ({ commit, state }) => { + if (state.editMode) { + commit(types.SET_EDIT_MODE); + } else { + commit(types.SET_PREVIEW_MODE); + } +}; + +export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { + if (side === 'left') { + commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed); + } else { + commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed); + } +}; + +export const checkCommitStatus = ({ state }) => + service + .getBranchData(state.currentProjectId, state.currentBranchId) + .then((data) => { + const { id } = data.commit; + const selectedBranch = + state.projects[state.currentProjectId].branches[state.currentBranchId]; + + if (selectedBranch.workingReference !== id) { + return true; + } + + return false; + }) + .catch(() => flash('Error checking branch data. Please try again.')); + +export const commitChanges = ( + { commit, state, dispatch, getters }, + { payload, newMr }, +) => + service + .commit(state.currentProjectId, payload) + .then((data) => { + const { branch } = payload; + if (!data.short_id) { + flash(data.message); + return; + } + + const selectedProject = state.projects[state.currentProjectId]; + const lastCommit = { + commit_path: `${selectedProject.web_url}/commit/${data.id}`, + commit: { + message: data.message, + authored_date: data.committed_date, + }, + }; + + flash( + `Your changes have been committed. Commit ${data.short_id} with ${ + data.stats.additions + } additions, ${data.stats.deletions} deletions.`, + 'notice', + ); + + if (newMr) { + dispatch( + 'redirectToUrl', + `${ + selectedProject.web_url + }/merge_requests/new?merge_request%5Bsource_branch%5D=${branch}`, + ); + } else { + commit(types.SET_BRANCH_WORKING_REFERENCE, { + projectId: state.currentProjectId, + branchId: state.currentBranchId, + reference: data.id, + }); + + getters.changedFiles.forEach((entry) => { + commit(types.SET_LAST_COMMIT_DATA, { + entry, + lastCommit, + }); + }); + + dispatch('discardAllChanges'); + dispatch('closeAllFiles'); + + window.scrollTo(0, 0); + } + }) + .catch(() => flash('Error committing changes. Please try again.')); + +export const createTempEntry = ( + { state, dispatch }, + { projectId, branchId, parent, name, type, content = '', base64 = false }, +) => { + const selectedParent = parent || state.trees[`${projectId}/${branchId}`]; + if (type === 'tree') { + dispatch('createTempTree', { + projectId, + branchId, + parent: selectedParent, + name, + }); + } else if (type === 'blob') { + dispatch('createTempFile', { + projectId, + branchId, + parent: selectedParent, + name, + base64, + content, + }); + } +}; + +export const scrollToTab = () => { + Vue.nextTick(() => { + const tabs = document.getElementById('tabs'); + + if (tabs) { + const tabEl = tabs.querySelector('.active .repo-tab'); + + tabEl.focus(); + } + }); +}; + +export * from './actions/tree'; +export * from './actions/file'; +export * from './actions/project'; +export * from './actions/branch'; diff --git a/app/assets/javascripts/ide/stores/actions/branch.js b/app/assets/javascripts/ide/stores/actions/branch.js new file mode 100644 index 00000000000..32bdf7fec22 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/branch.js @@ -0,0 +1,43 @@ +import service from '../../services'; +import flash from '../../../flash'; +import * as types from '../mutation_types'; + +export const getBranchData = ( + { commit, state, dispatch }, + { projectId, branchId, force = false } = {}, +) => new Promise((resolve, reject) => { + if ((typeof state.projects[`${projectId}`] === 'undefined' || + !state.projects[`${projectId}`].branches[branchId]) + || force) { + service.getBranchData(`${projectId}`, branchId) + .then((data) => { + const { id } = data.commit; + commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data }); + commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); + resolve(data); + }) + .catch(() => { + flash('Error loading branch data. Please try again.'); + reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); + }); + } else { + resolve(state.projects[`${projectId}`].branches[branchId]); + } +}); + +export const createNewBranch = ({ state, commit }, branch) => service.createBranch( + state.currentProjectId, + { + branch, + ref: state.currentBranchId, + }, +) +.then(res => res.json()) +.then((data) => { + const branchName = data.name; + const url = location.href.replace(state.currentBranchId, branchName); + + if (this.$router) this.$router.push(url); + + commit(types.SET_CURRENT_BRANCH, branchName); +}); diff --git a/app/assets/javascripts/repo/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 5bae4fa826a..0f27d5bf1c3 100644 --- a/app/assets/javascripts/repo/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -2,9 +2,9 @@ import { normalizeHeaders } from '../../../lib/utils/common_utils'; import flash from '../../../flash'; import service from '../../services'; import * as types from '../mutation_types'; +import router from '../../ide_router'; import { findEntry, - pushState, setPageTitle, createTemp, findIndexOfFile, @@ -25,7 +25,7 @@ export const closeFile = ({ commit, state, dispatch }, { file, force = false }) dispatch('setFileActive', nextFileToOpen); } else if (!state.openFiles.length) { - pushState(file.parentTreeUrl); + router.push(`/project/${file.projectId}/tree/${file.branchId}/`); } dispatch('getLastCommitData'); @@ -45,6 +45,9 @@ export const setFileActive = ({ commit, state, getters, dispatch }, file) => { // reset hash for line highlighting location.hash = ''; + + commit(types.SET_CURRENT_PROJECT, file.projectId); + commit(types.SET_CURRENT_BRANCH, file.branchId); }; export const getFileData = ({ state, commit, dispatch }, file) => { @@ -63,8 +66,6 @@ export const getFileData = ({ state, commit, dispatch }, file) => { commit(types.TOGGLE_FILE_OPEN, file); dispatch('setFileActive', file); commit(types.TOGGLE_LOADING, file); - - pushState(file.url); }) .catch(() => { commit(types.TOGGLE_LOADING, file); @@ -82,21 +83,39 @@ export const changeFileContent = ({ commit }, { file, content }) => { commit(types.UPDATE_FILE_CONTENT, { file, content }); }; -export const createTempFile = ({ state, commit, dispatch }, { tree, name, content = '', base64 = '' }) => { +export const setFileLanguage = ({ state, commit }, { fileLanguage }) => { + commit(types.SET_FILE_LANGUAGE, { file: state.selectedFile, fileLanguage }); +}; + +export const setFileEOL = ({ state, commit }, { eol }) => { + commit(types.SET_FILE_EOL, { file: state.selectedFile, eol }); +}; + +export const setEditorPosition = ({ state, commit }, { editorRow, editorColumn }) => { + commit(types.SET_FILE_POSITION, { file: state.selectedFile, editorRow, editorColumn }); +}; + +export const createTempFile = ({ state, commit, dispatch }, { projectId, branchId, parent, name, content = '', base64 = '' }) => { + const path = parent.path !== undefined ? parent.path : ''; + // We need to do the replacement otherwise the web_url + file.url duplicate + const newUrl = `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${name}`; const file = createTemp({ - name: name.replace(`${state.path}/`, ''), - path: tree.path, + projectId, + branchId, + name: name.replace(`${path}/`, ''), + path, type: 'blob', - level: tree.level !== undefined ? tree.level + 1 : 0, + level: parent.level !== undefined ? parent.level + 1 : 0, changed: true, content, base64, + url: newUrl, }); - if (findEntry(tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); + if (findEntry(parent.tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); commit(types.CREATE_TMP_FILE, { - parent: tree, + parent, file, }); commit(types.TOGGLE_FILE_OPEN, file); @@ -106,5 +125,7 @@ export const createTempFile = ({ state, commit, dispatch }, { tree, name, conten dispatch('toggleEditMode', true); } + router.push(`/project${file.url}`); + return Promise.resolve(file); }; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js new file mode 100644 index 00000000000..75e332090cb --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -0,0 +1,25 @@ +import service from '../../services'; +import flash from '../../../flash'; +import * as types from '../mutation_types'; + +// eslint-disable-next-line import/prefer-default-export +export const getProjectData = ( + { commit, state, dispatch }, + { namespace, projectId, force = false } = {}, +) => new Promise((resolve, reject) => { + if (!state.projects[`${namespace}/${projectId}`] || force) { + service.getProjectData(namespace, projectId) + .then(res => res.data) + .then((data) => { + commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); + if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); + resolve(data); + }) + .catch(() => { + flash('Error loading project data. Please try again.'); + reject(new Error(`Project not loaded ${namespace}/${projectId}`)); + }); + } else { + resolve(state.projects[`${namespace}/${projectId}`]); + } +}); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js new file mode 100644 index 00000000000..25909400a75 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -0,0 +1,188 @@ +import { visitUrl } from '../../../lib/utils/url_utility'; +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import router from '../../ide_router'; +import { + setPageTitle, + findEntry, + createTemp, + createOrMergeEntry, +} from '../utils'; + +export const getTreeData = ( + { commit, state, dispatch }, + { endpoint, tree = null, projectId, branch, force = false } = {}, +) => new Promise((resolve, reject) => { + // We already have the base tree so we resolve immediately + if (!tree && state.trees[`${projectId}/${branch}`] && !force) { + resolve(); + } else { + if (tree) commit(types.TOGGLE_LOADING, tree); + const selectedProject = state.projects[projectId]; + // We are merging the web_url that we got on the project info with the endpoint + // we got on the tree entry, as both contain the projectId, we replace it in the tree endpoint + const completeEndpoint = selectedProject.web_url + (endpoint).replace(projectId, ''); + if (completeEndpoint && (!tree || !tree.tempFile)) { + service.getTreeData(completeEndpoint) + .then((res) => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then((data) => { + if (!state.isInitialRoot) { + commit(types.SET_ROOT, data.path === '/'); + } + + dispatch('updateDirectoryData', { data, tree, projectId, branch }); + const selectedTree = tree || state.trees[`${projectId}/${branch}`]; + + commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); + commit(types.SET_LAST_COMMIT_URL, { tree: selectedTree, url: data.last_commit_path }); + if (tree) commit(types.TOGGLE_LOADING, selectedTree); + + const prevLastCommitPath = selectedTree.lastCommitPath; + if (prevLastCommitPath !== null) { + dispatch('getLastCommitData', selectedTree); + } + resolve(data); + }) + .catch((e) => { + flash('Error loading tree data. Please try again.'); + if (tree) commit(types.TOGGLE_LOADING, tree); + reject(e); + }); + } else { + resolve(); + } + } +}); + +export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { + if (tree.opened) { + // send empty data to clear the tree + const data = { trees: [], blobs: [], submodules: [] }; + + dispatch('updateDirectoryData', { data, tree, projectId: tree.projectId, branchId: tree.branchId }); + } else { + dispatch('getTreeData', { endpoint, tree, projectId: tree.projectId, branch: tree.branchId }); + } + + commit(types.TOGGLE_TREE_OPEN, tree); +}; + +export const handleTreeEntryAction = ({ commit, dispatch }, row) => { + if (row.type === 'tree') { + dispatch('toggleTreeOpen', { + endpoint: row.url, + tree: row, + }); + } else if (row.type === 'submodule') { + commit(types.TOGGLE_LOADING, row); + visitUrl(row.url); + } else if (row.type === 'blob' && row.opened) { + dispatch('setFileActive', row); + } else { + dispatch('getFileData', row); + } +}; + +export const createTempTree = ( + { state, commit, dispatch }, + { projectId, branchId, parent, name }, +) => { + let selectedTree = parent; + const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/'); + + dirNames.forEach((dirName) => { + const foundEntry = findEntry(selectedTree.tree, 'tree', dirName); + + if (!foundEntry) { + const path = selectedTree.path !== undefined ? selectedTree.path : ''; + const tmpEntry = createTemp({ + projectId, + branchId, + name: dirName, + path, + type: 'tree', + level: selectedTree.level !== undefined ? selectedTree.level + 1 : 0, + tree: [], + url: `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${dirName}`, + }); + + commit(types.CREATE_TMP_TREE, { + parent: selectedTree, + tmpEntry, + }); + commit(types.TOGGLE_TREE_OPEN, tmpEntry); + + router.push(`/project${tmpEntry.url}`); + + selectedTree = tmpEntry; + } else { + selectedTree = foundEntry; + } + }); +}; + +export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { + if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; + + service.getTreeLastCommit(tree.lastCommitPath) + .then((res) => { + const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; + + commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); + + return res.json(); + }) + .then((data) => { + data.forEach((lastCommit) => { + const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); + + if (entry) { + commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); + } + }); + + dispatch('getLastCommitData', tree); + }) + .catch(() => flash('Error fetching log data.')); +}; + +export const updateDirectoryData = ( + { commit, state }, + { data, tree, projectId, branch }, +) => { + if (!tree) { + const existingTree = state.trees[`${projectId}/${branch}`]; + if (!existingTree) { + commit(types.CREATE_TREE, { treePath: `${projectId}/${branch}` }); + } + } + + const selectedTree = tree || state.trees[`${projectId}/${branch}`]; + const level = selectedTree.level !== undefined ? selectedTree.level + 1 : 0; + const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; + const createEntry = (entry, type) => createOrMergeEntry({ + tree: selectedTree, + projectId: `${projectId}`, + branchId: branch, + entry, + level, + type, + parentTreeUrl, + }); + + const formattedData = [ + ...data.trees.map(t => createEntry(t, 'tree')), + ...data.submodules.map(m => createEntry(m, 'submodule')), + ...data.blobs.map(b => createEntry(b, 'blob')), + ]; + + commit(types.SET_DIRECTORY_DATA, { tree: selectedTree, data: formattedData }); +}; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js new file mode 100644 index 00000000000..6b51ccff817 --- /dev/null +++ b/app/assets/javascripts/ide/stores/getters.js @@ -0,0 +1,19 @@ +export const changedFiles = state => state.openFiles.filter(file => file.changed); + +export const activeFile = state => state.openFiles.find(file => file.active) || null; + +export const activeFileExtension = (state) => { + const file = activeFile(state); + return file ? `.${file.path.split('.').pop()}` : ''; +}; + +export const canEditFile = (state) => { + const currentActiveFile = activeFile(state); + + return state.canCommit && + (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); +}; + +export const addedFiles = state => changedFiles(state).filter(f => f.tempFile); + +export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile); diff --git a/app/assets/javascripts/repo/stores/index.js b/app/assets/javascripts/ide/stores/index.js index 6ac9bfd8189..6ac9bfd8189 100644 --- a/app/assets/javascripts/repo/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js diff --git a/app/assets/javascripts/repo/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index bc3390f1506..4e3c10972ba 100644 --- a/app/assets/javascripts/repo/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -1,16 +1,27 @@ export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_COMMIT_REF = 'SET_COMMIT_REF'; export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL'; export const SET_ROOT = 'SET_ROOT'; -export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL'; export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; +export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; +export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED'; + +// Project Mutation Types +export const SET_PROJECT = 'SET_PROJECT'; +export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; +export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; + +// Branch Mutation Types +export const SET_BRANCH = 'SET_BRANCH'; +export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; +export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; // Tree mutation types export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; export const CREATE_TMP_TREE = 'CREATE_TMP_TREE'; export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; +export const CREATE_TREE = 'CREATE_TREE'; // File mutation types export const SET_FILE_DATA = 'SET_FILE_DATA'; @@ -18,6 +29,9 @@ export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; +export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; +export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_EOL = 'SET_FILE_EOL'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const CREATE_TMP_FILE = 'CREATE_TMP_FILE'; @@ -28,3 +42,4 @@ export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE'; export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP'; export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; + diff --git a/app/assets/javascripts/repo/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index ae2ba5bedf7..2fed9019cb6 100644 --- a/app/assets/javascripts/repo/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,4 +1,5 @@ import * as types from './mutation_types'; +import projectMutations from './mutations/project'; import fileMutations from './mutations/file'; import treeMutations from './mutations/tree'; import branchMutations from './mutations/branch'; @@ -32,29 +33,32 @@ export default { discardPopupOpen, }); }, - [types.SET_COMMIT_REF](state, ref) { - Object.assign(state, { - currentRef: ref, - }); - }, [types.SET_ROOT](state, isRoot) { Object.assign(state, { isRoot, isInitialRoot: isRoot, }); }, - [types.SET_PREVIOUS_URL](state, previousUrl) { + [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) { + Object.assign(state, { + leftPanelCollapsed: collapsed, + }); + }, + [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) { Object.assign(state, { - previousUrl, + rightPanelCollapsed: collapsed, }); }, [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { Object.assign(entry.lastCommit, { + id: lastCommit.commit.id, url: lastCommit.commit_path, message: lastCommit.commit.message, + author: lastCommit.commit.author_name, updatedAt: lastCommit.commit.authored_date, }); }, + ...projectMutations, ...fileMutations, ...treeMutations, ...branchMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js new file mode 100644 index 00000000000..04b9582c5bb --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -0,0 +1,28 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_BRANCH](state, currentBranchId) { + Object.assign(state, { + currentBranchId, + }); + }, + [types.SET_BRANCH](state, { projectPath, branchName, branch }) { + // Add client side properties + Object.assign(branch, { + treeId: `${projectPath}/${branchName}`, + active: true, + workingReference: '', + }); + + Object.assign(state.projects[projectPath], { + branches: { + [branchName]: branch, + }, + }); + }, + [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) { + Object.assign(state.projects[projectId].branches[branchId], { + workingReference: reference, + }); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index f9ba80b9dc2..5f3655b0092 100644 --- a/app/assets/javascripts/repo/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -6,6 +6,10 @@ export default { Object.assign(file, { active, }); + + Object.assign(state, { + selectedFile: file, + }); }, [types.TOGGLE_FILE_OPEN](state, file) { Object.assign(file, { @@ -42,6 +46,22 @@ export default { changed, }); }, + [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) { + Object.assign(file, { + fileLanguage, + }); + }, + [types.SET_FILE_EOL](state, { file, eol }) { + Object.assign(file, { + eol, + }); + }, + [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) { + Object.assign(file, { + editorRow, + editorColumn, + }); + }, [types.DISCARD_FILE_CHANGES](state, file) { Object.assign(file, { content: '', diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js new file mode 100644 index 00000000000..2816562a919 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -0,0 +1,23 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_PROJECT](state, currentProjectId) { + Object.assign(state, { + currentProjectId, + }); + }, + [types.SET_PROJECT](state, { projectPath, project }) { + // Add client side properties + Object.assign(project, { + tree: [], + branches: {}, + active: true, + }); + + Object.assign(state, { + projects: Object.assign({}, state.projects, { + [projectPath]: project, + }), + }); + }, +}; diff --git a/app/assets/javascripts/repo/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js index 130221c9fda..4fe438ab465 100644 --- a/app/assets/javascripts/repo/stores/mutations/tree.js +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -6,6 +6,15 @@ export default { opened: !tree.opened, }); }, + [types.CREATE_TREE](state, { treePath }) { + Object.assign(state, { + trees: Object.assign({}, state.trees, { + [treePath]: { + tree: [], + }, + }), + }); + }, [types.SET_DIRECTORY_DATA](state, { data, tree }) { Object.assign(tree, { tree: data, diff --git a/app/assets/javascripts/repo/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 0068834831e..539e382830f 100644 --- a/app/assets/javascripts/repo/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -1,10 +1,10 @@ export default () => ({ canCommit: false, - currentBranch: '', - currentBlobView: 'repo-preview', - currentRef: '', + currentProjectId: '', + currentBranchId: '', + currentBlobView: 'repo-editor', discardPopupOpen: false, - editMode: false, + editMode: true, endpoints: {}, isRoot: false, isInitialRoot: false, @@ -12,13 +12,11 @@ export default () => ({ loading: false, onTopOfBranch: false, openFiles: [], + selectedFile: null, path: '', - project: { - id: 0, - name: '', - url: '', - }, parentTreeUrl: '', - previousUrl: '', - tree: [], + trees: {}, + projects: {}, + leftPanelCollapsed: false, + rightPanelCollapsed: true, }); diff --git a/app/assets/javascripts/repo/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index fae1f4439a9..29e3ab5d040 100644 --- a/app/assets/javascripts/repo/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -2,6 +2,8 @@ export const dataStructure = () => ({ id: '', key: '', type: '', + projectId: '', + branchId: '', name: '', url: '', path: '', @@ -15,9 +17,11 @@ export const dataStructure = () => ({ changed: false, lastCommitPath: '', lastCommit: { + id: '', url: '', message: '', updatedAt: '', + author: '', }, tree_url: '', blamePath: '', @@ -31,11 +35,17 @@ export const dataStructure = () => ({ parentTreeUrl: '', renderError: false, base64: false, + editorRow: 1, + editorColumn: 1, + fileLanguage: '', + eol: '', }); export const decorateData = (entity) => { const { id, + projectId, + branchId, type, url, name, @@ -56,6 +66,8 @@ export const decorateData = (entity) => { return { ...dataStructure(), id, + projectId, + branchId, key: `${name}-${type}-${id}`, type, name, @@ -75,24 +87,51 @@ export const decorateData = (entity) => { }; }; -export const findEntry = (state, type, name) => state.tree.find( +/* + Takes the multi-dimensional tree and returns a flattened array. + This allows for the table to recursively render the table rows but keeps the data + structure nested to make it easier to add new files/directories. +*/ +export const treeList = (state, treeId) => { + const baseTree = state.trees[treeId]; + if (baseTree) { + const mapTree = arr => (!arr.tree || !arr.tree.length ? + [] : _.map(arr.tree, a => [a, mapTree(a)])); + + return _.chain(baseTree.tree) + .map(arr => [arr, mapTree(arr)]) + .flatten() + .value(); + } + return []; +}; + +export const getTree = state => (namespace, projectId, branch) => state.trees[`${namespace}/${projectId}/${branch}`]; + +export const getTreeEntry = (store, treeId, path) => { + const fileList = treeList(store.state, treeId); + return fileList ? fileList.find(file => file.path === path) : null; +}; + +export const findEntry = (tree, type, name) => tree.find( f => f.type === type && f.name === name, ); + export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); export const setPageTitle = (title) => { document.title = title; }; -export const pushState = (url) => { - history.pushState({ url }, '', url); -}; - -export const createTemp = ({ name, path, type, level, changed, content, base64 }) => { +export const createTemp = ({ + projectId, branchId, name, path, type, level, changed, content, base64, url, +}) => { const treePath = path ? `${path}/${name}` : name; return decorateData({ id: new Date().getTime().toString(), + projectId, + branchId, name, type, tempFile: true, @@ -104,11 +143,18 @@ export const createTemp = ({ name, path, type, level, changed, content, base64 } level, base64, renderError: base64, + url, }); }; -export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level }) => { - const found = findEntry(tree, type, entry.name); +export const createOrMergeEntry = ({ tree, + projectId, + branchId, + entry, + type, + parentTreeUrl, + level }) => { + const found = findEntry(tree.tree || tree, type, entry.name); if (found) { return Object.assign({}, found, { @@ -120,6 +166,8 @@ export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level }) return decorateData({ ...entry, + projectId, + branchId, type, parentTreeUrl, level, diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 25ebe5314e0..952f49d522e 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -32,7 +32,7 @@ export default { showInlineEditButton: { type: Boolean, required: false, - default: false, + default: true, }, showDeleteButton: { type: Boolean, diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index a363d06d950..b7e6eadd440 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -79,7 +79,7 @@ v-tooltip v-if="showInlineEditButton && canUpdate" type="button" - class="btn btn-default btn-edit btn-svg" + class="btn btn-default btn-edit btn-svg js-issuable-edit" v-html="pencilIcon" title="Edit title and description" data-placement="bottom" diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 7b762496ba5..75dfdedcf1b 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import eventHub from './event_hub'; import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; @@ -7,12 +6,6 @@ document.addEventListener('DOMContentLoaded', () => { const initialDataEl = document.getElementById('js-issuable-app-initial-data'); const props = JSON.parse(initialDataEl.innerHTML.replace(/"/g, '"')); - $('.js-issuable-edit').on('click', (e) => { - e.preventDefault(); - - eventHub.$emit('open.form'); - }); - return new Vue({ el: document.getElementById('js-issuable-app'), components: { diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index a6f82b247e2..ab3cc29146a 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,59 +1,51 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, no-unused-vars, one-var, one-var-declaration-per-line, vars-on-top, max-len */ -import _ from 'underscore'; -import Cookies from 'js-cookie'; import ContextualSidebar from './contextual_sidebar'; import initFlyOutNav from './fly_out_nav'; -(function() { - var hideEndFade; +function hideEndFade($scrollingTabs) { + $scrollingTabs.each(function scrollTabsLoop() { + const $this = $(this); + $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth')); + }); +} - hideEndFade = function($scrollingTabs) { - return $scrollingTabs.each(function() { - var $this; - $this = $(this); - return $this.siblings('.fade-right').toggleClass('scrolling', $this.width() < $this.prop('scrollWidth')); - }); - }; +export default function initLayoutNav() { + const contextualSidebar = new ContextualSidebar(); + contextualSidebar.bindEvents(); + + initFlyOutNav(); $(document).on('init.scrolling-tabs', () => { const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized'); $scrollingTabs.addClass('is-initialized'); - hideEndFade($scrollingTabs); - $(window).off('resize.nav').on('resize.nav', function() { - return hideEndFade($scrollingTabs); - }); - $scrollingTabs.off('scroll').on('scroll', function(event) { - var $this, currentPosition, maxPosition; - $this = $(this); - currentPosition = $this.scrollLeft(); - maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); + $(window).on('resize.nav', () => { + hideEndFade($scrollingTabs); + }).trigger('resize.nav'); + + $scrollingTabs.on('scroll', function tabsScrollEvent() { + const $this = $(this); + const currentPosition = $this.scrollLeft(); + const maxPosition = $this.prop('scrollWidth') - $this.outerWidth(); + $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0); - return $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); + $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1); }); - $scrollingTabs.each(function () { - var $this = $(this); - var scrollingTabWidth = $this.width(); - var $active = $this.find('.active'); - var activeWidth = $active.width(); + $scrollingTabs.each(function scrollTabsEachLoop() { + const $this = $(this); + const scrollingTabWidth = $this.width(); + const $active = $this.find('.active'); + const activeWidth = $active.width(); if ($active.length) { - var offset = $active.offset().left + activeWidth; + const offset = $active.offset().left + activeWidth; if (offset > scrollingTabWidth - 30) { - var scrollLeft = scrollingTabWidth / 2; - scrollLeft = (offset - scrollLeft) - (activeWidth / 2); + const scrollLeft = (offset - (scrollingTabWidth / 2)) - (activeWidth / 2); + $this.scrollLeft(scrollLeft); } } }); - }); - - $(() => { - const contextualSidebar = new ContextualSidebar(); - contextualSidebar.bindEvents(); - - initFlyOutNav(); - }); -}).call(window); + }).trigger('init.scrolling-tabs'); +} diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 198b5164c92..1fa6715180e 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -2,7 +2,7 @@ import timeago from 'timeago.js'; import dateFormat from 'vendor/date.format'; import { pluralize } from './text_utility'; import { - lang, + languageCode, s__, } from '../../locale'; @@ -24,7 +24,15 @@ export const getDayName = date => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', ' */ export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); +/** + * Timeago uses underscores instead of dashes to separate language from country code. + * + * see https://github.com/hustcc/timeago.js/tree/v3.0.0/locales + */ +const timeagoLanguageCode = languageCode().replace(/-/g, '_'); + let timeagoInstance; + /** * Sets a timeago Instance */ @@ -67,8 +75,8 @@ export function getTimeago() { ][index]; }; - timeago.register(lang, locale); - timeago.register(`${lang}-remaining`, localeRemaining); + timeago.register(timeagoLanguageCode, locale); + timeago.register(`${timeagoLanguageCode}-remaining`, localeRemaining); timeagoInstance = timeago(); } @@ -83,7 +91,7 @@ export const renderTimeago = ($els) => { const timeagoEls = $els || document.querySelectorAll('.js-timeago-render'); // timeago.js sets timeouts internally for each timeago value to be updated in real time - getTimeago().render(timeagoEls, lang); + getTimeago().render(timeagoEls, timeagoLanguageCode); }; /** @@ -118,7 +126,7 @@ export const timeFor = (time, expiredLabel) => { if (new Date(time) < new Date()) { return expiredLabel || s__('Timeago|Past due'); } - return getTimeago().format(time, `${lang}-remaining`).trim(); + return getTimeago().format(time, `${timeagoLanguageCode}-remaining`).trim(); }; export const getDayDifference = (a, b) => { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 15a3a91c5f5..59bfa482bb0 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -41,7 +41,7 @@ import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; import initTodoToggle from './header'; import initImporterStatus from './importer_status'; -import './layout_nav'; +import initLayoutNav from './layout_nav'; import LazyLoader from './lazy_loader'; import './line_highlighter'; import initLogoAnimation from './logo'; @@ -89,6 +89,7 @@ $(function () { var fitSidebarForSize; initBreadcrumbs(); + initLayoutNav(); initImporterStatus(); initTodoToggle(); initLogoAnimation(); @@ -261,8 +262,6 @@ $(function () { renderTimeago(); - $(document).trigger('init.scrolling-tabs'); - $('form.filter-form').on('submit', function (event) { const link = document.createElement('a'); link.href = this.action; diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index cdae287658b..eede04a06cd 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -1,5 +1,8 @@ <script> - import d3 from 'd3'; + import { scaleLinear, scaleTime } from 'd3-scale'; + import { axisLeft, axisBottom } from 'd3-axis'; + import { max, extent } from 'd3-array'; + import { select } from 'd3-selection'; import GraphLegend from './graph/legend.vue'; import GraphFlag from './graph/flag.vue'; import GraphDeployment from './graph/deployment.vue'; @@ -7,10 +10,12 @@ import MonitoringMixin from '../mixins/monitoring_mixins'; import eventHub from '../event_hub'; import measurements from '../utils/measurements'; - import { timeScaleFormat, bisectDate } from '../utils/date_time_formatters'; + import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters'; import createTimeSeries from '../utils/multiple_time_series'; import bp from '../../breakpoints'; + const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }; + export default { props: { graphData: { @@ -156,25 +161,22 @@ this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; } - const axisXScale = d3.time.scale() + const axisXScale = d3.scaleTime() .range([0, this.graphWidth - 70]); - const axisYScale = d3.scale.linear() + const axisYScale = d3.scaleLinear() .range([this.graphHeight - this.graphHeightOffset, 0]); const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); axisXScale.domain(d3.extent(allValues, d => d.time)); axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); - const xAxis = d3.svg.axis() + const xAxis = d3.axisBottom() .scale(axisXScale) - .ticks(d3.time.minute, 60) - .tickFormat(timeScaleFormat) - .orient('bottom'); + .tickFormat(timeScaleFormat); - const yAxis = d3.svg.axis() + const yAxis = d3.axisLeft() .scale(axisYScale) - .ticks(measurements.yTicks) - .orient('left'); + .ticks(measurements.yTicks); d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis); diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js index ad07a8465e2..48bdec1e030 100644 --- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js +++ b/app/assets/javascripts/monitoring/utils/date_time_formatters.js @@ -1,17 +1,32 @@ -import d3 from 'd3'; +import { timeFormat as time } from 'd3-time-format'; +import { timeSecond, timeMinute, timeHour, timeDay, timeMonth, timeYear } from 'd3-time'; +import { bisector } from 'd3-array'; -export const dateFormat = d3.time.format('%b %-d, %Y'); -export const dateFormatWithName = d3.time.format('%a, %b %-d'); -export const timeFormat = d3.time.format('%-I:%M%p'); +const d3 = { time, bisector, timeSecond, timeMinute, timeHour, timeDay, timeMonth, timeYear }; + +export const dateFormat = d3.time('%b %-d, %Y'); +export const timeFormat = d3.time('%-I:%M%p'); +export const dateFormatWithName = d3.time('%a, %b %-d'); export const bisectDate = d3.bisector(d => d.time).left; -export const timeScaleFormat = d3.time.format.multi([ - ['.%L', d => d.getMilliseconds()], - [':%S', d => d.getSeconds()], - ['%-I:%M', d => d.getMinutes()], - ['%-I %p', d => d.getHours()], - ['%a %-d', d => d.getDay() && d.getDate() !== 1], - ['%b %-d', d => d.getDate() !== 1], - ['%B', d => d.getMonth()], - ['%Y', () => true], -]); +export function timeScaleFormat(date) { + let formatFunction; + if (d3.timeSecond(date) < date) { + formatFunction = d3.time('.%L'); + } else if (d3.timeMinute(date) < date) { + formatFunction = d3.time(':%S'); + } else if (d3.timeHour(date) < date) { + formatFunction = d3.time('%-I:%M'); + } else if (d3.timeDay(date) < date) { + formatFunction = d3.time('%-I %p'); + } else if (d3.timeWeek(date) < date) { + formatFunction = d3.time('%a %d'); + } else if (d3.timeMonth(date) < date) { + formatFunction = d3.time('%b %d'); + } else if (d3.timeYear(date) < date) { + formatFunction = d3.time('%B'); + } else { + formatFunction = d3.time('%Y'); + } + return formatFunction(date); +} diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index d21a265bd43..4ce3dad440c 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -1,5 +1,10 @@ -import d3 from 'd3'; import _ from 'underscore'; +import { scaleLinear, scaleTime } from 'd3-scale'; +import { line, area, curveLinear } from 'd3-shape'; +import { extent, max } from 'd3-array'; +import { timeMinute } from 'd3-time'; + +const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute }; const defaultColorPalette = { blue: ['#1f78d1', '#8fbce8'], @@ -38,27 +43,27 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom let lineColor = ''; let areaColor = ''; - const timeSeriesScaleX = d3.time.scale() + const timeSeriesScaleX = d3.scaleTime() .range([0, graphWidth - 70]); - const timeSeriesScaleY = d3.scale.linear() + const timeSeriesScaleY = d3.scaleLinear() .range([graphHeight - graphHeightOffset, 0]); timeSeriesScaleX.domain(xDom); - timeSeriesScaleX.ticks(d3.time.minute, 60); + timeSeriesScaleX.ticks(d3.timeMinute, 60); timeSeriesScaleY.domain(yDom); const defined = d => !isNaN(d.value) && d.value != null; - const lineFunction = d3.svg.line() + const lineFunction = d3.line() .defined(defined) - .interpolate('linear') + .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate .x(d => timeSeriesScaleX(d.time)) .y(d => timeSeriesScaleY(d.value)); - const areaFunction = d3.svg.area() + const areaFunction = d3.area() .defined(defined) - .interpolate('linear') + .curve(d3.curveLinear) .x(d => timeSeriesScaleX(d.time)) .y0(graphHeight - graphHeightOffset) .y1(d => timeSeriesScaleY(d.value)); diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index 6e152497d20..a2f0a44863f 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -6,11 +6,12 @@ export default class NewCommitForm { this.branchName = form.find('.js-branch-name'); this.originalBranch = form.find('.js-original-branch'); this.createMergeRequest = form.find('.js-create-merge-request'); - this.createMergeRequestContainer = form.find('.js-create-merge-request-container'); + this.createMergeRequestContainer = form.find( + '.js-create-merge-request-container', + ); this.branchName.keyup(this.renderDestination); this.renderDestination(); } - renderDestination() { var different; different = this.branchName.val() !== this.originalBranch.val(); @@ -23,6 +24,6 @@ export default class NewCommitForm { this.createMergeRequestContainer.hide(); this.createMergeRequest.prop('checked', false); } - return this.wasDifferent = different; + return (this.wasDifferent = different); } } diff --git a/app/assets/javascripts/preview_markdown.js b/app/assets/javascripts/preview_markdown.js index 141333b2b4d..ffaafb3ee9e 100644 --- a/app/assets/javascripts/preview_markdown.js +++ b/app/assets/javascripts/preview_markdown.js @@ -117,12 +117,10 @@ }()); markdownPreview = new window.MarkdownPreview(); - previewButtonSelector = '.js-md-preview-button'; - writeButtonSelector = '.js-md-write-button'; - lastTextareaPreviewed = null; + const markdownToolbar = $('.md-header-toolbar'); $.fn.setupMarkdownPreview = function () { var $form = $(this); @@ -146,6 +144,7 @@ // toggle content $form.find('.md-write-holder').hide(); $form.find('.md-preview-holder').show(); + markdownToolbar.removeClass('active'); markdownPreview.showPreview($form); }); @@ -167,6 +166,7 @@ $form.find('.md-write-holder').show(); $form.find('textarea.markdown-area').focus(); $form.find('.md-preview-holder').hide(); + markdownToolbar.addClass('active'); markdownPreview.hideReferencedCommands($form); }); diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list.vue b/app/assets/javascripts/repo/components/commit_sidebar/list.vue deleted file mode 100644 index fb862e7bf01..00000000000 --- a/app/assets/javascripts/repo/components/commit_sidebar/list.vue +++ /dev/null @@ -1,89 +0,0 @@ -<script> - 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, - }, - collapsed: { - type: Boolean, - required: true, - }, - }, - methods: { - toggleCollapsed() { - this.$emit('toggleCollapsed'); - }, - }, - }; -</script> - -<template> - <div class="multi-file-commit-panel-section"> - <header - class="multi-file-commit-panel-header" - :class="{ - 'is-collapsed': collapsed, - }" - > - <icon - name="list-bulleted" - :size="18" - css-classes="append-right-default" - /> - <template v-if="!collapsed"> - {{ title }} - <button - type="button" - class="btn btn-transparent multi-file-commit-panel-collapse-btn" - @click="toggleCollapsed" - > - <i - aria-hidden="true" - class="fa fa-angle-double-right" - > - </i> - </button> - </template> - </header> - <div class="multi-file-commit-list"> - <list-collapsed - v-if="collapsed" - /> - <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> - <div - v-else - class="help-block prepend-top-0" - > - No changes - </div> - </template> - </div> - </div> -</template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/index.vue b/app/assets/javascripts/repo/components/new_dropdown/index.vue deleted file mode 100644 index 781404cf8ca..00000000000 --- a/app/assets/javascripts/repo/components/new_dropdown/index.vue +++ /dev/null @@ -1,89 +0,0 @@ -<script> - import { mapState } from 'vuex'; - import newModal from './modal.vue'; - import upload from './upload.vue'; - import icon from '../../../vue_shared/components/icon.vue'; - - export default { - components: { - icon, - newModal, - upload, - }, - data() { - return { - openModal: false, - modalType: '', - }; - }, - computed: { - ...mapState([ - 'path', - ]), - }, - methods: { - createNewItem(type) { - this.modalType = type; - this.toggleModalOpen(); - }, - toggleModalOpen() { - this.openModal = !this.openModal; - }, - }, - }; -</script> - -<template> - <div> - <ul class="breadcrumb repo-breadcrumb"> - <li class="dropdown"> - <button - type="button" - class="btn btn-default dropdown-toggle add-to-tree" - data-toggle="dropdown" - aria-label="Create new file or directory" - > - <icon - name="plus" - css-classes="pull-left" - /> - <icon - name="arrow-down" - css-classes="pull-left" - /> - </button> - <ul class="dropdown-menu"> - <li> - <a - href="#" - role="button" - @click.prevent="createNewItem('blob')" - > - {{ __('New file') }} - </a> - </li> - <li> - <upload - :path="path" - /> - </li> - <li> - <a - href="#" - role="button" - @click.prevent="createNewItem('tree')" - > - {{ __('New directory') }} - </a> - </li> - </ul> - </li> - </ul> - <new-modal - v-if="openModal" - :type="modalType" - :path="path" - @toggle="toggleModalOpen" - /> - </div> -</template> diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue deleted file mode 100644 index a00e1e9d809..00000000000 --- a/app/assets/javascripts/repo/components/repo.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { mapState, mapGetters } from 'vuex'; -import RepoSidebar from './repo_sidebar.vue'; -import RepoCommitSection from './repo_commit_section.vue'; -import RepoTabs from './repo_tabs.vue'; -import RepoFileButtons from './repo_file_buttons.vue'; -import RepoPreview from './repo_preview.vue'; -import repoEditor from './repo_editor.vue'; - -export default { - computed: { - ...mapState([ - 'currentBlobView', - ]), - ...mapGetters([ - 'isCollapsed', - 'changedFiles', - ]), - }, - components: { - RepoSidebar, - RepoTabs, - RepoFileButtons, - repoEditor, - RepoCommitSection, - RepoPreview, - }, - 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="multi-file" - :class="{ - 'is-collapsed': isCollapsed - }" - > - <repo-sidebar/> - <div - v-if="isCollapsed" - class="multi-file-edit-pane" - > - <repo-tabs /> - <component - class="multi-file-edit-pane-content" - :is="currentBlobView" - /> - <repo-file-buttons /> - </div> - <repo-commit-section /> - </div> -</template> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue deleted file mode 100644 index 4ea21913129..00000000000 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ /dev/null @@ -1,85 +0,0 @@ -<script> -import { mapState, mapGetters, mapActions } from 'vuex'; -import RepoPreviousDirectory from './repo_prev_directory.vue'; -import RepoFile from './repo_file.vue'; -import RepoLoadingFile from './repo_loading_file.vue'; - -export default { - components: { - 'repo-previous-directory': RepoPreviousDirectory, - 'repo-file': RepoFile, - 'repo-loading-file': RepoLoadingFile, - }, - created() { - window.addEventListener('popstate', this.popHistoryState); - }, - destroyed() { - window.removeEventListener('popstate', this.popHistoryState); - }, - mounted() { - this.getTreeData(); - }, - computed: { - ...mapState([ - 'loading', - 'isRoot', - ]), - ...mapState({ - projectName(state) { - return state.project.name; - }, - }), - ...mapGetters([ - 'treeList', - 'isCollapsed', - ]), - }, - methods: { - ...mapActions([ - 'getTreeData', - 'popHistoryState', - ]), - }, -}; -</script> - -<template> -<div class="ide-file-list"> - <table class="table"> - <thead> - <tr> - <th - v-if="isCollapsed" - > - </th> - <template v-else> - <th class="name multi-file-table-name"> - Name - </th> - <th class="hidden-sm hidden-xs last-commit"> - Last commit - </th> - <th class="hidden-xs last-update text-right"> - Last update - </th> - </template> - </tr> - </thead> - <tbody> - <repo-previous-directory - v-if="!isRoot && treeList.length" - /> - <repo-loading-file - v-if="!treeList.length && loading" - v-for="n in 5" - :key="n" - /> - <repo-file - v-for="file in treeList" - :key="file.key" - :file="file" - /> - </tbody> - </table> -</div> -</template> diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js deleted file mode 100644 index b6801af7fcb..00000000000 --- a/app/assets/javascripts/repo/index.js +++ /dev/null @@ -1,106 +0,0 @@ -import Vue from 'vue'; -import { mapActions } from 'vuex'; -import { convertPermissionToBoolean } from '../lib/utils/common_utils'; -import Repo from './components/repo.vue'; -import RepoEditButton from './components/repo_edit_button.vue'; -import newBranchForm from './components/new_branch_form.vue'; -import newDropdown from './components/new_dropdown/index.vue'; -import store from './stores'; -import Translate from '../vue_shared/translate'; - -function initRepo(el) { - if (!el) return null; - - return new Vue({ - el, - store, - components: { - repo: Repo, - }, - methods: { - ...mapActions([ - 'setInitialData', - ]), - }, - created() { - const data = el.dataset; - - this.setInitialData({ - project: { - id: data.projectId, - name: data.projectName, - url: data.projectUrl, - }, - endpoints: { - rootEndpoint: data.url, - newMergeRequestUrl: data.newMergeRequestUrl, - rootUrl: data.rootUrl, - }, - canCommit: convertPermissionToBoolean(data.canCommit), - onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch), - currentRef: data.ref, - path: data.currentPath, - currentBranch: data.currentBranch, - isRoot: convertPermissionToBoolean(data.root), - isInitialRoot: convertPermissionToBoolean(data.root), - }); - }, - render(createElement) { - return createElement('repo'); - }, - }); -} - -function initRepoEditButton(el) { - return new Vue({ - el, - store, - components: { - repoEditButton: RepoEditButton, - }, - render(createElement) { - return createElement('repo-edit-button'); - }, - }); -} - -function initNewDropdown(el) { - return new Vue({ - el, - store, - components: { - newDropdown, - }, - render(createElement) { - return createElement('new-dropdown'); - }, - }); -} - -function initNewBranchForm() { - const el = document.querySelector('.js-new-branch-dropdown'); - - if (!el) return null; - - return new Vue({ - el, - components: { - newBranchForm, - }, - store, - render(createElement) { - return createElement('new-branch-form'); - }, - }); -} - -const repo = document.getElementById('repo'); -const editButton = document.querySelector('.editable-mode'); -const newDropdownHolder = document.querySelector('.js-new-dropdown'); - -Vue.use(Translate); - -initRepo(repo); -initRepoEditButton(editButton); -initNewBranchForm(); -initNewDropdown(newDropdownHolder); diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js deleted file mode 100644 index af5dcf054ef..00000000000 --- a/app/assets/javascripts/repo/stores/actions.js +++ /dev/null @@ -1,146 +0,0 @@ -import Vue from 'vue'; -import { visitUrl } from '../../lib/utils/url_utility'; -import flash from '../../flash'; -import service from '../services'; -import * as types from './mutation_types'; - -export const redirectToUrl = (_, url) => visitUrl(url); - -export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); - -export const closeDiscardPopup = ({ commit }) => commit(types.TOGGLE_DISCARD_POPUP, false); - -export const discardAllChanges = ({ commit, getters, dispatch }) => { - const changedFiles = getters.changedFiles; - - changedFiles.forEach((file) => { - commit(types.DISCARD_FILE_CHANGES, file); - - if (file.tempFile) { - dispatch('closeFile', { file, force: true }); - } - }); -}; - -export const closeAllFiles = ({ state, dispatch }) => { - state.openFiles.forEach(file => dispatch('closeFile', { file })); -}; - -export const toggleEditMode = ({ state, commit, getters, dispatch }, force = false) => { - const changedFiles = getters.changedFiles; - - if (changedFiles.length && !force) { - commit(types.TOGGLE_DISCARD_POPUP, true); - } else { - commit(types.TOGGLE_EDIT_MODE); - commit(types.TOGGLE_DISCARD_POPUP, false); - dispatch('toggleBlobView'); - - if (!state.editMode) { - dispatch('discardAllChanges'); - } - } -}; - -export const toggleBlobView = ({ commit, state }) => { - if (state.editMode) { - commit(types.SET_EDIT_MODE); - } else { - commit(types.SET_PREVIEW_MODE); - } -}; - -export const checkCommitStatus = ({ state }) => service.getBranchData( - state.project.id, - state.currentBranch, -) - .then((data) => { - const { id } = data.commit; - - if (state.currentRef !== id) { - return true; - } - - return false; - }) - .catch(() => flash('Error checking branch data. Please try again.')); - -export const commitChanges = ({ commit, state, dispatch, getters }, { payload, newMr }) => - service.commit(state.project.id, payload) - .then((data) => { - const { branch } = payload; - if (!data.short_id) { - flash(data.message); - return; - } - - const lastCommit = { - commit_path: `${state.project.url}/commit/${data.id}`, - commit: { - message: data.message, - authored_date: data.committed_date, - }, - }; - - flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); - - if (newMr) { - dispatch('redirectToUrl', `${state.endpoints.newMergeRequestUrl}${branch}`); - } else { - commit(types.SET_COMMIT_REF, data.id); - - getters.changedFiles.forEach((entry) => { - commit(types.SET_LAST_COMMIT_DATA, { - entry, - lastCommit, - }); - }); - - dispatch('discardAllChanges'); - dispatch('closeAllFiles'); - dispatch('toggleEditMode'); - - window.scrollTo(0, 0); - } - }) - .catch(() => flash('Error committing changes. Please try again.')); - -export const createTempEntry = ({ state, dispatch }, { name, type, content = '', base64 = false }) => { - if (type === 'tree') { - dispatch('createTempTree', name); - } else if (type === 'blob') { - dispatch('createTempFile', { - tree: state, - name, - base64, - content, - }); - } -}; - -export const popHistoryState = ({ state, dispatch, getters }) => { - const treeList = getters.treeList; - const tree = treeList.find(file => file.url === state.previousUrl); - - if (!tree) return; - - if (tree.type === 'tree') { - dispatch('toggleTreeOpen', { endpoint: tree.url, tree }); - } -}; - -export const scrollToTab = () => { - Vue.nextTick(() => { - const tabs = document.getElementById('tabs'); - - if (tabs) { - const tabEl = tabs.querySelector('.active .repo-tab'); - - tabEl.focus(); - } - }); -}; - -export * from './actions/tree'; -export * from './actions/file'; -export * from './actions/branch'; diff --git a/app/assets/javascripts/repo/stores/actions/branch.js b/app/assets/javascripts/repo/stores/actions/branch.js deleted file mode 100644 index 61d9a5af3e3..00000000000 --- a/app/assets/javascripts/repo/stores/actions/branch.js +++ /dev/null @@ -1,20 +0,0 @@ -import service from '../../services'; -import * as types from '../mutation_types'; -import { pushState } from '../utils'; - -// eslint-disable-next-line import/prefer-default-export -export const createNewBranch = ({ state, commit }, branch) => service.createBranch( - state.project.id, - { - branch, - ref: state.currentBranch, - }, -).then(res => res.json()) -.then((data) => { - const branchName = data.name; - const url = location.href.replace(state.currentBranch, branchName); - - pushState(url); - - commit(types.SET_CURRENT_BRANCH, branchName); -}); diff --git a/app/assets/javascripts/repo/stores/actions/tree.js b/app/assets/javascripts/repo/stores/actions/tree.js deleted file mode 100644 index 7c251e26bed..00000000000 --- a/app/assets/javascripts/repo/stores/actions/tree.js +++ /dev/null @@ -1,163 +0,0 @@ -import { visitUrl } from '../../../lib/utils/url_utility'; -import { normalizeHeaders } from '../../../lib/utils/common_utils'; -import flash from '../../../flash'; -import service from '../../services'; -import * as types from '../mutation_types'; -import { - pushState, - setPageTitle, - findEntry, - createTemp, - createOrMergeEntry, -} from '../utils'; - -export const getTreeData = ( - { commit, state, dispatch }, - { endpoint = state.endpoints.rootEndpoint, tree = state } = {}, -) => { - commit(types.TOGGLE_LOADING, tree); - - service.getTreeData(endpoint) - .then((res) => { - const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - - setPageTitle(pageTitle); - - return res.json(); - }) - .then((data) => { - const prevLastCommitPath = tree.lastCommitPath; - if (!state.isInitialRoot) { - commit(types.SET_ROOT, data.path === '/'); - } - - dispatch('updateDirectoryData', { data, tree }); - commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); - commit(types.SET_LAST_COMMIT_URL, { tree, url: data.last_commit_path }); - commit(types.TOGGLE_LOADING, tree); - - if (prevLastCommitPath !== null) { - dispatch('getLastCommitData', tree); - } - - pushState(endpoint); - }) - .catch(() => { - flash('Error loading tree data. Please try again.'); - commit(types.TOGGLE_LOADING, tree); - }); -}; - -export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { - if (tree.opened) { - // send empty data to clear the tree - const data = { trees: [], blobs: [], submodules: [] }; - - pushState(tree.parentTreeUrl); - - commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl); - dispatch('updateDirectoryData', { data, tree }); - } else { - commit(types.SET_PREVIOUS_URL, endpoint); - dispatch('getTreeData', { endpoint, tree }); - } - - commit(types.TOGGLE_TREE_OPEN, tree); -}; - -export const clickedTreeRow = ({ commit, dispatch }, row) => { - if (row.type === 'tree') { - dispatch('toggleTreeOpen', { - endpoint: row.url, - tree: row, - }); - } else if (row.type === 'submodule') { - commit(types.TOGGLE_LOADING, row); - - visitUrl(row.url); - } else if (row.type === 'blob' && row.opened) { - dispatch('setFileActive', row); - } else { - dispatch('getFileData', row); - } -}; - -export const createTempTree = ({ state, commit, dispatch }, name) => { - let tree = state; - const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/'); - - dirNames.forEach((dirName) => { - const foundEntry = findEntry(tree, 'tree', dirName); - - if (!foundEntry) { - const tmpEntry = createTemp({ - name: dirName, - path: tree.path, - type: 'tree', - level: tree.level !== undefined ? tree.level + 1 : 0, - }); - - commit(types.CREATE_TMP_TREE, { - parent: tree, - tmpEntry, - }); - commit(types.TOGGLE_TREE_OPEN, tmpEntry); - - tree = tmpEntry; - } else { - tree = foundEntry; - } - }); - - if (tree.tempFile) { - dispatch('createTempFile', { - tree, - name: '.gitkeep', - }); - } -}; - -export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { - if (tree.lastCommitPath === null || getters.isCollapsed) return; - - service.getTreeLastCommit(tree.lastCommitPath) - .then((res) => { - const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; - - commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); - - return res.json(); - }) - .then((data) => { - data.forEach((lastCommit) => { - const entry = findEntry(tree, lastCommit.type, lastCommit.file_name); - - if (entry) { - commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); - } - }); - - dispatch('getLastCommitData', tree); - }) - .catch(() => flash('Error fetching log data.')); -}; - -export const updateDirectoryData = ({ commit, state }, { data, tree }) => { - const level = tree.level !== undefined ? tree.level + 1 : 0; - const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; - const createEntry = (entry, type) => createOrMergeEntry({ - tree, - entry, - level, - type, - parentTreeUrl, - }); - - const formattedData = [ - ...data.trees.map(t => createEntry(t, 'tree')), - ...data.submodules.map(m => createEntry(m, 'submodule')), - ...data.blobs.map(b => createEntry(b, 'blob')), - ]; - - commit(types.SET_DIRECTORY_DATA, { tree, data: formattedData }); -}; diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js deleted file mode 100644 index 5ce9f449905..00000000000 --- a/app/assets/javascripts/repo/stores/getters.js +++ /dev/null @@ -1,40 +0,0 @@ -import _ from 'underscore'; - -/* - Takes the multi-dimensional tree and returns a flattened array. - This allows for the table to recursively render the table rows but keeps the data - structure nested to make it easier to add new files/directories. -*/ -export const treeList = (state) => { - const mapTree = arr => (!arr.tree.length ? [] : _.map(arr.tree, a => [a, mapTree(a)])); - - return _.chain(state.tree) - .map(arr => [arr, mapTree(arr)]) - .flatten() - .value(); -}; - -export const changedFiles = state => state.openFiles.filter(file => file.changed); - -export const activeFile = state => state.openFiles.find(file => file.active); - -export const activeFileExtension = (state) => { - const file = activeFile(state); - return file ? `.${file.path.split('.').pop()}` : ''; -}; - -export const isCollapsed = state => !!state.openFiles.length; - -export const canEditFile = (state) => { - const currentActiveFile = activeFile(state); - const openedFiles = state.openFiles; - - return state.canCommit && - state.onTopOfBranch && - openedFiles.length && - (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); -}; - -export const addedFiles = state => changedFiles(state).filter(f => f.tempFile); - -export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile); diff --git a/app/assets/javascripts/repo/stores/mutations/branch.js b/app/assets/javascripts/repo/stores/mutations/branch.js deleted file mode 100644 index d8229e8a620..00000000000 --- a/app/assets/javascripts/repo/stores/mutations/branch.js +++ /dev/null @@ -1,9 +0,0 @@ -import * as types from '../mutation_types'; - -export default { - [types.SET_CURRENT_BRANCH](state, currentBranch) { - Object.assign(state, { - currentBranch, - }); - }, -}; diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index 130730b1700..d2f0d7410da 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -51,7 +51,10 @@ export default class Shortcuts { } onToggleHelp(e) { - e.preventDefault(); + if (e.preventDefault) { + e.preventDefault(); + } + Shortcuts.toggleHelp(this.enabledHelp); } @@ -112,6 +115,9 @@ export default class Shortcuts { static focusSearch(e) { $('#search').focus(); - e.preventDefault(); + + if (e.preventDefault) { + e.preventDefault(); + } } } diff --git a/app/assets/javascripts/users/activity_calendar.js b/app/assets/javascripts/users/activity_calendar.js index 4fa8c680580..0581239d5a5 100644 --- a/app/assets/javascripts/users/activity_calendar.js +++ b/app/assets/javascripts/users/activity_calendar.js @@ -1,7 +1,10 @@ import _ from 'underscore'; -import d3 from 'd3'; +import { scaleLinear, scaleThreshold } from 'd3-scale'; +import { select } from 'd3-selection'; import { getDayName, getDayDifference } from '../lib/utils/datetime_utility'; +const d3 = { select, scaleLinear, scaleThreshold }; + const LOADING_HTML = ` <div class="text-center"> <i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i> @@ -28,7 +31,7 @@ function formatTooltipText({ date, count }) { return `${contribText}<br />${dateDayName} ${dateText}`; } -const initColorKey = () => d3.scale.linear().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) { @@ -205,7 +208,7 @@ export default class ActivityCalendar { initColor() { const colorRange = ['#ededed', this.colorKey(0), this.colorKey(1), this.colorKey(2), this.colorKey(3)]; - return d3.scale.threshold().domain([0, 10, 20, 30]).range(colorRange); + return d3.scaleThreshold().domain([0, 10, 20, 30]).range(colorRange); } clickDay(stamp) { diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 9cb3edead86..8a9129c385b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -62,7 +62,7 @@ export default { return this.mr.hasCI; }, shouldRenderRelatedLinks() { - return !!this.mr.relatedLinks; + return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState; }, shouldRenderDeployments() { return this.mr.deployments.length; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 7c15abfff10..2bace3311c8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -1,30 +1,32 @@ +import { stateKey } from './state_maps'; + export default function deviseState(data) { if (data.project_archived) { - return 'archived'; + return stateKey.archived; } else if (data.branch_missing) { - return 'missingBranch'; + return stateKey.missingBranch; } else if (!data.commits_count) { - return 'nothingToMerge'; + return stateKey.nothingToMerge; } else if (this.mergeStatus === 'unchecked') { - return 'checking'; + return stateKey.checking; } else if (data.has_conflicts) { - return 'conflicts'; + return stateKey.conflicts; } else if (data.work_in_progress) { - return 'workInProgress'; + return stateKey.workInProgress; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { - return 'pipelineFailed'; + return stateKey.pipelineFailed; } else if (this.hasMergeableDiscussionsState) { - return 'unresolvedDiscussions'; + return stateKey.unresolvedDiscussions; } else if (this.isPipelineBlocked) { - return 'pipelineBlocked'; + return stateKey.pipelineBlocked; } else if (this.hasSHAChanged) { - return 'shaMismatch'; + return stateKey.shaMismatch; } else if (this.mergeWhenPipelineSucceeds) { - return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds'; + return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; } else if (!this.canMerge) { - return 'notAllowedToMerge'; + return stateKey.notAllowedToMerge; } else if (this.canBeMerged) { - return 'readyToMerge'; + return stateKey.readyToMerge; } return null; } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 707766e08e4..93d31a2a684 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,5 +1,6 @@ import Timeago from 'timeago.js'; import { getStateKey } from '../dependencies'; +import { stateKey } from './state_maps'; import { formatDate } from '../../lib/utils/datetime_utility'; export default class MergeRequestStore { @@ -120,6 +121,10 @@ export default class MergeRequestStore { } } + get isNothingToMergeState() { + return this.state === stateKey.nothingToMerge; + } + static getEventObject(event) { return { author: MergeRequestStore.getAuthorObject(event), diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 9074a064a6d..de980c175fb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -31,6 +31,23 @@ const statesToShowHelpWidget = [ 'autoMergeFailed', ]; +export const stateKey = { + archived: 'archived', + missingBranch: 'missingBranch', + nothingToMerge: 'nothingToMerge', + checking: 'checking', + conflicts: 'conflicts', + workInProgress: 'workInProgress', + pipelineFailed: 'pipelineFailed', + unresolvedDiscussions: 'unresolvedDiscussions', + pipelineBlocked: 'pipelineBlocked', + shaMismatch: 'shaMismatch', + autoMergeFailed: 'autoMergeFailed', + mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds', + notAllowedToMerge: 'notAllowedToMerge', + readyToMerge: 'readyToMerge', +}; + export default { stateToComponentMap, statesToShowHelpWidget, diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 6c575d8eb49..36d2d1dc164 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -72,7 +72,9 @@ Preview </a> </li> - <li class="md-header-toolbar"> + <li + class="md-header-toolbar" + :class="{ active: !previewMarkdown }"> <toolbar-button tag="**" button-title="Add bold text" diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue new file mode 100644 index 00000000000..dce23bd65f6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue @@ -0,0 +1,103 @@ +<script> + +/* This is a re-usable vue component for rendering a project avatar that + does not need to link to the project's profile. The image and an optional + tooltip can be configured by props passed to this component. + + Sample configuration: + + <project-avatar-image + :lazy="true" + :img-src="projectAvatarSrc" + :img-alt="tooltipText" + :tooltip-text="tooltipText" + tooltip-placement="top" + /> + +*/ + +import defaultAvatarUrl from 'images/no_avatar.png'; +import { placeholderImage } from '../../../lazy_loader'; +import tooltip from '../../directives/tooltip'; + +export default { + name: 'ProjectAvatarImage', + props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, + imgSrc: { + type: String, + required: false, + default: defaultAvatarUrl, + }, + cssClasses: { + type: String, + required: false, + default: '', + }, + imgAlt: { + type: String, + required: false, + default: 'project avatar', + }, + size: { + type: Number, + required: false, + default: 20, + }, + tooltipText: { + type: String, + required: false, + default: '', + }, + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + }, + directives: { + tooltip, + }, + computed: { + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside project avatar link. + // In both cases we should render the defaultAvatarUrl + sanitizedSource() { + return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + }, + resultantSrcAttribute() { + return this.lazy ? placeholderImage : this.sanitizedSource; + }, + tooltipContainer() { + return this.tooltipText ? 'body' : null; + }, + avatarSizeClass() { + return `s${this.size}`; + }, + }, +}; +</script> + +<template> + <img + v-tooltip + class="avatar" + :class="{ + lazy, + [avatarSizeClass]: true, + [cssClasses]: true + }" + :src="resultantSrcAttribute" + :width="size" + :height="size" + :alt="imgAlt" + :data-src="sanitizedSource" + :data-container="tooltipContainer" + :data-placement="tooltipPlacement" + :title="tooltipText" + /> +</template> diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual-sidebar.scss index 8baf7ca23a4..5da06b90113 100644 --- a/app/assets/stylesheets/framework/contextual-sidebar.scss +++ b/app/assets/stylesheets/framework/contextual-sidebar.scss @@ -9,12 +9,6 @@ padding-left: $contextual-sidebar-width; } - // Override position: absolute - .right-sidebar { - position: fixed; - height: calc(100% - #{$header-height}); - } - .issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header { padding: 10px 0 15px; } @@ -29,7 +23,6 @@ .context-header { position: relative; margin-right: 2px; - width: $contextual-sidebar-width; a { transition: padding $sidebar-transition-duration; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 478269f3fcf..bc907a390d8 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -16,27 +16,18 @@ @mixin set-visible { transform: translateY(0); - visibility: visible; - opacity: 1; - transition-duration: 100ms, 150ms, 25ms; - transition-delay: 35ms, 50ms, 25ms; + display: block; } @mixin set-invisible { transform: translateY(-10px); - visibility: hidden; - opacity: 0; - transition-property: opacity, transform, visibility; - transition-duration: 70ms, 250ms, 250ms; - transition-timing-function: linear, $dropdown-animation-timing; - transition-delay: 25ms, 50ms, 0ms; + display: none; } .open { .dropdown-menu, .dropdown-menu-nav { @include set-visible; - display: block; min-height: 40px; @media (max-width: $screen-xs-max) { @@ -55,6 +46,11 @@ } } +// Get search dropdown to line up with other nav dropdowns +.search-input-container .dropdown-menu { + margin-top: 11px; +} + .dropdown-toggle { padding: 6px 8px 6px 10px; background-color: $white-light; @@ -214,7 +210,6 @@ .dropdown-menu, .dropdown-menu-nav { @include set-invisible; - display: block; position: absolute; width: auto; top: 100%; diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 5389eb0a5f2..6b07ffdbd61 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -74,7 +74,7 @@ } .md-header-tab { - @media(max-width: $screen-xs-max) { + @media (max-width: $screen-xs-max) { flex: 1; width: 100%; border-bottom: 1px solid $border-color; @@ -82,16 +82,23 @@ } } -.md-header-toolbar { - margin-left: auto; +.nav-links { + li.md-header-toolbar { + margin-left: auto; + display: none; - @media(max-width: $screen-xs-max) { - flex: none; - display: flex; - justify-content: center; - width: 100%; - padding-top: $gl-padding-top; - padding-bottom: $gl-padding-top; + &.active { + display: block; + + @media (max-width: $screen-xs-max) { + flex: none; + display: flex; + justify-content: center; + width: 100%; + padding-top: $gl-padding-top; + padding-bottom: $gl-padding-top; + } + } } } @@ -175,7 +182,7 @@ margin-left: $gl-padding; margin-right: -5px; - @media(max-width: $screen-xs-max) { + @media (max-width: $screen-xs-max) { margin-left: 0; margin-right: 0; } @@ -239,7 +246,7 @@ } } -@media(max-width: $screen-xs-max) { +@media (max-width: $screen-xs-max) { .atwho-view-ul { width: 350px; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 0742c0a2a09..d61809cb0a4 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -90,11 +90,6 @@ .right-sidebar { border-left: 1px solid $border-color; height: calc(100% - #{$header-height}); - - &.affix { - position: fixed; - top: $header-height; - } } .with-performance-bar .right-sidebar.affix { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index b84d6c140be..1d6c7a5c472 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -219,6 +219,7 @@ $gl-input-padding: 10px; $gl-vert-padding: 6px; $gl-padding-top: 10px; $gl-sidebar-padding: 22px; +$gl-bar-padding: 3px; /* * Misc diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e19196e0c41..e1637618ab2 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -122,7 +122,7 @@ } .right-sidebar { - position: absolute; + position: fixed; top: $header-height; bottom: 0; right: 0; @@ -502,7 +502,7 @@ top: $header-height + $performance-bar-height; .issuable-sidebar { - height: calc(100% - #{$header-height} - #{$performance-bar-height}); + height: calc(100% - #{$performance-bar-height}); } } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 6eb92c7baee..da3c2d7fa5d 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -22,9 +22,10 @@ } } -.multi-file { +.ide-view { display: flex; - height: calc(100vh - 145px); + height: calc(100vh - #{$header-height}); + color: $almost-black; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; @@ -35,12 +36,47 @@ } } +.with-performance-bar .ide-view { + height: calc(100vh - #{$header-height}); +} + .ide-file-list { flex: 1; - overflow: scroll; .file { cursor: pointer; + + &.file-open { + background: $white-normal; + } + + .repo-file-name { + white-space: nowrap; + text-overflow: ellipsis; + } + + .unsaved-icon { + color: $indigo-700; + float: right; + font-size: smaller; + line-height: 20px; + } + + .repo-new-btn { + display: none; + margin-top: -4px; + margin-bottom: -4px; + } + + &:hover { + .repo-new-btn { + display: block; + } + + .unsaved-icon { + display: none; + } + } } a { @@ -55,10 +91,9 @@ .multi-file-table-name, .multi-file-table-col-commit-message { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + overflow: visible; max-width: 0; + padding: 6px 12px; } .multi-file-table-name { @@ -66,6 +101,7 @@ } .multi-file-table-col-commit-message { + white-space: nowrap; width: 50%; } @@ -79,7 +115,7 @@ .multi-file-tabs { display: flex; - overflow: scroll; + overflow-x: auto; background-color: $white-normal; box-shadow: inset 0 -1px $white-dark; @@ -128,9 +164,38 @@ height: 0; } +.blob-editor-container { + flex: 1; + height: 0; + display: flex; + flex-direction: column; + justify-content: center; + + .vertical-center { + min-height: auto; + } +} + +.multi-file-editor-holder { + height: 100%; +} + .multi-file-editor-btn-group { - padding: $grid-size; + padding: $gl-bar-padding $gl-padding; border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + background: $white-light; +} + +.ide-status-bar { + padding: $gl-bar-padding $gl-padding; + background: $white-light; + display: flex; + justify-content: space-between; + + svg { + vertical-align: middle; + } } // Not great, but this is to deal with our current output @@ -138,10 +203,6 @@ height: 100%; overflow: scroll; - .blob-viewer { - height: 100%; - } - .file-content.code { display: flex; @@ -162,18 +223,101 @@ } } +.file-content.blob-no-preview { + a { + margin-left: auto; + margin-right: auto; + } +} + .multi-file-commit-panel { display: flex; flex-direction: column; height: 100%; width: 290px; - padding: $gl-padding; + padding: 0; background-color: $gray-light; border-left: 1px solid $white-dark; + .projects-sidebar { + display: flex; + flex-direction: column; + } + + .multi-file-commit-panel-inner { + display: flex; + flex: 1; + flex-direction: column; + } + + .multi-file-commit-panel-inner-scroll { + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + } + &.is-collapsed { width: 60px; - padding: 0; + + .multi-file-commit-list { + padding-top: $gl-padding; + overflow: hidden; + } + + .multi-file-context-bar-icon { + align-items: center; + + svg { + float: none; + margin: 0; + } + } + } + + .branch-container { + border-left: 4px solid $indigo-700; + margin-bottom: $gl-bar-padding; + } + + .branch-header { + background: $white-dark; + display: flex; + } + + .branch-header-title { + flex: 1; + padding: $grid-size $gl-padding; + color: $indigo-700; + font-weight: $gl-font-weight-bold; + + svg { + vertical-align: middle; + } + } + + .branch-header-btns { + padding: $gl-vert-padding $gl-padding; + } + + .left-collapse-btn { + display: none; + background: $gray-light; + text-align: left; + border-top: 1px solid $white-dark; + + svg { + vertical-align: middle; + } + } +} + +.multi-file-context-bar-icon { + padding: 10px; + + svg { + margin-right: 10px; + float: left; } } @@ -186,9 +330,9 @@ .multi-file-commit-panel-header { display: flex; align-items: center; - padding: 0 0 12px; margin-bottom: 12px; border-bottom: 1px solid $white-dark; + padding: $gl-btn-padding 0; &.is-collapsed { border-bottom: 1px solid $white-dark; @@ -197,23 +341,33 @@ 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-collapse-btn { - padding-top: 0; - padding-bottom: 0; - margin-left: auto; - font-size: 20px; +.multi-file-commit-panel-header-title { + display: flex; + flex: 1; + padding: $gl-btn-padding; - &.is-collapsed { - margin-right: auto; + svg { + margin-right: $gl-btn-padding; } } +.multi-file-commit-panel-collapse-btn { + border-left: 1px solid $white-dark; +} + .multi-file-commit-list { flex: 1; - overflow: scroll; + overflow: auto; + padding: $gl-padding; } .multi-file-commit-list-item { @@ -244,7 +398,7 @@ } .multi-file-commit-form { - padding-top: 12px; + padding: $gl-padding; border-top: 1px solid $white-dark; } @@ -295,3 +449,40 @@ } } } + +.ide-loading { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-empty-state { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.repo-new-btn { + .dropdown-toggle svg { + margin-top: -2px; + margin-bottom: 2px; + } + + .dropdown-menu { + left: auto; + right: 0; + + label { + font-weight: $gl-font-weight-normal; + padding: 5px 8px; + margin-bottom: 0; + } + } +} + +.ide-flash-container.flash-container { + margin-top: $header-height; + margin-bottom: 0; +} diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 49c8e546bf2..c9363188505 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -108,13 +108,6 @@ input[type="checkbox"]:hover { // Custom dropdown positioning .dropdown-menu { - transition-property: opacity, transform; - transition-duration: 250ms, 250ms; - transition-delay: 0ms, 25ms; - transition-timing-function: $dropdown-animation-timing; - transform: translateY(0); - opacity: 0; - display: block; left: -5px; } @@ -152,13 +145,6 @@ input[type="checkbox"]:hover { background-color: $nav-badge-bg; border-color: $border-color; } - - .dropdown-menu { - transition-duration: 100ms, 75ms; - transition-delay: 75ms, 100ms; - transform: translateY(7px); - opacity: 1; - } } &.has-value { diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss index cede147d559..8e2c42c1bd3 100644 --- a/app/assets/stylesheets/pages/stat_graph.scss +++ b/app/assets/stylesheets/pages/stat_graph.scss @@ -10,7 +10,6 @@ } .axis { - fill: $stat-graph-axis-fill; font-size: 10px; } @@ -54,9 +53,7 @@ } .selection rect { - fill: $stat-graph-selection-fill; fill-opacity: 0.1; - stroke: $stat-graph-selection-stroke; stroke-width: 1px; stroke-opacity: 0.4; shape-rendering: crispedges; diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index cde1e284d2d..86bade49ec9 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -8,12 +8,12 @@ class AutocompleteController < ApplicationController def users @users = AutocompleteUsersFinder.new(params: params, current_user: current_user, project: @project, group: @group).execute - render json: @users, only: [:name, :username, :id], methods: [:avatar_url] + render json: UserSerializer.new.represent(@users) end def user @user = User.find(params[:id]) - render json: @user, only: [:name, :username, :id], methods: [:avatar_url] + render json: UserSerializer.new.represent(@user) end def projects diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb new file mode 100644 index 00000000000..1ff25a45398 --- /dev/null +++ b/app/controllers/ide_controller.rb @@ -0,0 +1,6 @@ +class IdeController < ApplicationController + layout 'nav_only' + + def index + end +end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 770381472c5..d838b8dc29e 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -5,9 +5,6 @@ class Projects::BlobController < Projects::ApplicationController include RendersBlob include ActionView::Helpers::SanitizeHelper - # Raised when given an invalid file path - InvalidPathError = Class.new(StandardError) - prepend_before_action :authenticate_user!, only: [:edit] before_action :require_non_empty_project, except: [:new, :create] @@ -61,7 +58,6 @@ class Projects::BlobController < Projects::ApplicationController create_commit(Files::UpdateService, success_path: -> { after_edit_path }, failure_view: :edit, failure_path: project_blob_path(@project, @id)) - rescue Files::UpdateService::FileChangedError @conflict = true render :edit @@ -132,7 +128,6 @@ class Projects::BlobController < Projects::ApplicationController def assign_blob_vars @id = params[:id] @ref, @path = extract_ref(@id) - rescue InvalidPathError render_404 end diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb index b64f7a2a6bd..d3b9d8a9bbc 100644 --- a/app/controllers/projects/clusters/gcp_controller.rb +++ b/app/controllers/projects/clusters/gcp_controller.rb @@ -39,6 +39,7 @@ class Projects::Clusters::GcpController < Projects::ApplicationController params.require(:cluster).permit( :enabled, :name, + :environment_scope, provider_gcp_attributes: [ :gcp_project_id, :zone, diff --git a/app/controllers/projects/clusters/user_controller.rb b/app/controllers/projects/clusters/user_controller.rb index d7678512073..d0db64b2fa9 100644 --- a/app/controllers/projects/clusters/user_controller.rb +++ b/app/controllers/projects/clusters/user_controller.rb @@ -26,6 +26,7 @@ class Projects::Clusters::UserController < Projects::ApplicationController params.require(:cluster).permit( :enabled, :name, + :environment_scope, platform_kubernetes_attributes: [ :namespace, :api_url, diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 4a7879db313..1dc7f1b3a7f 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -87,6 +87,7 @@ class Projects::ClustersController < Projects::ApplicationController if cluster.managed? params.require(:cluster).permit( :enabled, + :environment_scope, platform_kubernetes_attributes: [ :namespace ] @@ -95,6 +96,7 @@ class Projects::ClustersController < Projects::ApplicationController params.require(:cluster).permit( :enabled, :name, + :environment_scope, platform_kubernetes_attributes: [ :api_url, :token, diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 7ad7b3003af..e146d0d3cd5 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -29,6 +29,8 @@ class Projects::PipelinesController < Projects::ApplicationController @pipelines_count = PipelinesFinder .new(project).execute.count + @pipelines.map(&:commit) # List commits for batch loading + respond_to do |format| format.html format.json do diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4754a67450f..d13407a06c8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -306,7 +306,7 @@ module ApplicationHelper cookies["sidebar_collapsed"] == "true" end - def show_new_repo? + def show_new_ide? cookies["new_repo"] == "true" && body_data_page != 'projects:show' end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 556ed233ccf..3c2ee2cb5bc 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -8,7 +8,7 @@ module BlobHelper %w(credits changelog news copying copyright license authors) end - def edit_path(project = @project, ref = @ref, path = @path, options = {}) + def edit_blob_path(project = @project, ref = @ref, path = @path, options = {}) project_edit_blob_path(project, tree_join(ref, path), options[:link_opts]) @@ -26,10 +26,10 @@ module BlobHelper button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' } # This condition applies to anonymous or users who can edit directly elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) - link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" + link_to 'Edit', edit_blob_path(project, ref, path, options), class: "#{common_classes} btn-sm" elsif current_user && can?(current_user, :fork_project, project) continue_params = { - to: edit_path(project, ref, path, options), + to: edit_blob_path(project, ref, path, options), notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } @@ -41,6 +41,43 @@ module BlobHelper end end + def ide_edit_path(project = @project, ref = @ref, path = @path, options = {}) + "#{ide_path}/project#{edit_blob_path(project, ref, path, options)}" + end + + def ide_edit_text + "#{_('Multi Edit')} <span class='label label-primary'>#{_('Beta')}</span>".html_safe + end + + def ide_blob_link(project = @project, ref = @ref, path = @path, options = {}) + return unless show_new_ide? + + blob = options.delete(:blob) + blob ||= project.repository.blob_at(ref, path) rescue nil + + return unless blob && blob.readable_text? + + common_classes = "btn js-edit-ide #{options[:extra_class]}" + + if !on_top_of_branch?(project, ref) + button_tag ide_edit_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' } + # This condition applies to anonymous or users who can edit directly + elsif current_user && can_modify_blob?(blob, project, ref) + link_to ide_edit_text, ide_edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" + elsif current_user && can?(current_user, :fork_project, project) + continue_params = { + to: ide_edit_path(project, ref, path, options), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now + } + fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params) + + button_tag ide_edit_text, + class: common_classes, + data: { fork_path: fork_path } + end + end + def modify_file_link(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:) return unless current_user diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb new file mode 100644 index 00000000000..7e4eb06b99d --- /dev/null +++ b/app/helpers/clusters_helper.rb @@ -0,0 +1,5 @@ +module ClustersHelper + def has_multiple_clusters?(project) + false + end +end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index b5dece38de1..e26ce6da030 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -35,7 +35,7 @@ module FormHelper multi_select: true, 'input-meta': 'name', 'always-show-selectbox': true, - current_user_info: current_user.to_json(only: [:id, :name]) + current_user_info: UserSerializer.new.represent(current_user) } } end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index b4ca0e68e0b..2668cf78afe 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -362,7 +362,7 @@ module IssuablesHelper moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), editable: can_edit_issuable, - currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url), + currentUser: UserSerializer.new.represent(current_user), rootPath: root_path, fullPath: @project.full_path } diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 2f57660516d..0f9ac958f95 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -139,7 +139,7 @@ module SearchHelper id: "filtered-search-#{type}", placeholder: 'Search or filter results...', data: { - 'username-params' => @users.to_json(only: [:id, :username]) + 'username-params' => UserSerializer.new.represent(@users) }, autocomplete: 'off' } diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index b05eb93b465..36a311dfa8a 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -43,14 +43,20 @@ module SortingHelper end def groups_sort_options_hash - options = { + { + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, sort_value_recently_updated => sort_title_recently_updated, sort_value_oldest_updated => sort_title_oldest_updated } + end - options + def admin_groups_sort_options_hash + groups_sort_options_hash.merge( + sort_value_largest_group => sort_title_largest_group + ) end def member_sort_options_hash diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb index a8d9be945dc..cc4950240af 100644 --- a/app/models/blob_viewer/dependency_manager.rb +++ b/app/models/blob_viewer/dependency_manager.rb @@ -27,10 +27,17 @@ module BlobViewer private - def package_name_from_json(key) - prepare! + def json_data + @json_data ||= begin + prepare! + JSON.parse(blob.data) + rescue + {} + end + end - JSON.parse(blob.data)[key] rescue nil + def package_name_from_json(key) + json_data[key] end def package_name_from_method_call(name) diff --git a/app/models/blob_viewer/package_json.rb b/app/models/blob_viewer/package_json.rb index 09221efb56c..46cd2f04f4d 100644 --- a/app/models/blob_viewer/package_json.rb +++ b/app/models/blob_viewer/package_json.rb @@ -16,7 +16,25 @@ module BlobViewer @package_name ||= package_name_from_json('name') end + def package_type + private? ? 'private package' : super + end + def package_url + private? ? homepage : npm_url + end + + private + + def private? + !!json_data['private'] + end + + def homepage + json_data['homepage'] + end + + def npm_url "https://www.npmjs.com/package/#{package_name}" end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 28f154581a9..d4690da3be6 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -287,8 +287,12 @@ module Ci Ci::Pipeline.truncate_sha(sha) end + # NOTE: This is loaded lazily and will never be nil, even if the commit + # cannot be found. + # + # Use constructs like: `pipeline.commit.present?` def commit - @commit ||= project.commit_by(oid: sha) + @commit ||= Commit.lazy(project, sha) end def branch? @@ -338,12 +342,9 @@ module Ci end def latest? - return false unless ref - - commit = project.commit(ref) - return false unless commit + return false unless ref && commit.present? - commit.sha == sha + project.commit(ref) == commit end def retried diff --git a/app/models/commit.rb b/app/models/commit.rb index 13c31111134..2be07ca7d3c 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -86,6 +86,20 @@ class Commit def valid_hash?(key) !!(/\A#{COMMIT_SHA_PATTERN}\z/ =~ key) end + + def lazy(project, oid) + BatchLoader.for({ project: project, oid: oid }).batch do |items, loader| + items_by_project = items.group_by { |i| i[:project] } + + items_by_project.each do |project, commit_ids| + oids = commit_ids.map { |i| i[:oid] } + + project.repository.commits_by(oids: oids).each do |commit| + loader.call({ project: commit.project, oid: commit.id }, commit) if commit + end + end + end + end end attr_accessor :raw @@ -103,7 +117,7 @@ class Commit end def ==(other) - (self.class === other) && (raw == other.raw) + other.is_a?(self.class) && raw == other.raw end def self.reference_prefix @@ -224,8 +238,8 @@ class Commit notes.includes(:author) end - def method_missing(m, *args, &block) - @raw.__send__(m, *args, &block) # rubocop:disable GitlabSecurity/PublicSend + def method_missing(method, *args, &block) + @raw.__send__(method, *args, &block) # rubocop:disable GitlabSecurity/PublicSend end def respond_to_missing?(method, include_private = false) diff --git a/app/models/concerns/blocks_json_serialization.rb b/app/models/concerns/blocks_json_serialization.rb new file mode 100644 index 00000000000..8019e6adc1c --- /dev/null +++ b/app/models/concerns/blocks_json_serialization.rb @@ -0,0 +1,16 @@ +# Overrides `as_json` and `to_json` to raise an exception when called in order +# to prevent accidentally exposing attributes +# +# Not that that would ever happen... but just in case. +module BlocksJsonSerialization + extend ActiveSupport::Concern + + JsonSerializationError = Class.new(StandardError) + + def to_json(*) + raise JsonSerializationError, + "JSON serialization has been disabled on #{self.class.name}" + end + + alias_method :as_json, :to_json +end diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index 89fe6527647..5911b56c34c 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -24,7 +24,7 @@ module TimeTrackable # rubocop:disable Gitlab/ModuleWithInstanceVariables def spend_time(options) @time_spent = options[:duration] - @time_spent_user = options[:user] + @time_spent_user = User.find(options[:user_id]) @spent_at = options[:spent_at] @original_total_time_spent = nil diff --git a/app/models/identity.rb b/app/models/identity.rb index 99d99bc6deb..b3fa7d8176a 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -8,6 +8,8 @@ class Identity < ActiveRecord::Base validates :extern_uid, allow_blank: true, uniqueness: { scope: :provider, case_sensitive: false } validates :user_id, uniqueness: { scope: :provider } + before_save :ensure_normalized_extern_uid, if: :extern_uid_changed? + scope :with_provider, ->(provider) { where(provider: provider) } scope :with_extern_uid, ->(provider, extern_uid) do iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider) @@ -24,4 +26,12 @@ class Identity < ActiveRecord::Base uid.to_s end end + + private + + def ensure_normalized_extern_uid + return if extern_uid.nil? + + self.extern_uid = Identity.normalize_uid(self.provider, self.extern_uid) + end end diff --git a/app/models/project.rb b/app/models/project.rb index 5183a216c53..3440c01b356 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1148,7 +1148,7 @@ class Project < ActiveRecord::Base def change_head(branch) if repository.branch_exists?(branch) repository.before_change_head - repository.write_ref('HEAD', "refs/heads/#{branch}", force: true) + repository.write_ref('HEAD', "refs/heads/#{branch}") repository.copy_gitattributes(branch) repository.after_change_head reload_default_branch diff --git a/app/models/repository.rb b/app/models/repository.rb index 4ec8ec9c8b2..a34f5e5439b 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -19,7 +19,6 @@ class Repository attr_accessor :full_path, :disk_path, :project, :is_wiki delegate :ref_name_for_sha, to: :raw_repository - delegate :write_ref, to: :raw_repository CreateTreeError = Class.new(StandardError) @@ -118,6 +117,18 @@ class Repository @commit_cache[oid] = find_commit(oid) end + def commits_by(oids:) + return [] unless oids.present? + + commits = Gitlab::Git::Commit.batch_by_oid(raw_repository, oids) + + if commits.present? + Commit.decorate(commits, @project) + else + [] + end + end + def commits(ref, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil) options = { repo: raw_repository, @@ -244,10 +255,11 @@ class Repository # This will still fail if the file is corrupted (e.g. 0 bytes) begin - write_ref(keep_around_ref_name(sha), sha, force: true) - rescue Gitlab::Git::Repository::GitError => ex - # Necessary because https://gitlab.com/gitlab-org/gitlab-ce/issues/20156 - return true if ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/ + write_ref(keep_around_ref_name(sha), sha) + rescue Rugged::ReferenceError => ex + Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}" + rescue Rugged::OSError => ex + raise unless ex.message =~ /Failed to create locked file/ && ex.message =~ /File exists/ Rails.logger.error "Unable to create #{REF_KEEP_AROUND} reference for repository #{path}: #{ex}" end @@ -257,6 +269,10 @@ class Repository ref_exists?(keep_around_ref_name(sha)) end + def write_ref(ref_path, sha) + rugged.references.create(ref_path, sha, force: true) + end + def diverging_commit_counts(branch) root_ref_hash = raw_repository.commit(root_ref).id cache.fetch(:"diverging_commit_counts_#{branch.name}") do @@ -1003,7 +1019,7 @@ class Repository end def create_ref(ref, ref_path) - write_ref(ref_path, ref) + raw_repository.write_ref(ref_path, ref) end def ls_files(ref) diff --git a/app/models/user.rb b/app/models/user.rb index 51941f43919..b52f17cd6a8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,7 @@ class User < ActiveRecord::Base include CreatedAtFilterable include IgnorableColumn include BulkMemberAccessLoad + include BlocksJsonSerialization DEFAULT_NOTIFICATION_LEVEL = :participating diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb index 38231f66009..8d4b9f14780 100644 --- a/app/services/files/base_service.rb +++ b/app/services/files/base_service.rb @@ -1,11 +1,14 @@ module Files class BaseService < Commits::CreateService + FileChangedError = Class.new(StandardError) + def initialize(*args) super @author_email = params[:author_email] @author_name = params[:author_name] @commit_message = params[:commit_message] + @last_commit_sha = params[:last_commit_sha] @file_path = params[:file_path] @previous_path = params[:previous_path] @@ -13,5 +16,16 @@ module Files @file_content = params[:file_content] @file_content = Base64.decode64(@file_content) if params[:file_content_encoding] == 'base64' end + + def file_has_changed?(path, commit_id) + return false unless commit_id + + last_commit = Gitlab::Git::Commit + .last_for_path(@start_project.repository, @start_branch, path) + + return false unless last_commit + + last_commit.sha != commit_id + end end end diff --git a/app/services/files/delete_service.rb b/app/services/files/delete_service.rb index 7952e5c95d4..32a57484d4e 100644 --- a/app/services/files/delete_service.rb +++ b/app/services/files/delete_service.rb @@ -11,5 +11,15 @@ module Files start_project: @start_project, start_branch_name: @start_branch) end + + private + + def validate! + super + + if file_has_changed?(@file_path, @last_commit_sha) + raise FileChangedError, "You are attempting to delete a file that has been previously updated." + end + end end end diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb index bfacc462847..98a3e83c130 100644 --- a/app/services/files/multi_service.rb +++ b/app/services/files/multi_service.rb @@ -1,5 +1,7 @@ module Files class MultiService < Files::BaseService + UPDATE_FILE_ACTIONS = %w(update move delete).freeze + def create_commit! repository.multi_action( user: current_user, @@ -20,6 +22,7 @@ module Files params[:actions].each do |action| validate_action!(action) + validate_file_status!(action) end end @@ -28,5 +31,15 @@ module Files raise_error("Unknown action '#{action[:action]}'") end end + + def validate_file_status!(action) + return unless UPDATE_FILE_ACTIONS.include?(action[:action]) + + file_path = action[:previous_path] || action[:file_path] + + if file_has_changed?(file_path, action[:last_commit_id]) + raise_error("The file has changed since you started editing it: #{file_path}") + end + end end end diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb index bcca1386bed..1902d1cea72 100644 --- a/app/services/files/update_service.rb +++ b/app/services/files/update_service.rb @@ -1,13 +1,5 @@ module Files class UpdateService < Files::BaseService - FileChangedError = Class.new(StandardError) - - def initialize(*args) - super - - @last_commit_sha = params[:last_commit_sha] - end - def create_commit! repository.update_file(current_user, @file_path, @file_content, message: @commit_message, @@ -21,21 +13,10 @@ module Files private - def file_has_changed? - return false unless @last_commit_sha && last_commit - - @last_commit_sha != last_commit.sha - end - - def last_commit - @last_commit ||= Gitlab::Git::Commit - .last_for_path(@start_project.repository, @start_branch, @file_path) - end - def validate! super - if file_has_changed? + if file_has_changed?(@file_path, @last_commit_sha) raise FileChangedError, "You are attempting to update a file that has changed since you started editing it." end end diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index c499f384426..842fe4e09c4 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -5,7 +5,7 @@ module Projects if fork_source = @project.fork_source fork_source.lfs_objects.find_each do |lfs_object| - lfs_object.projects << @project + lfs_object.projects << @project unless lfs_object.projects.include?(@project) end refresh_forks_count(fork_source) diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index 06ac86cd5a9..669c1ba0a22 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -405,7 +405,7 @@ module QuickActions if time_spent @updates[:spend_time] = { duration: time_spent, - user: current_user, + user_id: current_user.id, spent_at: time_spent_date } end @@ -428,7 +428,7 @@ module QuickActions current_user.can?(:"admin_#{issuable.to_ability_name}", project) end command :remove_time_spent do - @updates[:spend_time] = { duration: :reset, user: current_user } + @updates[:spend_time] = { duration: :reset, user_id: current_user.id } end desc "Append the comment with #{SHRUG}" diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index 535251fef5e..25946ba6eaf 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -11,23 +11,7 @@ .search-field-holder = search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name' = icon("search", class: "search-icon") - .dropdown - - toggle_text = @sort.present? ? sort_options_hash[@sort] : sort_title_recently_created - = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) - %ul.dropdown-menu.dropdown-menu-align-right - %li.dropdown-header - Sort by - %li - = link_to admin_groups_path(sort: sort_value_recently_created, name: project_name) do - = sort_title_recently_created - = link_to admin_groups_path(sort: sort_value_oldest_created, name: project_name) do - = sort_title_oldest_created - = link_to admin_groups_path(sort: sort_value_recently_updated, name: project_name) do - = sort_title_recently_updated - = link_to admin_groups_path(sort: sort_value_oldest_updated, name: project_name) do - = sort_title_oldest_updated - = link_to admin_groups_path(sort: sort_value_largest_group, name: project_name) do - = sort_title_largest_group + = render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash = link_to new_admin_group_path, class: "btn btn-new" do New group %ul.content-list diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 9a763887b30..f85f5c5be88 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -7,7 +7,8 @@ %span.pushed #{event.action_name} #{event.ref_type} %strong - commits_link = project_commits_path(project, event.ref_name) - = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link, class: 'ref-name' + - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name) + = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name' = render "events/event_scope", event: event diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml index 021de4f0caf..b8692009225 100644 --- a/app/views/help/index.html.haml +++ b/app/views/help/index.html.haml @@ -1,3 +1,5 @@ += webpack_bundle_tag 'docs' + %div - if current_application_settings.help_page_text.present? = markdown_field(current_application_settings, :help_page_text) @@ -37,8 +39,12 @@ Quick help %ul.well-list %li= link_to 'See our website for getting help', support_url - %li= link_to 'Use the search bar on the top of this page', '#', onclick: 'Shortcuts.focusSearch(event)' - %li= link_to 'Use shortcuts', '#', onclick: 'Shortcuts.toggleHelp()' + %li + %button.btn-blank.btn-link.js-trigger-search-bar{ type: 'button' } + Use the search bar on the top of this page + %li + %button.btn-blank.btn-link.js-trigger-shortcut{ type: 'button' } + Use shortcuts - unless current_application_settings.help_page_hide_commercial_content? %li= link_to 'Get a support subscription', 'https://about.gitlab.com/pricing/' %li= link_to 'Compare GitLab editions', 'https://about.gitlab.com/features/#compare' diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml new file mode 100644 index 00000000000..8368e7a4563 --- /dev/null +++ b/app/views/ide/index.html.haml @@ -0,0 +1,12 @@ +- page_title 'IDE' + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'ide' + +.ide-flash-container.flash-container + +#ide.ide-loading + .text-center + = icon('spinner spin 2x') + %h2.clgray= _('IDE Loading ...') diff --git a/app/views/layouts/nav_only.html.haml b/app/views/layouts/nav_only.html.haml new file mode 100644 index 00000000000..6fa4b39dc10 --- /dev/null +++ b/app/views/layouts/nav_only.html.haml @@ -0,0 +1,13 @@ +!!! 5 +%html{ lang: I18n.locale, class: page_class } + = render "layouts/head" + %body{ class: "#{user_application_theme} #{@body_class}", data: { page: body_data_page } } + = render 'peek/bar' + = render "layouts/header/default" + = render 'shared/outdated_browser' + .mobile-overlay + .alert-wrapper + = render "layouts/broadcast" + = yield :flash_message + = render "layouts/flash" + = yield diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 3a7a99462a6..79530e78154 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -7,7 +7,7 @@ .nav-block = render 'projects/tree/tree_header', tree: @tree - - if !show_new_repo? && commit + - if commit = render 'shared/commit_well', commit: commit, ref: ref, project: project = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index c5e3a7945bd..8212ab9a31e 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -17,7 +17,7 @@ %a.js-md-preview-button{ href: "#md-preview-holder", tabindex: -1 } Preview - %li.md-header-toolbar + %li.md-header-toolbar.active = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: "Add bold text" }) = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: "Add italic text" }) = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: "Insert a quote" }) diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 281363d2e01..2a77dedd9a2 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -12,6 +12,7 @@ .btn-group{ role: "group" }< = edit_blob_link + = ide_blob_link - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index c4712bf3736..4d358052d43 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -6,21 +6,14 @@ - content_for :page_specific_javascripts do = webpack_bundle_tag 'blob' - - if show_new_repo? - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'repo' - = render 'projects/last_push' %div{ class: container_class } - - if show_new_repo? - = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_blob_path(@project, @id) - - else - #tree-holder.tree-holder - = render 'blob', blob: @blob + #tree-holder.tree-holder + = render 'blob', blob: @blob - if can_modify_blob?(@blob) = render 'projects/blob/remove' - - title = "Replace #{@blob.name}" - = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put + - title = "Replace #{@blob.name}" + = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml index a0f0215a5ff..87aa7c1dbf8 100644 --- a/app/views/projects/blob/viewers/_dependency_manager.html.haml +++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml @@ -6,6 +6,6 @@ - if viewer.package_name and defines a #{viewer.package_type} named %strong< - = link_to viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' + = link_to_if viewer.package_url.present?, viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer' = link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/clusters/gcp/_form.html.haml b/app/views/projects/clusters/gcp/_form.html.haml index 0f6bae97571..e384b60d8d9 100644 --- a/app/views/projects/clusters/gcp/_form.html.haml +++ b/app/views/projects/clusters/gcp/_form.html.haml @@ -7,6 +7,9 @@ .form-group = field.label :name, s_('ClusterIntegration|Cluster name') = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name') + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field| .form-group diff --git a/app/views/projects/clusters/gcp/_show.html.haml b/app/views/projects/clusters/gcp/_show.html.haml index 3fa9f69708a..bde85aed341 100644 --- a/app/views/projects/clusters/gcp/_show.html.haml +++ b/app/views/projects/clusters/gcp/_show.html.haml @@ -8,6 +8,11 @@ = form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| = form_errors(@cluster) + + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') + = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL') diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/projects/clusters/user/_form.html.haml index 4a9bd5186c6..babfca0c567 100644 --- a/app/views/projects/clusters/user/_form.html.haml +++ b/app/views/projects/clusters/user/_form.html.haml @@ -3,6 +3,9 @@ .form-group = field.label :name, s_('ClusterIntegration|Cluster name') = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name') + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group diff --git a/app/views/projects/clusters/user/_show.html.haml b/app/views/projects/clusters/user/_show.html.haml index 5931e0b7f17..89595bca007 100644 --- a/app/views/projects/clusters/user/_show.html.haml +++ b/app/views/projects/clusters/user/_show.html.haml @@ -4,6 +4,10 @@ = field.label :name, s_('ClusterIntegration|Cluster name') = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Cluster name') + .form-group + = field.label :environment_scope, s_('ClusterIntegration|Environment scope') + = field.text_field :environment_scope, class: 'form-control js-select-on-focus', readonly: !has_multiple_clusters?(@project), placeholder: s_('ClusterIntegration|Environment scope') + = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field| .form-group = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL') diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index eab7879c7bf..1f28d8acff6 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -39,8 +39,6 @@ = icon('caret-down') .dropdown-menu.dropdown-menu-align-right.hidden-lg %ul - - if can_update_issue - %li= link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'js-issuable-edit' - unless current_user == @issue.author %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue)) - if can_update_issue @@ -52,9 +50,6 @@ %li.divider %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link' - - if can_update_issue - = link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped js-issuable-edit' - = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue - if can_report_spam diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 01ea9356af5..85946aec1f2 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,6 +1,6 @@ #js-pipeline-header-vue.pipeline-header-container -- if @commit +- if @commit.present? .commit-box %h3.commit-title = markdown(@commit.title, pipeline: :single_line) @@ -8,28 +8,28 @@ %pre.commit-description = preserve(markdown(@commit.description, pipeline: :single_line)) -.info-well - - if @commit.status - .well-segment.pipeline-info - .icon-container - = icon('clock-o') - = pluralize @pipeline.total_size, "job" - - if @pipeline.ref - from - = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - - if @pipeline.duration - in - = time_interval_in_words(@pipeline.duration) - - if @pipeline.queued_duration - = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + .info-well + - if @commit.status + .well-segment.pipeline-info + .icon-container + = icon('clock-o') + = pluralize @pipeline.total_size, "job" + - if @pipeline.ref + from + = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" + - if @pipeline.duration + in + = time_interval_in_words(@pipeline.duration) + - if @pipeline.queued_duration + = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" - .well-segment.branch-info - .icon-container.commit-icon - = custom_icon("icon_commit") - = link_to @commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha js-details-short" - = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do - %span.text-expander - \... - %span.js-details-content.hide - = link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full" - = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") + .well-segment.branch-info + .icon-container.commit-icon + = custom_icon("icon_commit") + = link_to @commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha js-details-short" + = link_to("#", class: "js-details-expand hidden-xs hidden-sm") do + %span.text-expander + \... + %span.js-details-content.hide + = link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full" + = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard") diff --git a/app/views/projects/tree/_old_tree_content.html.haml b/app/views/projects/tree/_old_tree_content.html.haml deleted file mode 100644 index 6ea78851b8d..00000000000 --- a/app/views/projects/tree/_old_tree_content.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } - .table-holder - %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } - %thead - %tr - %th= s_('ProjectFileTree|Name') - %th.hidden-xs - .pull-left= _('Last commit') - %th.text-right= _('Last update') - - if @path.present? - %tr.tree-item - %td.tree-item-file-name - = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' - %td - %td.hidden-xs - - = render_tree(tree) - - - if tree.readme - = render "projects/tree/readme", readme: tree.readme - -- if can_edit_tree? - = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post - = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_old_tree_header.html.haml b/app/views/projects/tree/_old_tree_header.html.haml deleted file mode 100644 index 7f636b7e0e8..00000000000 --- a/app/views/projects/tree/_old_tree_header.html.haml +++ /dev/null @@ -1,64 +0,0 @@ -- if on_top_of_branch? - - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' } -- else - - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } - -%ul.breadcrumb.repo-breadcrumb - %li - = link_to project_tree_path(@project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - %li - = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - - if current_user - %li - %a.btn.add-to-tree{ addtotree_toggle_attributes } - = sprite_icon('plus', size: 16, css_class: 'pull-left') - = sprite_icon('arrow-down', size: 16, css_class: 'pull-left') - - if on_top_of_branch? - .add-to-tree-dropdown - %ul.dropdown-menu - - if can_edit_tree? - %li - = link_to project_new_blob_path(@project, @id) do - #{ _('New file') } - %li - = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do - #{ _('Upload file') } - %li - = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do - #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) - %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('Upload file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New directory') } - - %li.divider - %li - = link_to new_project_branch_path(@project) do - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - #{ _('New tag') } diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index a4bdd67209d..6ea78851b8d 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -1,5 +1,24 @@ -- content_url = local_assigns.fetch(:content_url, nil) -- if show_new_repo? - = render 'shared/repo/repo', project: @project, content_url: content_url -- else - = render 'projects/tree/old_tree_content', tree: tree +.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } + .table-holder + %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } + %thead + %tr + %th= s_('ProjectFileTree|Name') + %th.hidden-xs + .pull-left= _('Last commit') + %th.text-right= _('Last update') + - if @path.present? + %tr.tree-item + %td.tree-item-file-name + = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' + %td + %td.hidden-xs + + = render_tree(tree) + + - if tree.readme + = render "projects/tree/readme", readme: tree.readme + +- if can_edit_tree? + = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post + = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index c02f7ee37ed..d1ecef39475 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -2,16 +2,78 @@ .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - - if show_new_repo? && can_push_branch?(@project, @ref) - .js-new-dropdown - - else - = render 'projects/tree/old_tree_header' + - if on_top_of_branch? + - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' } + - else + - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to project_tree_path(@project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) + + - if current_user + %li + %a.btn.add-to-tree{ addtotree_toggle_attributes } + = sprite_icon('plus', size: 16, css_class: 'pull-left') + = sprite_icon('arrow-down', size: 16, css_class: 'pull-left') + - if on_top_of_branch? + .add-to-tree-dropdown + %ul.dropdown-menu + - if can_edit_tree? + %li + = link_to project_new_blob_path(@project, @id) do + #{ _('New file') } + %li + = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do + #{ _('Upload file') } + %li + = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do + #{ _('New directory') } + - elsif can?(current_user, :fork_project, @project) + %li + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('Upload file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New directory') } + + %li.divider + %li + = link_to new_project_branch_path(@project) do + #{ _('New branch') } + %li + = link_to new_project_tag_path(@project) do + #{ _('New tag') } .tree-controls - - if show_new_repo? - .editable-mode - - else - = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' + - if show_new_ide? + = succeed " " do + = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do + = ide_edit_text + + = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = render 'projects/find_file_link' diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 64cc70053ef..3b4057e56d0 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -6,11 +6,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") -- if show_new_repo? - - content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'repo' - -%div{ class: [(container_class unless show_new_repo?), ("limit-container-width" unless fluid_layout)] } +%div{ class: [(container_class), ("limit-container-width" unless fluid_layout)] } = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index f4a4bfaec54..479bd2cdb38 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -1,6 +1,6 @@ - show_create = local_assigns.fetch(:show_create, false) -- show_new_branch_form = show_new_repo? && show_create && can?(current_user, :push_code, @project) +- show_new_branch_form = show_new_ide? && show_create && can?(current_user, :push_code, @project) - dropdown_toggle_text = @ref || @project.default_branch = form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do = hidden_field_tag :destination, destination diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml index b3f73e96b81..8e5e32e9f16 100644 --- a/app/views/shared/boards/components/_sidebar.html.haml +++ b/app/views/shared/boards/components/_sidebar.html.haml @@ -1,5 +1,4 @@ -%board-sidebar{ "inline-template" => true, - ":current-user" => "#{current_user ? current_user.to_json(only: [:username, :id, :name], methods: [:avatar_url]) : {}}" } +%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json } %transition{ name: "boards-sidebar-slide" } %aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" } .issuable-sidebar diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 8e6747ca740..1a259b679c7 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -1,3 +1,4 @@ +- options_hash = local_assigns.fetch(:options_hash, groups_sort_options_hash) - show_archive_options = local_assigns.fetch(:show_archive_options, false) - if @sort.present? - default_sort_by = @sort @@ -10,12 +11,12 @@ .dropdown.inline.js-group-filter-dropdown-wrap.append-right-10 %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.dropdown-label - = sort_options_hash[default_sort_by] + = options_hash[default_sort_by] = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable %li.dropdown-header = _("Sort by") - - groups_sort_options_hash.each do |value, title| + - options_hash.each do |value, title| %li.js-filter-sort-order = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do = title diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml deleted file mode 100644 index 87e8c416194..00000000000 --- a/app/views/shared/repo/_repo.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -- @no_container = true; -#repo{ data: { root: @path.empty?.to_s, - root_url: project_tree_path(project), - url: content_url, - current_branch: @ref, - ref: @commit.id, - project_name: project.name, - project_url: project_path(project), - project_id: project.id, - new_merge_request_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '' }), - can_commit: (!!can_push_branch?(project, @ref)).to_s, - on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s, - current_path: @path } } diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb new file mode 100644 index 00000000000..10b971344f7 --- /dev/null +++ b/app/workers/concerns/project_import_options.rb @@ -0,0 +1,23 @@ +module ProjectImportOptions + extend ActiveSupport::Concern + + included do + IMPORT_RETRY_COUNT = 5 + + sidekiq_options retry: IMPORT_RETRY_COUNT, status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + + # We only want to mark the project as failed once we exhausted all retries + sidekiq_retries_exhausted do |job| + project = Project.find(job['args'].first) + + action = if project.forked? + "fork" + else + "import" + end + + project.mark_import_as_failed("Every #{action} attempt has failed: #{job['error_message']}. Please try again.") + Sidekiq.logger.warn "Failed #{job['class']} with #{job['args']}: #{job['error_message']}" + end + end +end diff --git a/app/workers/concerns/project_start_import.rb b/app/workers/concerns/project_start_import.rb index 0704ebbb0fd..4e55a1ee3d6 100644 --- a/app/workers/concerns/project_start_import.rb +++ b/app/workers/concerns/project_start_import.rb @@ -1,3 +1,4 @@ +# Used in EE by mirroring module ProjectStartImport def start(project) if project.import_started? && project.import_jid == self.jid diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 3e34de22c19..db73d37868a 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -13,7 +13,7 @@ class ExpirePipelineCacheWorker store.touch(project_pipelines_path(project)) store.touch(project_pipeline_path(project, pipeline)) - store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit + store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil? store.touch(new_merge_request_pipelines_path(project)) each_pipelines_merge_request_path(project, pipeline) do |path| store.touch(path) diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index a07ef1705a1..d1c57b82681 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -1,11 +1,8 @@ class RepositoryForkWorker - ForkError = Class.new(StandardError) - include ApplicationWorker include Gitlab::ShellAdapter include ProjectStartImport - - sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + include ProjectImportOptions def perform(project_id, forked_from_repository_storage_path, source_disk_path) project = Project.find(project_id) @@ -18,20 +15,12 @@ class RepositoryForkWorker result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_disk_path, project.repository_storage_path, project.disk_path) - raise ForkError, "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result + raise "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result project.repository.after_import - raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo? + raise "Project #{project_id} had an invalid repository after fork" unless project.valid_repo? project.import_finish - rescue ForkError => ex - fail_fork(project, ex.message) - raise - rescue => ex - return unless project - - fail_fork(project, ex.message) - raise ForkError, "#{ex.class} #{ex.message}" end private @@ -42,9 +31,4 @@ class RepositoryForkWorker Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.") false end - - def fail_fork(project, message) - Rails.logger.error(message) - project.mark_import_as_failed(message) - end end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 55715c83cb1..31e2798c36b 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -1,11 +1,8 @@ class RepositoryImportWorker - ImportError = Class.new(StandardError) - include ApplicationWorker include ExceptionBacktrace include ProjectStartImport - - sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + include ProjectImportOptions def perform(project_id) project = Project.find(project_id) @@ -23,17 +20,9 @@ class RepositoryImportWorker # to those importers to mark the import process as complete. return if service.async? - raise ImportError, result[:message] if result[:status] == :error + raise result[:message] if result[:status] == :error project.after_import - rescue ImportError => ex - fail_import(project, ex.message) - raise - rescue => ex - return unless project - - fail_import(project, ex.message) - raise ImportError, "#{ex.class} #{ex.message}" end private @@ -44,8 +33,4 @@ class RepositoryImportWorker Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.") false end - - def fail_import(project, message) - project.mark_import_as_failed(message) - end end |