Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile5
-rw-r--r--Gemfile.lock23
-rw-r--r--app/assets/javascripts/behaviors/index.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js (renamed from app/assets/javascripts/behaviors/copy_as_gfm.js)4
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js (renamed from app/assets/javascripts/render_gfm.js)2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js (renamed from app/assets/javascripts/render_math.js)4
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js (renamed from app/assets/javascripts/render_mermaid.js)6
-rw-r--r--app/assets/javascripts/dispatcher.js12
-rw-r--r--app/assets/javascripts/gl_form.js6
-rw-r--r--app/assets/javascripts/ide/components/changed_file_icon.vue31
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue65
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue66
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue35
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue60
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue94
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue91
-rw-r--r--app/assets/javascripts/ide/components/ide.vue111
-rw-r--r--app/assets/javascripts/ide/components/ide_context_bar.vue84
-rw-r--r--app/assets/javascripts/ide/components/ide_external_links.vue43
-rw-r--r--app/assets/javascripts/ide/components/ide_project_branches_tree.vue47
-rw-r--r--app/assets/javascripts/ide/components/ide_project_tree.vue54
-rw-r--r--app/assets/javascripts/ide/components/ide_repo_tree.vue41
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue51
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue60
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue111
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue99
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue75
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue174
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue161
-rw-r--r--app/assets/javascripts/ide/components/repo_file.vue127
-rw-r--r--app/assets/javascripts/ide/components/repo_file_buttons.vue61
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue39
-rw-r--r--app/assets/javascripts/ide/components/repo_loading_file.vue42
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue98
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue61
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue88
-rw-r--r--app/assets/javascripts/ide/eventhub.js3
-rw-r--r--app/assets/javascripts/ide/ide_router.js97
-rw-r--r--app/assets/javascripts/ide/index.js33
-rw-r--r--app/assets/javascripts/ide/lib/common/disposable.js14
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js90
-rw-r--r--app/assets/javascripts/ide/lib/common/model_manager.js51
-rw-r--r--app/assets/javascripts/ide/lib/decorations/controller.js45
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js72
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js30
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff_worker.js10
-rw-r--r--app/assets/javascripts/ide/lib/editor.js164
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js15
-rw-r--r--app/assets/javascripts/ide/lib/themes/gl_theme.js14
-rw-r--r--app/assets/javascripts/ide/monaco_loader.js16
-rw-r--r--app/assets/javascripts/ide/services/index.js55
-rw-r--r--app/assets/javascripts/ide/stores/actions.js121
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js146
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js49
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js93
-rw-r--r--app/assets/javascripts/ide/stores/getters.js30
-rw-r--r--app/assets/javascripts/ide/stores/index.js19
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js218
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/constants.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/index.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutations.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/state.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js43
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js106
-rw-r--r--app/assets/javascripts/ide/stores/mutations/branch.js26
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js83
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js23
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js38
-rw-r--r--app/assets/javascripts/ide/stores/state.js19
-rw-r--r--app/assets/javascripts/ide/stores/utils.js125
-rw-r--r--app/assets/javascripts/ide/stores/workers/files_decorator_worker.js91
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js131
-rw-r--r--app/assets/javascripts/main.js1
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue5
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue6
-rw-r--r--app/assets/javascripts/performance_bar.js57
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue78
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue191
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue52
-rw-r--r--app/assets/javascripts/performance_bar/components/simple_metric.vue30
-rw-r--r--app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue18
-rw-r--r--app/assets/javascripts/performance_bar/index.js37
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js24
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js39
-rw-r--r--app/assets/javascripts/profile/profile.js41
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js2
-rw-r--r--app/assets/stylesheets/framework/header.scss108
-rw-r--r--app/assets/stylesheets/framework/images.scss41
-rw-r--r--app/assets/stylesheets/pages/notes.scss6
-rw-r--r--app/assets/stylesheets/pages/repo.scss365
-rw-r--r--app/assets/stylesheets/performance_bar.scss29
-rw-r--r--app/controllers/ide_controller.rb6
-rw-r--r--app/finders/admin/projects_finder.rb1
-rw-r--r--app/helpers/application_helper.rb6
-rw-r--r--app/helpers/blob_helper.rb11
-rw-r--r--app/helpers/services_helper.rb23
-rw-r--r--app/models/ci/build.rb9
-rw-r--r--app/models/notification_recipient.rb3
-rw-r--r--app/models/project_services/jira_service.rb14
-rw-r--r--app/models/service.rb23
-rw-r--r--app/views/ide/index.html.haml12
-rw-r--r--app/views/peek/_bar.html.haml12
-rw-r--r--app/views/peek/views/_gitaly.html.haml17
-rw-r--r--app/views/peek/views/_host.html.haml2
-rw-r--r--app/views/peek/views/_mysql2.html.haml4
-rw-r--r--app/views/peek/views/_pg.html.haml4
-rw-r--r--app/views/peek/views/_rblineprof.html.haml7
-rw-r--r--app/views/peek/views/_sql.html.haml14
-rw-r--r--app/views/projects/blob/_header.html.haml1
-rw-r--r--app/views/projects/environments/metrics.html.haml1
-rw-r--r--app/views/projects/tree/_tree_header.html.haml4
-rw-r--r--app/views/shared/_service_settings.html.haml2
-rw-r--r--changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml5
-rw-r--r--changelogs/unreleased/43933-always-notify-mentions.yml6
-rw-r--r--changelogs/unreleased/44232-docs-for-runner-ip-address.yml5
-rw-r--r--changelogs/unreleased/44280-fix-code-search.yml5
-rw-r--r--changelogs/unreleased/44330-docs-for-ingress-ip.yml5
-rw-r--r--changelogs/unreleased/44383-cleanup-framework-header.yml5
-rw-r--r--changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml5
-rw-r--r--changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml5
-rw-r--r--changelogs/unreleased/ajax-requests-in-performance-bar.yml5
-rw-r--r--changelogs/unreleased/fix-ci-job-auto-retry.yml5
-rw-r--r--changelogs/unreleased/issue_25542.yml5
-rw-r--r--changelogs/unreleased/optional-api-delimiter.yml5
-rw-r--r--changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml5
-rw-r--r--changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml5
-rw-r--r--config/initializers/active_record_locking.rb108
-rw-r--r--config/initializers/lograge.rb18
-rw-r--r--config/routes.rb5
-rw-r--r--config/webpack.config.js123
-rw-r--r--doc/administration/monitoring/performance/performance_bar.md6
-rw-r--r--doc/api/search.md26
-rw-r--r--doc/ci/runners/README.md33
-rw-r--r--doc/ci/runners/img/shared_runner_ip_address.pngbin0 -> 69821 bytes
-rw-r--r--doc/ci/runners/img/specific_runner_ip_address.pngbin0 -> 42055 bytes
-rw-r--r--doc/development/new_fe_guide/development/performance.md15
-rw-r--r--doc/install/kubernetes/index.md4
-rw-r--r--doc/user/project/clusters/index.md29
-rw-r--r--doc/user/project/integrations/prometheus.md30
-rw-r--r--doc/user/project/merge_requests/maintainer_access.md13
-rw-r--r--lib/api/search.rb4
-rw-r--r--lib/api/services.rb2
-rw-r--r--lib/banzai/pipeline/gfm_pipeline.rb4
-rw-r--r--lib/gitlab/git/repository.rb2
-rw-r--r--lib/gitlab/project_search_results.rb2
-rw-r--r--lib/peek/views/host.rb9
-rw-r--r--qa/qa/page/merge_request/show.rb17
-rw-r--r--spec/controllers/admin/projects_controller_spec.rb10
-rw-r--r--spec/features/markdown/copy_as_gfm_spec.rb2
-rw-r--r--spec/features/projects/tree/create_directory_spec.rb53
-rw-r--r--spec/features/projects/tree/create_file_spec.rb43
-rw-r--r--spec/features/projects/tree/upload_file_spec.rb51
-rw-r--r--spec/features/user_can_display_performance_bar_spec.rb10
-rw-r--r--spec/javascripts/behaviors/copy_as_gfm_spec.js2
-rw-r--r--spec/javascripts/ide/components/changed_file_icon_spec.js45
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/actions_spec.js35
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js28
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/list_item_spec.js85
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/list_spec.js53
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js130
-rw-r--r--spec/javascripts/ide/components/ide_context_bar_spec.js37
-rw-r--r--spec/javascripts/ide/components/ide_external_links_spec.js43
-rw-r--r--spec/javascripts/ide/components/ide_repo_tree_spec.js43
-rw-r--r--spec/javascripts/ide/components/ide_side_bar_spec.js42
-rw-r--r--spec/javascripts/ide/components/ide_spec.js41
-rw-r--r--spec/javascripts/ide/components/new_dropdown/index_spec.js84
-rw-r--r--spec/javascripts/ide/components/new_dropdown/modal_spec.js82
-rw-r--r--spec/javascripts/ide/components/new_dropdown/upload_spec.js87
-rw-r--r--spec/javascripts/ide/components/repo_commit_section_spec.js173
-rw-r--r--spec/javascripts/ide/components/repo_editor_spec.js137
-rw-r--r--spec/javascripts/ide/components/repo_file_buttons_spec.js47
-rw-r--r--spec/javascripts/ide/components/repo_file_spec.js80
-rw-r--r--spec/javascripts/ide/components/repo_loading_file_spec.js63
-rw-r--r--spec/javascripts/ide/components/repo_tab_spec.js165
-rw-r--r--spec/javascripts/ide/components/repo_tabs_spec.js81
-rw-r--r--spec/javascripts/ide/helpers.js22
-rw-r--r--spec/javascripts/ide/lib/common/disposable_spec.js44
-rw-r--r--spec/javascripts/ide/lib/common/model_manager_spec.js129
-rw-r--r--spec/javascripts/ide/lib/common/model_spec.js113
-rw-r--r--spec/javascripts/ide/lib/decorations/controller_spec.js139
-rw-r--r--spec/javascripts/ide/lib/diff/controller_spec.js196
-rw-r--r--spec/javascripts/ide/lib/diff/diff_spec.js80
-rw-r--r--spec/javascripts/ide/lib/editor_options_spec.js11
-rw-r--r--spec/javascripts/ide/lib/editor_spec.js197
-rw-r--r--spec/javascripts/ide/monaco_loader_spec.js15
-rw-r--r--spec/javascripts/ide/stores/actions/file_spec.js421
-rw-r--r--spec/javascripts/ide/stores/actions/tree_spec.js172
-rw-r--r--spec/javascripts/ide/stores/actions_spec.js306
-rw-r--r--spec/javascripts/ide/stores/getters_spec.js55
-rw-r--r--spec/javascripts/ide/stores/modules/commit/actions_spec.js505
-rw-r--r--spec/javascripts/ide/stores/modules/commit/getters_spec.js128
-rw-r--r--spec/javascripts/ide/stores/modules/commit/mutations_spec.js42
-rw-r--r--spec/javascripts/ide/stores/mutations/branch_spec.js18
-rw-r--r--spec/javascripts/ide/stores/mutations/file_spec.js157
-rw-r--r--spec/javascripts/ide/stores/mutations/tree_spec.js69
-rw-r--r--spec/javascripts/ide/stores/mutations_spec.js79
-rw-r--r--spec/javascripts/ide/stores/utils_spec.js66
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js3
-rw-r--r--spec/javascripts/lib/utils/text_markdown_spec.js10
-rw-r--r--spec/javascripts/merge_request_notes_spec.js3
-rw-r--r--spec/javascripts/monitoring/dashboard_spec.js1
-rw-r--r--spec/javascripts/monitoring/dashboard_state_spec.js46
-rw-r--r--spec/javascripts/notes/components/diff_file_header_spec.js2
-rw-r--r--spec/javascripts/notes/components/diff_with_note_spec.js2
-rw-r--r--spec/javascripts/notes/components/note_app_spec.js2
-rw-r--r--spec/javascripts/notes_spec.js2
-rw-r--r--spec/javascripts/pages/labels/components/promote_label_modal_spec.js2
-rw-r--r--spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js2
-rw-r--r--spec/javascripts/performance_bar/components/detailed_metric_spec.js88
-rw-r--r--spec/javascripts/performance_bar/components/performance_bar_app_spec.js88
-rw-r--r--spec/javascripts/performance_bar/components/request_selector_spec.js47
-rw-r--r--spec/javascripts/performance_bar/components/simple_metric_spec.js47
-rw-r--r--spec/javascripts/shortcuts_issuable_spec.js2
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js9
-rw-r--r--spec/javascripts/vue_shared/components/markdown/toolbar_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js4
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js4
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js4
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js4
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js4
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js4
-rw-r--r--spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js4
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb22
-rw-r--r--spec/lib/gitlab/project_search_results_spec.rb22
-rw-r--r--spec/models/ci/build_spec.rb29
-rw-r--r--spec/requests/api/search_spec.rb48
-rw-r--r--spec/services/notification_service_spec.rb21
-rw-r--r--spec/services/system_note_service_spec.rb2
-rw-r--r--spec/views/projects/services/_form.haml_spec.rb46
-rw-r--r--vendor/assets/javascripts/peek.js86
240 files changed, 10733 insertions, 806 deletions
diff --git a/Gemfile b/Gemfile
index ecbcb8757ed..957ceff554f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -113,7 +113,7 @@ gem 'fog-rackspace', '~> 0.1.1'
gem 'fog-aliyun', '~> 0.2.0'
# for Google storage
-gem 'google-api-client', '~> 0.13.6'
+gem 'google-api-client', '~> 0.19.8'
# for aws storage
gem 'unf', '~> 0.1.4'
@@ -268,7 +268,7 @@ gem 'premailer-rails', '~> 1.9.7'
gem 'ruby_parser', '~> 3.8', require: false
gem 'rails-i18n', '~> 4.0.9'
gem 'gettext_i18n_rails', '~> 1.8.0'
-gem 'gettext_i18n_rails_js', '~> 1.2.0'
+gem 'gettext_i18n_rails_js', '~> 1.3'
gem 'gettext', '~> 3.2.2', require: false, group: :development
gem 'batch-loader', '~> 1.2.1'
@@ -276,7 +276,6 @@ gem 'batch-loader', '~> 1.2.1'
# Perf bar
gem 'peek', '~> 1.0.1'
gem 'peek-gc', '~> 0.0.2'
-gem 'peek-host', '~> 1.0.0'
gem 'peek-mysql2', '~> 1.1.0', group: :mysql
gem 'peek-performance_bar', '~> 1.3.0'
gem 'peek-pg', '~> 1.3.0', group: :postgres
diff --git a/Gemfile.lock b/Gemfile.lock
index 1b1662c03be..f063edccc66 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -211,7 +211,7 @@ GEM
faraday_middleware
multi_json
fast_blank (1.0.0)
- fast_gettext (1.4.0)
+ fast_gettext (1.6.0)
ffaker (2.4.0)
ffi (1.9.18)
flay (2.10.0)
@@ -277,12 +277,12 @@ GEM
gemojione (3.3.0)
json
get_process_mem (0.2.0)
- gettext (3.2.2)
+ gettext (3.2.9)
locale (>= 2.0.5)
text (>= 1.3.0)
gettext_i18n_rails (1.8.0)
fast_gettext (>= 0.9.0)
- gettext_i18n_rails_js (1.2.0)
+ gettext_i18n_rails_js (1.3.0)
gettext (>= 3.0.2)
gettext_i18n_rails (>= 0.7.1)
po_to_json (>= 1.0.0)
@@ -336,9 +336,9 @@ GEM
json
multi_json
request_store (>= 1.0)
- google-api-client (0.13.6)
+ google-api-client (0.19.8)
addressable (~> 2.5, >= 2.5.1)
- googleauth (~> 0.5)
+ googleauth (>= 0.5, < 0.7.0)
httpclient (>= 2.8.1, < 3.0)
mime-types (~> 3.0)
representable (~> 3.0)
@@ -415,7 +415,7 @@ GEM
httparty (0.13.7)
json (~> 1.8)
multi_xml (>= 0.5.2)
- httpclient (2.8.2)
+ httpclient (2.8.3)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
@@ -498,7 +498,7 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521)
mimemagic (0.3.0)
- mini_mime (0.1.4)
+ mini_mime (1.0.0)
mini_portile2 (2.3.0)
minitest (5.7.0)
mousetrap-rails (1.4.6)
@@ -593,8 +593,6 @@ GEM
railties (>= 4.0.0)
peek-gc (0.0.2)
peek
- peek-host (1.0.0)
- peek
peek-mysql2 (1.1.0)
atomic (>= 1.0.0)
mysql2
@@ -658,7 +656,7 @@ GEM
httpclient (>= 2.4)
multi_json (>= 1.3.6)
rack (>= 1.1)
- rack-protection (1.5.3)
+ rack-protection (2.0.1)
rack
rack-proxy (0.6.0)
rack
@@ -1057,7 +1055,7 @@ DEPENDENCIES
gemojione (~> 3.3)
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
- gettext_i18n_rails_js (~> 1.2.0)
+ gettext_i18n_rails_js (~> 1.3)
gitaly-proto (~> 0.88.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
@@ -1067,7 +1065,7 @@ DEPENDENCIES
gollum-lib (~> 4.2)
gollum-rugged_adapter (~> 0.4.4)
gon (~> 6.1.0)
- google-api-client (~> 0.13.6)
+ google-api-client (~> 0.19.8)
google-protobuf (= 3.5.1)
gpgme
grape (~> 1.0)
@@ -1124,7 +1122,6 @@ DEPENDENCIES
org-ruby (~> 0.9.12)
peek (~> 1.0.1)
peek-gc (~> 0.0.2)
- peek-host (~> 1.0.0)
peek-mysql2 (~> 1.1.0)
peek-performance_bar (~> 1.3.0)
peek-pg (~> 1.3.0)
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 8d021de7998..84fef4d8b4f 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -1,6 +1,7 @@
import './autosize';
import './bind_in_out';
-import initCopyAsGFM from './copy_as_gfm';
+import './markdown/render_gfm';
+import initCopyAsGFM from './markdown/copy_as_gfm';
import initCopyToClipboard from './copy_to_clipboard';
import './details_behavior';
import installGlEmojiElement from './gl_emoji';
diff --git a/app/assets/javascripts/behaviors/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index f5f4f00d587..75cf90de0b5 100644
--- a/app/assets/javascripts/behaviors/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -2,8 +2,8 @@
import $ from 'jquery';
import _ from 'underscore';
-import { insertText, getSelectedFragment, nodeMatchesSelector } from '../lib/utils/common_utils';
-import { placeholderImage } from '../lazy_loader';
+import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils';
+import { placeholderImage } from '~/lazy_loader';
const gfmRules = {
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 94fffcd2f61..dbff2bd4b10 100644
--- a/app/assets/javascripts/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
+import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math';
import renderMermaid from './render_mermaid';
-import syntaxHighlight from './syntax_highlight';
// Render Gitlab flavoured Markdown
//
diff --git a/app/assets/javascripts/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index 8572bf64d46..7dcf1aeed17 100644
--- a/app/assets/javascripts/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
-import { __ } from './locale';
-import flash from './flash';
+import { __ } from '~/locale';
+import flash from '~/flash';
// Renders math using KaTeX in any element with the
// `js-render-math` class
diff --git a/app/assets/javascripts/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index d4f18955bd2..56b1896e9f1 100644
--- a/app/assets/javascripts/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -1,3 +1,5 @@
+import flash from '~/flash';
+
// Renders diagrams and flowcharts from text using Mermaid in any element with the
// `js-render-mermaid` class.
//
@@ -12,8 +14,6 @@
// </pre>
//
-import Flash from './flash';
-
export default function renderMermaid($els) {
if (!$els.length) return;
@@ -52,6 +52,6 @@ export default function renderMermaid($els) {
});
});
}).catch((err) => {
- Flash(`Can't load mermaid module: ${err}`);
+ flash(`Can't load mermaid module: ${err}`);
});
}
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 42ecc415173..72f21f13860 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -53,8 +53,12 @@ function initPageShortcuts(page) {
function initGFMInput() {
$('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
- const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
- const enableGFM = convertPermissionToBoolean(el.dataset.supportsAutocomplete);
+ const gfm = new GfmAutoComplete(
+ gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources,
+ );
+ const enableGFM = convertPermissionToBoolean(
+ el.dataset.supportsAutocomplete,
+ );
gfm.setup($(el), {
emojis: true,
members: enableGFM,
@@ -67,9 +71,9 @@ function initGFMInput() {
}
function initPerformanceBar() {
- if (document.querySelector('#peek')) {
+ if (document.querySelector('#js-peek')) {
import('./performance_bar')
- .then(m => new m.default({ container: '#peek' })) // eslint-disable-line new-cap
+ .then(m => new m.default({ container: '#js-peek' })) // eslint-disable-line new-cap
.catch(() => Flash('Error loading performance bar module'));
}
}
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 184c98813f1..9f5eba353d7 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import autosize from 'autosize';
import GfmAutoComplete from './gfm_auto_complete';
import dropzoneInput from './dropzone_input';
-import textUtils from './lib/utils/text_markdown';
+import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown';
export default class GLForm {
constructor(form, enableGFM = false) {
@@ -47,7 +47,7 @@ export default class GLForm {
}
// form and textarea event listeners
this.addEventListeners();
- textUtils.init(this.form);
+ addMarkdownListeners(this.form);
// hide discard button
this.form.find('.js-note-discard').hide();
this.form.show();
@@ -86,7 +86,7 @@ export default class GLForm {
clearEventListeners() {
this.textarea.off('focus');
this.textarea.off('blur');
- textUtils.removeListeners(this.form);
+ removeMarkdownListeners(this.form);
}
addEventListeners() {
diff --git a/app/assets/javascripts/ide/components/changed_file_icon.vue b/app/assets/javascripts/ide/components/changed_file_icon.vue
new file mode 100644
index 00000000000..0c54c992e51
--- /dev/null
+++ b/app/assets/javascripts/ide/components/changed_file_icon.vue
@@ -0,0 +1,31 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ changedIcon() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
+ },
+ changedIconClass() {
+ return `multi-${this.changedIcon}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <icon
+ :name="changedIcon"
+ :size="12"
+ :css-classes="`ide-file-changed-icon ${changedIconClass}`"
+ />
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
new file mode 100644
index 00000000000..2cbd982af19
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -0,0 +1,65 @@
+<script>
+ import { mapState } from 'vuex';
+ import { sprintf, __ } from '~/locale';
+ import * as consts from '../../stores/modules/commit/constants';
+ import RadioGroup from './radio_group.vue';
+
+ export default {
+ components: {
+ RadioGroup,
+ },
+ computed: {
+ ...mapState([
+ 'currentBranchId',
+ ]),
+ newMergeRequestHelpText() {
+ return sprintf(
+ __('Creates a new branch from %{branchName} and re-directs to create a new merge request'),
+ { branchName: this.currentBranchId },
+ );
+ },
+ commitToCurrentBranchText() {
+ return sprintf(
+ __('Commit to %{branchName} branch'),
+ { branchName: `<strong>${this.currentBranchId}</strong>` },
+ false,
+ );
+ },
+ commitToNewBranchText() {
+ return sprintf(
+ __('Creates a new branch from %{branchName}'),
+ { branchName: this.currentBranchId },
+ );
+ },
+ },
+ commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
+ commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
+ commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
+ };
+</script>
+
+<template>
+ <div class="append-bottom-15 ide-commit-radios">
+ <radio-group
+ :value="$options.commitToCurrentBranch"
+ :checked="true"
+ >
+ <span
+ v-html="commitToCurrentBranchText"
+ >
+ </span>
+ </radio-group>
+ <radio-group
+ :value="$options.commitToNewBranch"
+ :label="__('Create a new branch')"
+ :show-input="true"
+ :help-text="commitToNewBranchText"
+ />
+ <radio-group
+ :value="$options.commitToNewBranchMR"
+ :label="__('Create a new branch and merge request')"
+ :show-input="true"
+ :help-text="newMergeRequestHelpText"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
new file mode 100644
index 00000000000..453208f3f19
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -0,0 +1,66 @@
+<script>
+ import { mapState } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import listItem from './list_item.vue';
+ import listCollapsed from './list_collapsed.vue';
+
+ export default {
+ components: {
+ icon,
+ listItem,
+ listCollapsed,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ fileList: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'currentProjectId',
+ 'currentBranchId',
+ 'rightPanelCollapsed',
+ ]),
+ isCommitInfoShown() {
+ return this.rightPanelCollapsed || this.fileList.length;
+ },
+ },
+ methods: {
+ toggleCollapsed() {
+ this.$emit('toggleCollapsed');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ :class="{
+ 'multi-file-commit-list': isCommitInfoShown
+ }"
+ >
+ <list-collapsed
+ v-if="rightPanelCollapsed"
+ />
+ <template v-else>
+ <ul
+ v-if="fileList.length"
+ class="list-unstyled append-bottom-0"
+ >
+ <li
+ v-for="file in fileList"
+ :key="file.key"
+ >
+ <list-item
+ :file="file"
+ />
+ </li>
+ </ul>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
new file mode 100644
index 00000000000..15918ac9631
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
@@ -0,0 +1,35 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ computed: {
+ ...mapGetters([
+ 'addedFiles',
+ 'modifiedFiles',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-list-collapsed text-center"
+ >
+ <icon
+ name="file-addition"
+ :size="18"
+ css-classes="multi-file-addition append-bottom-10"
+ />
+ {{ addedFiles.length }}
+ <icon
+ name="file-modified"
+ :size="18"
+ css-classes="multi-file-modified prepend-top-10 append-bottom-10"
+ />
+ {{ modifiedFiles.length }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
new file mode 100644
index 00000000000..18934af004a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -0,0 +1,60 @@
+<script>
+ import { mapActions } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import router from '../../ide_router';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ iconName() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
+ },
+ iconClass() {
+ return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'discardFileChanges',
+ 'updateViewer',
+ ]),
+ openFileInEditor(file) {
+ this.updateViewer('diff');
+
+ router.push(`/project${file.url}`);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="multi-file-commit-list-item">
+ <button
+ type="button"
+ class="multi-file-commit-list-path"
+ @click="openFileInEditor(file)">
+ <span class="multi-file-commit-list-file-path">
+ <icon
+ :name="iconName"
+ :size="16"
+ :css-classes="iconClass"
+ />{{ file.path }}
+ </span>
+ </button>
+ <button
+ type="button"
+ class="btn btn-blank multi-file-discard-btn"
+ @click="discardFileChanges(file.path)"
+ >
+ Discard
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
new file mode 100644
index 00000000000..4310d762c78
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -0,0 +1,94 @@
+<script>
+ import { mapActions, mapState, mapGetters } from 'vuex';
+ import tooltip from '~/vue_shared/directives/tooltip';
+
+ export default {
+ directives: {
+ tooltip,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ checked: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showInput: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ ...mapState('commit', [
+ 'commitAction',
+ ]),
+ ...mapGetters('commit', [
+ 'newBranchName',
+ ]),
+ },
+ methods: {
+ ...mapActions('commit', [
+ 'updateCommitAction',
+ 'updateBranchName',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label>
+ <input
+ type="radio"
+ name="commit-action"
+ :value="value"
+ @change="updateCommitAction($event.target.value)"
+ :checked="checked"
+ v-once
+ />
+ <span class="prepend-left-10">
+ <template v-if="label">
+ {{ label }}
+ </template>
+ <slot v-else></slot>
+ <span
+ v-if="helpText"
+ v-tooltip
+ class="help-block inline"
+ :title="helpText"
+ >
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true"
+ >
+ </i>
+ </span>
+ </span>
+ </label>
+ <div
+ v-if="commitAction === value && showInput"
+ class="ide-commit-new-branch"
+ >
+ <input
+ type="text"
+ class="form-control"
+ :placeholder="newBranchName"
+ @input="updateBranchName($event.target.value)"
+ />
+ </div>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
new file mode 100644
index 00000000000..170347881e0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -0,0 +1,91 @@
+<script>
+ import Icon from '~/vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ Icon,
+ },
+ props: {
+ hasChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ viewer: {
+ type: String,
+ required: true,
+ },
+ showShadow: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ changeMode(mode) {
+ this.$emit('click', mode);
+ },
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dropdown"
+ :class="{
+ shadow: showShadow,
+ }"
+ >
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ :class="{
+ 'btn-inverted': hasChanges,
+ }"
+ data-toggle="dropdown"
+ >
+ <template v-if="viewer === 'editor'">
+ {{ __('Editing') }}
+ </template>
+ <template v-else>
+ {{ __('Reviewing') }}
+ </template>
+ <icon
+ name="angle-down"
+ :size="12"
+ css-classes="caret-down"
+ />
+ </button>
+ <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left">
+ <ul>
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('editor')"
+ :class="{
+ 'is-active': viewer === 'editor',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">{{ __('Editing') }}</strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('View and edit lines') }}
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ href="#"
+ @click.prevent="changeMode('diff')"
+ :class="{
+ 'is-active': viewer === 'diff',
+ }"
+ >
+ <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong>
+ <span class="dropdown-menu-inner-content">
+ {{ __('Compare changes with the last commit') }}
+ </span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
new file mode 100644
index 00000000000..015e750525a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -0,0 +1,111 @@
+<script>
+ import { mapState, mapGetters } from 'vuex';
+ import ideSidebar from './ide_side_bar.vue';
+ import ideContextbar from './ide_context_bar.vue';
+ import repoTabs from './repo_tabs.vue';
+ import repoFileButtons from './repo_file_buttons.vue';
+ import ideStatusBar from './ide_status_bar.vue';
+ import repoEditor from './repo_editor.vue';
+
+ export default {
+ components: {
+ ideSidebar,
+ ideContextbar,
+ repoTabs,
+ repoFileButtons,
+ ideStatusBar,
+ repoEditor,
+ },
+ props: {
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['changedFiles', 'openFiles', 'viewer']),
+ ...mapGetters(['activeFile', 'hasChanges']),
+ },
+ mounted() {
+ const returnValue = 'Are you sure you want to lose unsaved changes?';
+ window.onbeforeunload = e => {
+ if (!this.changedFiles.length) return undefined;
+
+ Object.assign(e, {
+ returnValue,
+ });
+ return returnValue;
+ };
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="ide-view"
+ >
+ <ide-sidebar />
+ <div
+ class="multi-file-edit-pane"
+ >
+ <template
+ v-if="activeFile"
+ >
+ <repo-tabs
+ :files="openFiles"
+ :viewer="viewer"
+ :has-changes="hasChanges"
+ />
+ <repo-editor
+ class="multi-file-edit-pane-content"
+ :file="activeFile"
+ />
+ <repo-file-buttons
+ :file="activeFile"
+ />
+ <ide-status-bar
+ :file="activeFile"
+ />
+ </template>
+ <template
+ v-else
+ >
+ <div
+ v-once
+ class="ide-empty-state"
+ >
+ <div class="row js-empty-state">
+ <div class="col-xs-12">
+ <div class="svg-content svg-250">
+ <img :src="emptyStateSvgPath" />
+ </div>
+ </div>
+ <div class="col-xs-12">
+ <div class="text-content text-center">
+ <h4>
+ Welcome to the GitLab IDE
+ </h4>
+ <p>
+ You can select a file in the left sidebar to begin
+ editing and use the right sidebar to commit your changes.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+ <ide-contextbar
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue
new file mode 100644
index 00000000000..79a83b47994
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_context_bar.vue
@@ -0,0 +1,84 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import icon from '~/vue_shared/components/icon.vue';
+import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+import repoCommitSection from './repo_commit_section.vue';
+import ResizablePanel from './resizable_panel.vue';
+
+export default {
+ components: {
+ repoCommitSection,
+ icon,
+ panelResizer,
+ ResizablePanel,
+ },
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['changedFiles', 'rightPanelCollapsed']),
+ ...mapGetters(['currentIcon']),
+ },
+ methods: {
+ ...mapActions(['setPanelCollapsedStatus']),
+ },
+};
+</script>
+
+<template>
+ <resizable-panel
+ :collapsible="true"
+ :initial-width="340"
+ side="right"
+ >
+ <div
+ class="multi-file-commit-panel-section"
+ >
+ <header
+ class="multi-file-commit-panel-header"
+ :class="{
+ 'is-collapsed': rightPanelCollapsed,
+ }"
+ >
+ <div
+ class="multi-file-commit-panel-header-title"
+ v-if="!rightPanelCollapsed"
+ >
+ <div
+ v-if="changedFiles.length"
+ >
+ <icon
+ name="list-bulleted"
+ :size="18"
+ />
+ Staged
+ </div>
+ </div>
+ <button
+ type="button"
+ class="btn btn-transparent multi-file-commit-panel-collapse-btn"
+ @click.stop="setPanelCollapsedStatus({
+ side: 'right',
+ collapsed: !rightPanelCollapsed,
+ })"
+ >
+ <icon
+ :name="currentIcon"
+ :size="18"
+ />
+ </button>
+ </header>
+ <repo-commit-section
+ :no-changes-state-svg-path="noChangesStateSvgPath"
+ :committed-state-svg-path="committedStateSvgPath"
+ />
+ </div>
+ </resizable-panel>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue
new file mode 100644
index 00000000000..c6f6e0d2348
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_external_links.vue
@@ -0,0 +1,43 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ icon,
+ },
+ props: {
+ projectUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ goBackUrl() {
+ return document.referrer || this.projectUrl;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav
+ class="ide-external-links"
+ v-once
+ >
+ <p>
+ <a
+ :href="goBackUrl"
+ class="ide-sidebar-link"
+ >
+ <icon
+ :size="16"
+ class="append-right-8"
+ name="go-back"
+ />
+ <span class="ide-external-links-text">
+ {{ s__('Go back') }}
+ </span>
+ </a>
+ </p>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
new file mode 100644
index 00000000000..eb2749e6151
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
@@ -0,0 +1,47 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import repoTree from './ide_repo_tree.vue';
+ import newDropdown from './new_dropdown/index.vue';
+
+ export default {
+ components: {
+ repoTree,
+ icon,
+ newDropdown,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ branch: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="branch-container">
+ <div class="branch-header">
+ <div class="branch-header-title str-truncated ref-name">
+ <icon
+ name="branch"
+ :size="12"
+ />
+ {{ branch.name }}
+ </div>
+ <div class="branch-header-btns">
+ <new-dropdown
+ :project-id="projectId"
+ :branch="branch.name"
+ path=""
+ />
+ </div>
+ </div>
+ <repo-tree
+ :tree="branch.tree"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue
new file mode 100644
index 00000000000..220db1abfb0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_project_tree.vue
@@ -0,0 +1,54 @@
+<script>
+import projectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
+import branchesTree from './ide_project_branches_tree.vue';
+import externalLinks from './ide_external_links.vue';
+
+export default {
+ components: {
+ branchesTree,
+ externalLinks,
+ projectAvatarImage,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="projects-sidebar">
+ <div class="context-header">
+ <a
+ :title="project.name"
+ :href="project.web_url"
+ >
+ <div class="avatar-container s40 project-avatar">
+ <project-avatar-image
+ class="avatar-container project-avatar"
+ :link-href="project.path"
+ :img-src="project.avatar_url"
+ :img-alt="project.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="sidebar-context-title">
+ {{ project.name }}
+ </div>
+ </a>
+ </div>
+ <external-links
+ :project-url="project.web_url"
+ />
+ <div class="multi-file-commit-panel-inner-scroll">
+ <branches-tree
+ v-for="branch in project.branches"
+ :key="branch.name"
+ :project-id="project.path_with_namespace"
+ :branch="branch"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue
new file mode 100644
index 00000000000..e6af88e04bc
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue
@@ -0,0 +1,41 @@
+<script>
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import RepoFile from './repo_file.vue';
+
+export default {
+ components: {
+ RepoFile,
+ SkeletonLoadingContainer,
+ },
+ props: {
+ tree: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="ide-file-list"
+ >
+ <template v-if="tree.loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <template v-else>
+ <repo-file
+ v-for="file in tree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
new file mode 100644
index 00000000000..8cf1ccb4fce
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -0,0 +1,51 @@
+<script>
+ import { mapState, mapGetters } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import panelResizer from '~/vue_shared/components/panel_resizer.vue';
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+ import projectTree from './ide_project_tree.vue';
+ import ResizablePanel from './resizable_panel.vue';
+
+ export default {
+ components: {
+ projectTree,
+ icon,
+ panelResizer,
+ skeletonLoadingContainer,
+ ResizablePanel,
+ },
+ computed: {
+ ...mapState([
+ 'loading',
+ ]),
+ ...mapGetters([
+ 'projectsWithTrees',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <resizable-panel
+ :collapsible="false"
+ :initial-width="290"
+ side="left"
+ >
+ <div class="multi-file-commit-panel-inner">
+ <template v-if="loading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <project-tree
+ v-for="project in projectsWithTrees"
+ :key="project.id"
+ :project="project"
+ />
+ </div>
+ </resizable-panel>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
new file mode 100644
index 00000000000..9c386896448
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -0,0 +1,60 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import timeAgoMixin from '~/vue_shared/mixins/timeago';
+
+ export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [
+ timeAgoMixin,
+ ],
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="ide-status-bar">
+ <div class="ref-name">
+ <icon
+ name="branch"
+ :size="12"
+ />
+ {{ file.branchId }}
+ </div>
+ <div>
+ <div v-if="file.lastCommit && file.lastCommit.id">
+ Last commit:
+ <a
+ v-tooltip
+ :title="file.lastCommit.message"
+ :href="file.lastCommit.url"
+ >
+ {{ timeFormated(file.lastCommit.updatedAt) }} by
+ {{ file.lastCommit.author }}
+ </a>
+ </div>
+ </div>
+ <div class="text-right">
+ {{ file.name }}
+ </div>
+ <div class="text-right">
+ {{ file.eol }}
+ </div>
+ <div class="text-right">
+ {{ file.editorRow }}:{{ file.editorColumn }}
+ </div>
+ <div class="text-right">
+ {{ file.fileLanguage }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
new file mode 100644
index 00000000000..769e9b79cad
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -0,0 +1,111 @@
+<script>
+ import { mapActions } from 'vuex';
+ import icon from '~/vue_shared/components/icon.vue';
+ import newModal from './modal.vue';
+ import upload from './upload.vue';
+
+ export default {
+ components: {
+ icon,
+ newModal,
+ upload,
+ },
+ props: {
+ branch: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ openModal: false,
+ modalType: '',
+ dropdownOpen: false,
+ };
+ },
+ methods: {
+ ...mapActions([
+ 'createTempEntry',
+ ]),
+ createNewItem(type) {
+ this.modalType = type;
+ this.openModal = true;
+ this.dropdownOpen = false;
+ },
+ hideModal() {
+ this.openModal = false;
+ },
+ openDropdown() {
+ this.dropdownOpen = !this.dropdownOpen;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="ide-new-btn">
+ <div
+ class="dropdown"
+ :class="{
+ open: dropdownOpen,
+ }"
+ >
+ <button
+ type="button"
+ class="btn btn-sm btn-default dropdown-toggle add-to-tree"
+ aria-label="Create new file or directory"
+ @click.stop="openDropdown()"
+ >
+ <icon
+ name="plus"
+ :size="12"
+ css-classes="pull-left"
+ />
+ <icon
+ name="arrow-down"
+ :size="12"
+ css-classes="pull-left"
+ />
+ </button>
+ <ul class="dropdown-menu dropdown-menu-right">
+ <li>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="createNewItem('blob')"
+ >
+ {{ __('New file') }}
+ </a>
+ </li>
+ <li>
+ <upload
+ :branch-id="branch"
+ :path="path"
+ @create="createTempEntry"
+ />
+ </li>
+ <li>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="createNewItem('tree')"
+ >
+ {{ __('New directory') }}
+ </a>
+ </li>
+ </ul>
+ </div>
+ <new-modal
+ v-if="openModal"
+ :type="modalType"
+ :branch-id="branch"
+ :path="path"
+ @hide="hideModal"
+ @create="createTempEntry"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
new file mode 100644
index 00000000000..5723891d130
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -0,0 +1,99 @@
+<script>
+ import { __ } from '~/locale';
+ import modal from '~/vue_shared/components/modal.vue';
+
+ export default {
+ components: {
+ modal,
+ },
+ props: {
+ branchId: {
+ type: String,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ entryName: this.path !== '' ? `${this.path}/` : '',
+ };
+ },
+ computed: {
+ modalTitle() {
+ if (this.type === 'tree') {
+ return __('Create new directory');
+ }
+
+ return __('Create new file');
+ },
+ buttonLabel() {
+ if (this.type === 'tree') {
+ return __('Create directory');
+ }
+
+ return __('Create file');
+ },
+ formLabelName() {
+ if (this.type === 'tree') {
+ return __('Directory name');
+ }
+
+ return __('File name');
+ },
+ },
+ mounted() {
+ this.$refs.fieldName.focus();
+ },
+ methods: {
+ createEntryInStore() {
+ this.$emit('create', {
+ branchId: this.branchId,
+ name: this.entryName,
+ type: this.type,
+ });
+
+ this.hideModal();
+ },
+ hideModal() {
+ this.$emit('hide');
+ },
+ },
+ };
+</script>
+
+<template>
+ <modal
+ :title="modalTitle"
+ :primary-button-label="buttonLabel"
+ kind="success"
+ @cancel="hideModal"
+ @submit="createEntryInStore"
+ >
+ <form
+ class="form-horizontal"
+ slot="body"
+ @submit.prevent="createEntryInStore"
+ >
+ <fieldset class="form-group append-bottom-0">
+ <label class="label-light col-sm-3">
+ {{ formLabelName }}
+ </label>
+ <div class="col-sm-9">
+ <input
+ type="text"
+ class="form-control"
+ v-model="entryName"
+ ref="fieldName"
+ />
+ </div>
+ </fieldset>
+ </form>
+ </modal>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
new file mode 100644
index 00000000000..c165af5ce52
--- /dev/null
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -0,0 +1,75 @@
+<script>
+ export default {
+ props: {
+ branchId: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ mounted() {
+ this.$refs.fileUpload.addEventListener('change', this.openFile);
+ },
+ beforeDestroy() {
+ this.$refs.fileUpload.removeEventListener('change', this.openFile);
+ },
+ methods: {
+ createFile(target, file, isText) {
+ const { name } = file;
+ let { result } = target;
+
+ if (!isText) {
+ result = result.split('base64,')[1];
+ }
+
+ this.$emit('create', {
+ name: `${(this.path ? `${this.path}/` : '')}${name}`,
+ branchId: this.branchId,
+ type: 'blob',
+ content: result,
+ base64: !isText,
+ });
+ },
+ readFile(file) {
+ const reader = new FileReader();
+ const isText = file.type.match(/text.*/) !== null;
+
+ reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true });
+
+ if (isText) {
+ reader.readAsText(file);
+ } else {
+ reader.readAsDataURL(file);
+ }
+ },
+ openFile() {
+ Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file));
+ },
+ startFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ },
+ };
+</script>
+
+<template>
+ <div>
+ <a
+ href="#"
+ role="button"
+ @click.stop.prevent="startFileUpload"
+ >
+ {{ __('Upload file') }}
+ </a>
+ <input
+ id="file-upload"
+ type="file"
+ class="hidden"
+ ref="fileUpload"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
new file mode 100644
index 00000000000..d772cab2d0e
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -0,0 +1,174 @@
+<script>
+import { mapState, mapActions, mapGetters } from 'vuex';
+import tooltip from '~/vue_shared/directives/tooltip';
+import icon from '~/vue_shared/components/icon.vue';
+import modal from '~/vue_shared/components/modal.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import commitFilesList from './commit_sidebar/list.vue';
+import * as consts from '../stores/modules/commit/constants';
+import Actions from './commit_sidebar/actions.vue';
+
+export default {
+ components: {
+ modal,
+ icon,
+ commitFilesList,
+ Actions,
+ LoadingButton,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ noChangesStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ committedStateSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'currentProjectId',
+ 'currentBranchId',
+ 'rightPanelCollapsed',
+ 'lastCommitMsg',
+ 'changedFiles',
+ ]),
+ ...mapState('commit', [
+ 'commitMessage',
+ 'submitCommitLoading',
+ ]),
+ ...mapGetters('commit', [
+ 'commitButtonDisabled',
+ 'discardDraftButtonDisabled',
+ 'branchName',
+ ]),
+ statusSvg() {
+ return this.lastCommitMsg ? this.committedStateSvgPath : this.noChangesStateSvgPath;
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setPanelCollapsedStatus',
+ ]),
+ ...mapActions('commit', [
+ 'updateCommitMessage',
+ 'discardDraft',
+ 'commitChanges',
+ 'updateCommitAction',
+ ]),
+ toggleCollapsed() {
+ this.setPanelCollapsedStatus({
+ side: 'right',
+ collapsed: !this.rightPanelCollapsed,
+ });
+ },
+ forceCreateNewBranch() {
+ return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH)
+ .then(() => this.commitChanges());
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-panel-section"
+ :class="{
+ 'multi-file-commit-empty-state-container': !changedFiles.length
+ }"
+ >
+ <modal
+ id="ide-create-branch-modal"
+ :primary-button-label="__('Create new branch')"
+ kind="success"
+ :title="__('Branch has changed')"
+ @submit="forceCreateNewBranch"
+ >
+ <template slot="body">
+ {{ __(`This branch has changed since you started editing.
+ Would you like to create a new branch?`) }}
+ </template>
+ </modal>
+ <commit-files-list
+ title="Staged"
+ :file-list="changedFiles"
+ :collapsed="rightPanelCollapsed"
+ @toggleCollapsed="toggleCollapsed"
+ />
+ <template
+ v-if="changedFiles.length"
+ >
+ <form
+ class="form-horizontal multi-file-commit-form"
+ @submit.prevent.stop="commitChanges"
+ v-if="!rightPanelCollapsed"
+ >
+ <div class="multi-file-commit-fieldset">
+ <textarea
+ class="form-control multi-file-commit-message"
+ name="commit-message"
+ :value="commitMessage"
+ :placeholder="__('Write a commit message...')"
+ @input="updateCommitMessage($event.target.value)"
+ >
+ </textarea>
+ </div>
+ <div class="clearfix prepend-top-15">
+ <actions />
+ <loading-button
+ :loading="submitCommitLoading"
+ :disabled="commitButtonDisabled"
+ container-class="btn btn-success btn-sm pull-left"
+ :label="__('Commit')"
+ @click="commitChanges"
+ />
+ <button
+ v-if="!discardDraftButtonDisabled"
+ type="button"
+ class="btn btn-default btn-sm pull-right"
+ @click="discardDraft"
+ >
+ {{ __('Discard draft') }}
+ </button>
+ </div>
+ </form>
+ </template>
+ <div
+ v-else-if="!rightPanelCollapsed"
+ class="row js-empty-state"
+ >
+ <div class="col-xs-10 col-xs-offset-1">
+ <div class="svg-content svg-80">
+ <img :src="statusSvg" />
+ </div>
+ </div>
+ <div class="col-xs-10 col-xs-offset-1">
+ <div
+ class="text-content text-center"
+ v-if="!lastCommitMsg"
+ >
+ <h4>
+ {{ __('No changes') }}
+ </h4>
+ <p>
+ {{ __('Edit files in the editor and commit changes here') }}
+ </p>
+ </div>
+ <div
+ class="text-content text-center"
+ v-else
+ >
+ <h4>
+ {{ __('All changes are committed') }}
+ </h4>
+ <p v-html="lastCommitMsg">
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
new file mode 100644
index 00000000000..e73d1ce839f
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -0,0 +1,161 @@
+<script>
+/* global monaco */
+import { mapState, mapActions } from 'vuex';
+import flash from '~/flash';
+import monacoLoader from '../monaco_loader';
+import Editor from '../lib/editor';
+
+export default {
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'leftPanelCollapsed',
+ 'rightPanelCollapsed',
+ 'viewer',
+ 'delayViewerUpdated',
+ ]),
+ shouldHideEditor() {
+ return this.file && this.file.binary && !this.file.raw;
+ },
+ },
+ watch: {
+ file(oldVal, newVal) {
+ if (newVal.path !== this.file.path) {
+ this.initMonaco();
+ }
+ },
+ leftPanelCollapsed() {
+ this.editor.updateDimensions();
+ },
+ rightPanelCollapsed() {
+ this.editor.updateDimensions();
+ },
+ viewer() {
+ this.createEditorInstance();
+ },
+ },
+ beforeDestroy() {
+ this.editor.dispose();
+ },
+ mounted() {
+ if (this.editor && monaco) {
+ this.initMonaco();
+ } else {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ this.editor = Editor.create(monaco);
+
+ this.initMonaco();
+ });
+ }
+ },
+ methods: {
+ ...mapActions([
+ 'getRawFileData',
+ 'changeFileContent',
+ 'setFileLanguage',
+ 'setEditorPosition',
+ 'setFileEOL',
+ 'updateViewer',
+ 'updateDelayViewerUpdated',
+ ]),
+ initMonaco() {
+ if (this.shouldHideEditor) return;
+
+ this.editor.clearEditor();
+
+ this.getRawFileData(this.file)
+ .then(() => {
+ const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve();
+
+ return viewerPromise;
+ })
+ .then(() => {
+ this.updateDelayViewerUpdated(false);
+ this.createEditorInstance();
+ })
+ .catch((err) => {
+ flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true);
+ throw err;
+ });
+ },
+ createEditorInstance() {
+ this.editor.dispose();
+
+ this.$nextTick(() => {
+ if (this.viewer === 'editor') {
+ this.editor.createInstance(this.$refs.editor);
+ } else {
+ this.editor.createDiffInstance(this.$refs.editor);
+ }
+
+ this.setupEditor();
+ });
+ },
+ setupEditor() {
+ if (!this.file || !this.editor.instance) return;
+
+ this.model = this.editor.createModel(this.file);
+
+ this.editor.attachModel(this.model);
+
+ this.model.onChange((model) => {
+ const { file } = model;
+
+ if (file.active) {
+ this.changeFileContent({
+ path: file.path,
+ content: model.getModel().getValue(),
+ });
+ }
+ });
+
+ // Handle Cursor Position
+ this.editor.onPositionChange((instance, e) => {
+ this.setEditorPosition({
+ editorRow: e.position.lineNumber,
+ editorColumn: e.position.column,
+ });
+ });
+
+ this.editor.setPosition({
+ lineNumber: this.file.editorRow,
+ column: this.file.editorColumn,
+ });
+
+ // Handle File Language
+ this.setFileLanguage({
+ fileLanguage: this.model.language,
+ });
+
+ // Get File eol
+ this.setFileEOL({
+ eol: this.model.eol,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ id="ide"
+ class="blob-viewer-container blob-editor-container"
+ >
+ <div
+ v-if="shouldHideEditor"
+ v-html="file.html"
+ >
+ </div>
+ <div
+ v-show="!shouldHideEditor"
+ ref="editor"
+ class="multi-file-editor-holder"
+ >
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue
new file mode 100644
index 00000000000..03a40096bb0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file.vue
@@ -0,0 +1,127 @@
+<script>
+import { mapActions } from 'vuex';
+import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import fileIcon from '~/vue_shared/components/file_icon.vue';
+import router from '../ide_router';
+import newDropdown from './new_dropdown/index.vue';
+import fileStatusIcon from './repo_file_status_icon.vue';
+import changedFileIcon from './changed_file_icon.vue';
+
+export default {
+ name: 'RepoFile',
+ components: {
+ skeletonLoadingContainer,
+ newDropdown,
+ fileStatusIcon,
+ fileIcon,
+ changedFileIcon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ level: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ isTree() {
+ return this.file.type === 'tree';
+ },
+ isBlob() {
+ return this.file.type === 'blob';
+ },
+ levelIndentation() {
+ return {
+ marginLeft: `${this.level * 16}px`,
+ };
+ },
+ fileClass() {
+ return {
+ 'file-open': this.isBlob && this.file.opened,
+ 'file-active': this.isBlob && this.file.active,
+ folder: this.isTree,
+ };
+ },
+ },
+ updated() {
+ if (this.file.type === 'blob' && this.file.active) {
+ this.$el.scrollIntoView();
+ }
+ },
+ methods: {
+ ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']),
+ clickFile() {
+ // Manual Action if a tree is selected/opened
+ if (
+ this.isTree &&
+ this.$router.currentRoute.path === `/project${this.file.url}`
+ ) {
+ this.toggleTreeOpen(this.file.path);
+ }
+
+ const delayPromise = this.file.changed
+ ? Promise.resolve()
+ : this.updateDelayViewerUpdated(true);
+
+ return delayPromise.then(() => {
+ router.push(`/project${this.file.url}`);
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="file"
+ :class="fileClass"
+ >
+ <div
+ class="file-name"
+ @click="clickFile"
+ role="button"
+ >
+ <span
+ class="ide-file-name str-truncated"
+ :style="levelIndentation"
+ >
+ <file-icon
+ :file-name="file.name"
+ :loading="file.loading"
+ :folder="isTree"
+ :opened="file.opened"
+ :size="16"
+ />
+ {{ file.name }}
+ <file-status-icon
+ :file="file"
+ />
+ </span>
+ <changed-file-icon
+ :file="file"
+ v-if="file.changed || file.tempFile"
+ class="prepend-top-5 pull-right"
+ />
+ <new-dropdown
+ v-if="isTree"
+ :project-id="file.projectId"
+ :branch="file.branchId"
+ :path="file.path"
+ class="pull-right prepend-left-8"
+ />
+ </div>
+ </div>
+ <template v-if="file.opened">
+ <repo-file
+ v-for="childFile in file.tree"
+ :key="childFile.key"
+ :file="childFile"
+ :level="level + 1"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file_buttons.vue b/app/assets/javascripts/ide/components/repo_file_buttons.vue
new file mode 100644
index 00000000000..4ea8cf7504b
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue
@@ -0,0 +1,61 @@
+<script>
+export default {
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ showButtons() {
+ return this.file.rawPath ||
+ this.file.blamePath ||
+ this.file.commitsPath ||
+ this.file.permalink;
+ },
+ rawDownloadButtonLabel() {
+ return this.file.binary ? 'Download' : 'Raw';
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-if="showButtons"
+ class="multi-file-editor-btn-group"
+ >
+ <a
+ :href="file.rawPath"
+ target="_blank"
+ class="btn btn-default btn-sm raw"
+ rel="noopener noreferrer">
+ {{ rawDownloadButtonLabel }}
+ </a>
+
+ <div
+ class="btn-group"
+ role="group"
+ aria-label="File actions"
+ >
+ <a
+ :href="file.blamePath"
+ class="btn btn-default btn-sm blame"
+ >
+ Blame
+ </a>
+ <a
+ :href="file.commitsPath"
+ class="btn btn-default btn-sm history"
+ >
+ History
+ </a>
+ <a
+ :href="file.permalink"
+ class="btn btn-default btn-sm permalink"
+ >
+ Permalink
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
new file mode 100644
index 00000000000..25d311142d5
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -0,0 +1,39 @@
+<script>
+ import icon from '~/vue_shared/components/icon.vue';
+ import tooltip from '~/vue_shared/directives/tooltip';
+ import '~/lib/utils/datetime_utility';
+
+ export default {
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ lockTooltip() {
+ return `Locked by ${this.file.file_lock.user.name}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <span
+ v-if="file.file_lock"
+ v-tooltip
+ :title="lockTooltip"
+ data-container="body"
+ >
+ <icon
+ name="lock"
+ css-classes="file-status-icon"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_loading_file.vue b/app/assets/javascripts/ide/components/repo_loading_file.vue
new file mode 100644
index 00000000000..79af8c0b0c7
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_loading_file.vue
@@ -0,0 +1,42 @@
+<script>
+ import { mapState } from 'vuex';
+ import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+
+ export default {
+ components: {
+ skeletonLoadingContainer,
+ },
+ computed: {
+ ...mapState([
+ 'leftPanelCollapsed',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <tr
+ class="loading-file"
+ aria-label="Loading files"
+ >
+ <td class="multi-file-table-col-name">
+ <skeleton-loading-container
+ :small="true"
+ />
+ </td>
+ <template v-if="!leftPanelCollapsed">
+ <td class="hidden-sm hidden-xs">
+ <skeleton-loading-container
+ :small="true"
+ />
+ </td>
+
+ <td class="hidden-xs">
+ <skeleton-loading-container
+ class="animation-container-right"
+ :small="true"
+ />
+ </td>
+ </template>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
new file mode 100644
index 00000000000..c337bc813e6
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -0,0 +1,98 @@
+<script>
+ import { mapActions } from 'vuex';
+
+ import fileIcon from '~/vue_shared/components/file_icon.vue';
+ import icon from '~/vue_shared/components/icon.vue';
+ import fileStatusIcon from './repo_file_status_icon.vue';
+ import changedFileIcon from './changed_file_icon.vue';
+
+ export default {
+ components: {
+ fileStatusIcon,
+ fileIcon,
+ icon,
+ changedFileIcon,
+ },
+ props: {
+ tab: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tabMouseOver: false,
+ };
+ },
+ computed: {
+ closeLabel() {
+ if (this.tab.changed || this.tab.tempFile) {
+ return `${this.tab.name} changed`;
+ }
+ return `Close ${this.tab.name}`;
+ },
+ showChangedIcon() {
+ return this.tab.changed ? !this.tabMouseOver : false;
+ },
+ },
+
+ methods: {
+ ...mapActions([
+ 'closeFile',
+ ]),
+ clickFile(tab) {
+ this.$router.push(`/project${tab.url}`);
+ },
+ mouseOverTab() {
+ if (this.tab.changed) {
+ this.tabMouseOver = true;
+ }
+ },
+ mouseOutTab() {
+ if (this.tab.changed) {
+ this.tabMouseOver = false;
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <li
+ @click="clickFile(tab)"
+ @mouseover="mouseOverTab"
+ @mouseout="mouseOutTab"
+ >
+ <button
+ type="button"
+ class="multi-file-tab-close"
+ @click.stop.prevent="closeFile(tab.path)"
+ :aria-label="closeLabel"
+ >
+ <icon
+ v-if="!showChangedIcon"
+ name="close"
+ :size="12"
+ />
+ <changed-file-icon
+ v-else
+ :file="tab"
+ />
+ </button>
+
+ <div
+ class="multi-file-tab"
+ :class="{active : tab.active }"
+ :title="tab.url"
+ >
+ <file-icon
+ :file-name="tab.name"
+ :size="16"
+ />
+ {{ tab.name }}
+ <file-status-icon
+ :file="tab"
+ />
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
new file mode 100644
index 00000000000..8ea64ddf84a
--- /dev/null
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -0,0 +1,61 @@
+<script>
+ import { mapActions } from 'vuex';
+ import RepoTab from './repo_tab.vue';
+ import EditorMode from './editor_mode_dropdown.vue';
+
+ export default {
+ components: {
+ RepoTab,
+ EditorMode,
+ },
+ props: {
+ files: {
+ type: Array,
+ required: true,
+ },
+ viewer: {
+ type: String,
+ required: true,
+ },
+ hasChanges: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showShadow: false,
+ };
+ },
+ updated() {
+ if (!this.$refs.tabsScroller) return;
+
+ this.showShadow =
+ this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth;
+ },
+ methods: {
+ ...mapActions(['updateViewer']),
+ },
+ };
+</script>
+
+<template>
+ <div class="multi-file-tabs">
+ <ul
+ class="list-unstyled append-bottom-0"
+ ref="tabsScroller"
+ >
+ <repo-tab
+ v-for="tab in files"
+ :key="tab.key"
+ :tab="tab"
+ />
+ </ul>
+ <editor-mode
+ :viewer="viewer"
+ :show-shadow="showShadow"
+ :has-changes="hasChanges"
+ @click="updateViewer"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
new file mode 100644
index 00000000000..faa690ecba0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -0,0 +1,88 @@
+<script>
+ import { mapActions, mapState } from 'vuex';
+ import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+
+ export default {
+ components: {
+ PanelResizer,
+ },
+ props: {
+ collapsible: {
+ type: Boolean,
+ required: true,
+ },
+ initialWidth: {
+ type: Number,
+ required: true,
+ },
+ minSize: {
+ type: Number,
+ required: false,
+ default: 200,
+ },
+ side: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ width: this.initialWidth,
+ };
+ },
+ computed: {
+ ...mapState({
+ collapsed(state) {
+ return state[`${this.side}PanelCollapsed`];
+ },
+ }),
+ panelStyle() {
+ if (!this.collapsed) {
+ return {
+ width: `${this.width}px`,
+ };
+ }
+
+ return {};
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setPanelCollapsedStatus',
+ 'setResizingStatus',
+ ]),
+ toggleFullbarCollapsed() {
+ if (this.collapsed && this.collapsible) {
+ this.setPanelCollapsedStatus({
+ side: this.side,
+ collapsed: !this.collapsed,
+ });
+ }
+ },
+ },
+ maxSize: (window.innerWidth / 2),
+ };
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-panel"
+ :class="{
+ 'is-collapsed': collapsed && collapsible,
+ }"
+ :style="panelStyle"
+ @click="toggleFullbarCollapsed"
+ >
+ <slot></slot>
+ <panel-resizer
+ :size.sync="width"
+ :enabled="!collapsed"
+ :start-size="initialWidth"
+ :min-size="minSize"
+ :max-size="$options.maxSize"
+ @resize-start="setResizingStatus(true)"
+ @resize-end="setResizingStatus(false)"
+ :side="side === 'right' ? 'left' : 'right'"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/ide/eventhub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new 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..048d5316922
--- /dev/null
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -0,0 +1,97 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import flash from '~/flash';
+import store from './stores';
+
+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('getFiles', {
+ projectId: fullProjectId,
+ branchId: to.params.branch,
+ })
+ .then(() => {
+ if (to.params[0]) {
+ const treeEntry = store.state.entries[to.params[0]];
+ if (treeEntry) {
+ store.dispatch('handleTreeEntryAction', treeEntry);
+ }
+ }
+ })
+ .catch((e) => {
+ flash('Error while loading the branch files. Please try again.', 'alert', document, null, false, true);
+ throw e;
+ });
+ }
+ })
+ .catch((e) => {
+ flash('Error while loading the project data. Please try again.', 'alert', document, null, false, true);
+ 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..cbfb3dc54f2
--- /dev/null
+++ b/app/assets/javascripts/ide/index.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import ide from './components/ide.vue';
+import store from './stores';
+import router from './ide_router';
+
+function initIde(el) {
+ if (!el) return null;
+
+ return new Vue({
+ el,
+ store,
+ router,
+ components: {
+ ide,
+ },
+ render(createElement) {
+ return createElement('ide', {
+ props: {
+ emptyStateSvgPath: el.dataset.emptyStateSvgPath,
+ noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
+ committedStateSvgPath: el.dataset.committedStateSvgPath,
+ },
+ });
+ },
+ });
+}
+
+const ideElement = document.getElementById('ide');
+
+Vue.use(Translate);
+
+initIde(ideElement);
diff --git a/app/assets/javascripts/ide/lib/common/disposable.js b/app/assets/javascripts/ide/lib/common/disposable.js
new file mode 100644
index 00000000000..84b29bdb600
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/disposable.js
@@ -0,0 +1,14 @@
+export default class Disposable {
+ constructor() {
+ this.disposers = new Set();
+ }
+
+ add(...disposers) {
+ disposers.forEach(disposer => this.disposers.add(disposer));
+ }
+
+ dispose() {
+ this.disposers.forEach(disposer => disposer.dispose());
+ this.disposers.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
new file mode 100644
index 00000000000..73cd684351c
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -0,0 +1,90 @@
+/* global monaco */
+import Disposable from './disposable';
+import eventHub from '../../eventhub';
+
+export default class Model {
+ constructor(monaco, file) {
+ this.monaco = monaco;
+ this.disposable = new Disposable();
+ this.file = file;
+ this.content = file.content !== '' ? file.content : file.raw;
+
+ this.disposable.add(
+ (this.originalModel = this.monaco.editor.createModel(
+ this.file.raw,
+ undefined,
+ new this.monaco.Uri(null, null, `original/${this.file.path}`),
+ )),
+ (this.model = this.monaco.editor.createModel(
+ this.content,
+ undefined,
+ new this.monaco.Uri(null, null, this.file.path),
+ )),
+ );
+
+ this.events = new Map();
+
+ this.updateContent = this.updateContent.bind(this);
+ this.dispose = this.dispose.bind(this);
+
+ eventHub.$on(`editor.update.model.dispose.${this.file.path}`, this.dispose);
+ eventHub.$on(
+ `editor.update.model.content.${this.file.path}`,
+ this.updateContent,
+ );
+ }
+
+ get url() {
+ 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;
+ }
+
+ getModel() {
+ return this.model;
+ }
+
+ getOriginalModel() {
+ return this.originalModel;
+ }
+
+ setValue(value) {
+ this.getModel().setValue(value);
+ }
+
+ onChange(cb) {
+ this.events.set(
+ this.path,
+ this.disposable.add(this.model.onDidChangeContent(e => cb(this, e))),
+ );
+ }
+
+ updateContent(content) {
+ this.getOriginalModel().setValue(content);
+ this.getModel().setValue(content);
+ }
+
+ dispose() {
+ this.disposable.dispose();
+ this.events.clear();
+
+ eventHub.$off(
+ `editor.update.model.dispose.${this.file.path}`,
+ this.dispose,
+ );
+ eventHub.$off(
+ `editor.update.model.content.${this.file.path}`,
+ this.updateContent,
+ );
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/common/model_manager.js b/app/assets/javascripts/ide/lib/common/model_manager.js
new file mode 100644
index 00000000000..57d5e59a88b
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/common/model_manager.js
@@ -0,0 +1,51 @@
+import eventHub from '../../eventhub';
+import Disposable from './disposable';
+import Model from './model';
+
+export default class ModelManager {
+ constructor(monaco) {
+ this.monaco = monaco;
+ this.disposable = new Disposable();
+ this.models = new Map();
+ }
+
+ hasCachedModel(path) {
+ return this.models.has(path);
+ }
+
+ getModel(path) {
+ return this.models.get(path);
+ }
+
+ addModel(file) {
+ if (this.hasCachedModel(file.path)) {
+ return this.getModel(file.path);
+ }
+
+ const model = new Model(this.monaco, file);
+ this.models.set(model.path, model);
+ this.disposable.add(model);
+
+ eventHub.$on(
+ `editor.update.model.dispose.${file.path}`,
+ this.removeCachedModel.bind(this, file),
+ );
+
+ return model;
+ }
+
+ removeCachedModel(file) {
+ this.models.delete(file.path);
+
+ eventHub.$off(
+ `editor.update.model.dispose.${file.path}`,
+ this.removeCachedModel,
+ );
+ }
+
+ dispose() {
+ // dispose of all the models
+ this.disposable.dispose();
+ this.models.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/decorations/controller.js b/app/assets/javascripts/ide/lib/decorations/controller.js
new file mode 100644
index 00000000000..42904774747
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/decorations/controller.js
@@ -0,0 +1,45 @@
+export default class DecorationsController {
+ constructor(editor) {
+ this.editor = editor;
+ this.decorations = new Map();
+ this.editorDecorations = new Map();
+ }
+
+ getAllDecorationsForModel(model) {
+ if (!this.decorations.has(model.url)) return [];
+
+ const modelDecorations = this.decorations.get(model.url);
+ const decorations = [];
+
+ modelDecorations.forEach(val => decorations.push(...val));
+
+ return decorations;
+ }
+
+ addDecorations(model, decorationsKey, decorations) {
+ const decorationMap = this.decorations.get(model.url) || new Map();
+
+ decorationMap.set(decorationsKey, decorations);
+
+ this.decorations.set(model.url, decorationMap);
+
+ this.decorate(model);
+ }
+
+ decorate(model) {
+ if (!this.editor.instance) return;
+
+ const decorations = this.getAllDecorationsForModel(model);
+ const oldDecorations = this.editorDecorations.get(model.url) || [];
+
+ this.editorDecorations.set(
+ model.url,
+ this.editor.instance.deltaDecorations(oldDecorations, decorations),
+ );
+ }
+
+ dispose() {
+ this.decorations.clear();
+ this.editorDecorations.clear();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
new file mode 100644
index 00000000000..b136545ad11
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -0,0 +1,72 @@
+/* global monaco */
+import { throttle } from 'underscore';
+import DirtyDiffWorker from './diff_worker';
+import Disposable from '../common/disposable';
+
+export const getDiffChangeType = (change) => {
+ if (change.modified) {
+ return 'modified';
+ } else if (change.added) {
+ return 'added';
+ } else if (change.removed) {
+ return 'removed';
+ }
+
+ return '';
+};
+
+export const getDecorator = change => ({
+ range: new monaco.Range(
+ change.lineNumber,
+ 1,
+ change.endLineNumber,
+ 1,
+ ),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
+ },
+});
+
+export default class DirtyDiffController {
+ constructor(modelManager, decorationsController) {
+ this.disposable = new Disposable();
+ this.editorSimpleWorker = null;
+ this.modelManager = modelManager;
+ this.decorationsController = decorationsController;
+ this.dirtyDiffWorker = new DirtyDiffWorker();
+ this.throttledComputeDiff = throttle(this.computeDiff, 250);
+ this.decorate = this.decorate.bind(this);
+
+ this.dirtyDiffWorker.addEventListener('message', this.decorate);
+ }
+
+ attachModel(model) {
+ model.onChange(() => this.throttledComputeDiff(model));
+ }
+
+ computeDiff(model) {
+ this.dirtyDiffWorker.postMessage({
+ path: model.path,
+ originalContent: model.getOriginalModel().getValue(),
+ newContent: model.getModel().getValue(),
+ });
+ }
+
+ reDecorate(model) {
+ this.decorationsController.decorate(model);
+ }
+
+ decorate({ data }) {
+ const decorations = data.changes.map(change => getDecorator(change));
+ const model = this.modelManager.getModel(data.path);
+ this.decorationsController.addDecorations(model, 'dirtyDiff', decorations);
+ }
+
+ dispose() {
+ this.disposable.dispose();
+
+ this.dirtyDiffWorker.removeEventListener('message', this.decorate);
+ this.dirtyDiffWorker.terminate();
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js
new file mode 100644
index 00000000000..0e37f5c4704
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/diff.js
@@ -0,0 +1,30 @@
+import { diffLines } from 'diff';
+
+// eslint-disable-next-line import/prefer-default-export
+export const computeDiff = (originalContent, newContent) => {
+ const changes = diffLines(originalContent, newContent);
+
+ let lineNumber = 1;
+ return changes.reduce((acc, change) => {
+ const findOnLine = acc.find(c => c.lineNumber === lineNumber);
+
+ if (findOnLine) {
+ Object.assign(findOnLine, change, {
+ modified: true,
+ endLineNumber: (lineNumber + change.count) - 1,
+ });
+ } else if ('added' in change || 'removed' in change) {
+ acc.push(Object.assign({}, change, {
+ lineNumber,
+ modified: undefined,
+ endLineNumber: (lineNumber + change.count) - 1,
+ }));
+ }
+
+ if (!change.removed) {
+ lineNumber += change.count;
+ }
+
+ return acc;
+ }, []);
+};
diff --git a/app/assets/javascripts/ide/lib/diff/diff_worker.js b/app/assets/javascripts/ide/lib/diff/diff_worker.js
new file mode 100644
index 00000000000..e74c4046330
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/diff/diff_worker.js
@@ -0,0 +1,10 @@
+import { computeDiff } from './diff';
+
+self.addEventListener('message', (e) => {
+ const data = e.data;
+
+ self.postMessage({
+ path: data.path,
+ changes: computeDiff(data.originalContent, data.newContent),
+ });
+});
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
new file mode 100644
index 00000000000..38de2fe2b27
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -0,0 +1,164 @@
+import _ from 'underscore';
+import DecorationsController from './decorations/controller';
+import DirtyDiffController from './diff/controller';
+import Disposable from './common/disposable';
+import ModelManager from './common/model_manager';
+import editorOptions, { defaultEditorOptions } from './editor_options';
+import gitlabTheme from './themes/gl_theme';
+
+export const clearDomElement = el => {
+ if (!el || !el.firstChild) return;
+
+ while (el.firstChild) {
+ el.removeChild(el.firstChild);
+ }
+};
+
+export default class Editor {
+ static create(monaco) {
+ if (this.editorInstance) return this.editorInstance;
+
+ this.editorInstance = new Editor(monaco);
+
+ return this.editorInstance;
+ }
+
+ constructor(monaco) {
+ this.monaco = monaco;
+ this.currentModel = null;
+ this.instance = null;
+ this.dirtyDiffController = null;
+ this.disposable = new Disposable();
+ this.modelManager = new ModelManager(this.monaco);
+ this.decorationsController = new DecorationsController(this);
+
+ this.setupMonacoTheme();
+
+ this.debouncedUpdate = _.debounce(() => {
+ this.updateDimensions();
+ }, 200);
+ }
+
+ createInstance(domElement) {
+ if (!this.instance) {
+ clearDomElement(domElement);
+
+ this.disposable.add(
+ (this.instance = this.monaco.editor.create(domElement, {
+ ...defaultEditorOptions,
+ })),
+ (this.dirtyDiffController = new DirtyDiffController(
+ this.modelManager,
+ this.decorationsController,
+ )),
+ );
+
+ window.addEventListener('resize', this.debouncedUpdate, false);
+ }
+ }
+
+ createDiffInstance(domElement) {
+ if (!this.instance) {
+ clearDomElement(domElement);
+
+ this.disposable.add(
+ (this.instance = this.monaco.editor.createDiffEditor(domElement, {
+ ...defaultEditorOptions,
+ readOnly: true,
+ })),
+ );
+
+ window.addEventListener('resize', this.debouncedUpdate, false);
+ }
+ }
+
+ createModel(file) {
+ return this.modelManager.addModel(file);
+ }
+
+ attachModel(model) {
+ if (this.instance.getEditorType() === 'vs.editor.IDiffEditor') {
+ this.instance.setModel({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+
+ return;
+ }
+
+ this.instance.setModel(model.getModel());
+ if (this.dirtyDiffController) this.dirtyDiffController.attachModel(model);
+
+ this.currentModel = model;
+
+ this.instance.updateOptions(
+ editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach(key => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
+ });
+ return acc;
+ }, {}),
+ );
+
+ if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model);
+ }
+
+ setupMonacoTheme() {
+ this.monaco.editor.defineTheme(
+ gitlabTheme.themeName,
+ gitlabTheme.monacoTheme,
+ );
+
+ this.monaco.editor.setTheme('gitlab');
+ }
+
+ clearEditor() {
+ if (this.instance) {
+ this.instance.setModel(null);
+ }
+ }
+
+ dispose() {
+ window.removeEventListener('resize', this.debouncedUpdate);
+
+ // catch any potential errors with disposing the error
+ // this is mainly for tests caused by elements not existing
+ try {
+ this.disposable.dispose();
+
+ this.instance = null;
+ } catch (e) {
+ this.instance = null;
+
+ if (process.env.NODE_ENV !== 'test') {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+ }
+ }
+
+ updateDimensions() {
+ this.instance.layout();
+ }
+
+ setPosition({ lineNumber, column }) {
+ this.instance.revealPositionInCenter({
+ lineNumber,
+ column,
+ });
+ this.instance.setPosition({
+ lineNumber,
+ column,
+ });
+ }
+
+ onPositionChange(cb) {
+ if (!this.instance.onDidChangeCursorPosition) return;
+
+ this.disposable.add(
+ this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)),
+ );
+ }
+}
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
new file mode 100644
index 00000000000..d69d4b8c615
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -0,0 +1,15 @@
+export const defaultEditorOptions = {
+ model: null,
+ readOnly: false,
+ contextmenu: true,
+ scrollBeyondLastLine: false,
+ minimap: {
+ enabled: false,
+ },
+};
+
+export default [
+ {
+ readOnly: model => !!model.file.file_lock,
+ },
+];
diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js
new file mode 100644
index 00000000000..2fc96250c7d
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/gl_theme.js
@@ -0,0 +1,14 @@
+export default {
+ themeName: 'gitlab',
+ monacoTheme: {
+ base: 'vs',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editorLineNumber.foreground': '#CCCCCC',
+ 'diffEditor.insertedTextBackground': '#ddfbe6',
+ 'diffEditor.removedTextBackground': '#f9d7dc',
+ 'editor.selectionBackground': '#aad6f8',
+ },
+ },
+};
diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js
new file mode 100644
index 00000000000..142a220097b
--- /dev/null
+++ b/app/assets/javascripts/ide/monaco_loader.js
@@ -0,0 +1,16 @@
+import monacoContext from 'monaco-editor/dev/vs/loader';
+
+monacoContext.require.config({
+ paths: {
+ vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
+ },
+});
+
+// ignore CDN config and use local assets path for service worker which cannot be cross-domain
+const relativeRootPath = (gon && gon.relative_url_root) || '';
+const monacoPath = `${relativeRootPath}/assets/webpack/monaco-editor/vs`;
+window.MonacoEnvironment = { getWorkerUrl: () => `${monacoPath}/base/worker/workerMain.js` };
+
+// eslint-disable-next-line no-underscore-dangle
+window.__monaco_context__ = monacoContext;
+export default monacoContext.require;
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
new file mode 100644
index 00000000000..5f1fb6cf843
--- /dev/null
+++ b/app/assets/javascripts/ide/services/index.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+import Api from '~/api';
+
+Vue.use(VueResource);
+
+export default {
+ getTreeData(endpoint) {
+ return Vue.http.get(endpoint, { params: { format: 'json' } });
+ },
+ getFileData(endpoint) {
+ return Vue.http.get(endpoint, { params: { format: 'json' } });
+ },
+ getRawFileData(file) {
+ if (file.tempFile) {
+ return Promise.resolve(file.content);
+ }
+
+ if (file.raw) {
+ return Promise.resolve(file.raw);
+ }
+
+ return Vue.http.get(file.rawPath, { params: { format: 'json' } })
+ .then(res => res.text());
+ },
+ 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);
+
+ return Vue.http.post(url, payload);
+ },
+ commit(projectId, payload) {
+ return Api.commitMultiple(projectId, payload);
+ },
+ getTreeLastCommit(endpoint) {
+ return Vue.http.get(endpoint, {
+ params: {
+ format: 'json',
+ },
+ });
+ },
+ getFiles(projectUrl, branchId) {
+ const url = `${projectUrl}/files/${branchId}`;
+ return Vue.http.get(url, {
+ params: {
+ format: 'json',
+ },
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
new file mode 100644
index 00000000000..7e920aa9f30
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -0,0 +1,121 @@
+import Vue from 'vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import flash from '~/flash';
+import * as types from './mutation_types';
+import FilesDecoratorWorker from './workers/files_decorator_worker';
+
+export const redirectToUrl = (_, url) => visitUrl(url);
+
+export const setInitialData = ({ commit }, data) =>
+ commit(types.SET_INITIAL_DATA, data);
+
+export const discardAllChanges = ({ state, commit, dispatch }) => {
+ state.changedFiles.forEach(file => {
+ commit(types.DISCARD_FILE_CHANGES, file.path);
+
+ if (file.tempFile) {
+ dispatch('closeFile', file.path);
+ }
+ });
+
+ commit(types.REMOVE_ALL_CHANGES_FILES);
+};
+
+export const closeAllFiles = ({ state, dispatch }) => {
+ state.openFiles.forEach(file => dispatch('closeFile', file.path));
+};
+
+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 setResizingStatus = ({ commit }, resizing) => {
+ commit(types.SET_RESIZING_STATUS, resizing);
+};
+
+export const createTempEntry = (
+ { state, commit, dispatch },
+ { branchId, name, type, content = '', base64 = false },
+) =>
+ new Promise(resolve => {
+ const worker = new FilesDecoratorWorker();
+ const fullName =
+ name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
+
+ if (state.entries[name]) {
+ flash(
+ `The name "${name
+ .split('/')
+ .pop()}" is already taken in this directory.`,
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+
+ resolve();
+
+ return null;
+ }
+
+ worker.addEventListener('message', ({ data }) => {
+ const { file } = data;
+
+ worker.terminate();
+
+ commit(types.CREATE_TMP_ENTRY, {
+ data,
+ projectId: state.currentProjectId,
+ branchId,
+ });
+
+ if (type === 'blob') {
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ commit(types.ADD_FILE_TO_CHANGED, file.path);
+ dispatch('setFileActive', file.path);
+ }
+
+ resolve(file);
+ });
+
+ worker.postMessage({
+ data: [fullName],
+ projectId: state.currentProjectId,
+ branchId,
+ type,
+ tempFile: true,
+ base64,
+ content,
+ });
+
+ return null;
+ });
+
+export const scrollToTab = () => {
+ Vue.nextTick(() => {
+ const tabs = document.getElementById('tabs');
+
+ if (tabs) {
+ const tabEl = tabs.querySelector('.active .repo-tab');
+
+ tabEl.focus();
+ }
+ });
+};
+
+export const updateViewer = ({ commit }, viewer) => {
+ commit(types.UPDATE_VIEWER, viewer);
+};
+
+export const updateDelayViewerUpdated = ({ commit }, delay) => {
+ commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay);
+};
+
+export * from './actions/tree';
+export * from './actions/file';
+export * from './actions/project';
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
new file mode 100644
index 00000000000..ddc4b757bf9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -0,0 +1,146 @@
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import flash from '~/flash';
+import eventHub from '../../eventhub';
+import service from '../../services';
+import * as types from '../mutation_types';
+import router from '../../ide_router';
+import { setPageTitle } from '../utils';
+
+export const closeFile = ({ commit, state, getters, dispatch }, path) => {
+ const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path);
+ const file = state.entries[path];
+ const fileWasActive = file.active;
+
+ commit(types.TOGGLE_FILE_OPEN, path);
+ commit(types.SET_FILE_ACTIVE, { path, active: false });
+
+ if (state.openFiles.length > 0 && fileWasActive) {
+ const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1;
+ const nextFileToOpen = state.entries[state.openFiles[nextIndexToOpen].path];
+
+ router.push(`/project${nextFileToOpen.url}`);
+ } else if (!state.openFiles.length) {
+ router.push(`/project/${file.projectId}/tree/${file.branchId}/`);
+ }
+
+ eventHub.$emit(`editor.update.model.dispose.${file.path}`);
+};
+
+export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
+ const file = state.entries[path];
+ const currentActiveFile = getters.activeFile;
+
+ if (file.active) return;
+
+ if (currentActiveFile) {
+ commit(types.SET_FILE_ACTIVE, {
+ path: currentActiveFile.path,
+ active: false,
+ });
+ }
+
+ commit(types.SET_FILE_ACTIVE, { path, active: true });
+ dispatch('scrollToTab');
+
+ commit(types.SET_CURRENT_PROJECT, file.projectId);
+ commit(types.SET_CURRENT_BRANCH, file.branchId);
+};
+
+export const getFileData = ({ state, commit, dispatch }, file) => {
+ commit(types.TOGGLE_LOADING, { entry: file });
+
+ return service
+ .getFileData(file.url)
+ .then(res => {
+ const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']);
+
+ setPageTitle(pageTitle);
+
+ return res.json();
+ })
+ .then(data => {
+ commit(types.SET_FILE_DATA, { data, file });
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ dispatch('setFileActive', file.path);
+ commit(types.TOGGLE_LOADING, { entry: file });
+ })
+ .catch(() => {
+ commit(types.TOGGLE_LOADING, { entry: file });
+ flash(
+ 'Error loading file data. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ });
+};
+
+export const getRawFileData = ({ commit, dispatch }, file) =>
+ service
+ .getRawFileData(file)
+ .then(raw => {
+ commit(types.SET_FILE_RAW_DATA, { file, raw });
+ })
+ .catch(() =>
+ flash(
+ 'Error loading file content. Please try again.',
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ ),
+ );
+
+export const changeFileContent = ({ state, commit }, { path, content }) => {
+ const file = state.entries[path];
+ commit(types.UPDATE_FILE_CONTENT, { path, content });
+
+ const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path);
+
+ if (file.changed && indexOfChangedFile === -1) {
+ commit(types.ADD_FILE_TO_CHANGED, path);
+ } else if (!file.changed && indexOfChangedFile !== -1) {
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+ }
+};
+
+export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage });
+ }
+};
+
+export const setFileEOL = ({ getters, commit }, { eol }) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_EOL, { file: getters.activeFile, eol });
+ }
+};
+
+export const setEditorPosition = (
+ { getters, commit },
+ { editorRow, editorColumn },
+) => {
+ if (getters.activeFile) {
+ commit(types.SET_FILE_POSITION, {
+ file: getters.activeFile,
+ editorRow,
+ editorColumn,
+ });
+ }
+};
+
+export const discardFileChanges = ({ state, commit }, path) => {
+ const file = state.entries[path];
+
+ commit(types.DISCARD_FILE_CHANGES, path);
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+
+ if (file.tempFile && file.opened) {
+ commit(types.TOGGLE_FILE_OPEN, path);
+ }
+
+ eventHub.$emit(`editor.update.model.content.${file.path}`, file.raw);
+};
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..b3882cb8d21
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -0,0 +1,49 @@
+import flash from '~/flash';
+import service from '../../services';
+import * as types from '../mutation_types';
+
+export const getProjectData = (
+ { commit, state, dispatch },
+ { namespace, projectId, force = false } = {},
+) => new Promise((resolve, reject) => {
+ if (!state.projects[`${namespace}/${projectId}`] || force) {
+ commit(types.TOGGLE_LOADING, { entry: state });
+ service.getProjectData(namespace, projectId)
+ .then(res => res.data)
+ .then((data) => {
+ commit(types.TOGGLE_LOADING, { entry: state });
+ 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.', 'alert', document, null, false, true);
+ reject(new Error(`Project not loaded ${namespace}/${projectId}`));
+ });
+ } else {
+ resolve(state.projects[`${namespace}/${projectId}`]);
+ }
+});
+
+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.', 'alert', document, null, false, true);
+ reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
+ });
+ } else {
+ resolve(state.projects[`${projectId}`].branches[branchId]);
+ }
+});
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..70a969a0325
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -0,0 +1,93 @@
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import flash from '~/flash';
+import service from '../../services';
+import * as types from '../mutation_types';
+import {
+ findEntry,
+} from '../utils';
+import FilesDecoratorWorker from '../workers/files_decorator_worker';
+
+export const toggleTreeOpen = ({ commit, dispatch }, path) => {
+ commit(types.TOGGLE_TREE_OPEN, path);
+};
+
+export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
+ if (row.type === 'tree') {
+ dispatch('toggleTreeOpen', row.path);
+ } else if (row.type === 'blob' && (row.opened || row.changed)) {
+ if (row.changed && !row.opened) {
+ commit(types.TOGGLE_FILE_OPEN, row.path);
+ }
+
+ dispatch('setFileActive', row.path);
+ } else {
+ dispatch('getFileData', row);
+ }
+};
+
+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.', 'alert', document, null, false, true));
+};
+
+export const getFiles = (
+ { state, commit, dispatch },
+ { projectId, branchId } = {},
+) => new Promise((resolve, reject) => {
+ if (!state.trees[`${projectId}/${branchId}`]) {
+ const selectedProject = state.projects[projectId];
+ commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
+
+ service
+ .getFiles(selectedProject.web_url, branchId)
+ .then(res => res.json())
+ .then((data) => {
+ const worker = new FilesDecoratorWorker();
+ worker.addEventListener('message', (e) => {
+ const { entries, treeList } = e.data;
+ const selectedTree = state.trees[`${projectId}/${branchId}`];
+
+ commit(types.SET_ENTRIES, entries);
+ commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList });
+ commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false });
+
+ worker.terminate();
+
+ resolve();
+ });
+
+ worker.postMessage({
+ data,
+ projectId,
+ branchId,
+ });
+ })
+ .catch((e) => {
+ flash('Error loading tree data. Please try again.', 'alert', document, null, false, true);
+ reject(e);
+ });
+ } else {
+ resolve();
+ }
+});
+
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
new file mode 100644
index 00000000000..eba325a31df
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -0,0 +1,30 @@
+export const activeFile = state =>
+ state.openFiles.find(file => file.active) || null;
+
+export const addedFiles = state => state.changedFiles.filter(f => f.tempFile);
+
+export const modifiedFiles = state =>
+ state.changedFiles.filter(f => !f.tempFile);
+
+export const projectsWithTrees = state =>
+ Object.keys(state.projects).map(projectId => {
+ const project = state.projects[projectId];
+
+ return {
+ ...project,
+ branches: Object.keys(project.branches).map(branchId => {
+ const branch = project.branches[branchId];
+
+ return {
+ ...branch,
+ tree: state.trees[branch.treeId],
+ };
+ }),
+ };
+ });
+
+// eslint-disable-next-line no-confusing-arrow
+export const currentIcon = state =>
+ state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
+
+export const hasChanges = state => !!state.changedFiles.length;
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
new file mode 100644
index 00000000000..7c82ce7976b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import commitModule from './modules/commit';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: state(),
+ actions,
+ mutations,
+ getters,
+ modules: {
+ commit: commitModule,
+ },
+});
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
new file mode 100644
index 00000000000..f536ce6344b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -0,0 +1,218 @@
+import $ from 'jquery';
+import { sprintf, __ } from '~/locale';
+import flash from '~/flash';
+import { stripHtml } from '~/lib/utils/text_utility';
+import * as rootTypes from '../../mutation_types';
+import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
+import router from '../../../ide_router';
+import service from '../../../services';
+import * as types from './mutation_types';
+import * as consts from './constants';
+import eventHub from '../../../eventhub';
+
+export const updateCommitMessage = ({ commit }, message) => {
+ commit(types.UPDATE_COMMIT_MESSAGE, message);
+};
+
+export const discardDraft = ({ commit }) => {
+ commit(types.UPDATE_COMMIT_MESSAGE, '');
+};
+
+export const updateCommitAction = ({ commit }, commitAction) => {
+ commit(types.UPDATE_COMMIT_ACTION, commitAction);
+};
+
+export const updateBranchName = ({ commit }, branchName) => {
+ commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
+};
+
+export const setLastCommitMessage = ({ rootState, commit }, data) => {
+ const currentProject = rootState.projects[rootState.currentProjectId];
+ const commitStats = data.stats
+ ? sprintf(__('with %{additions} additions, %{deletions} deletions.'), {
+ additions: data.stats.additions, // eslint-disable-line indent
+ deletions: data.stats.deletions, // eslint-disable-line indent
+ }) // eslint-disable-line indent
+ : '';
+ const commitMsg = sprintf(
+ __('Your changes have been committed. Commit %{commitId} %{commitStats}'),
+ {
+ commitId: `<a href="${currentProject.web_url}/commit/${
+ data.short_id
+ }" class="commit-sha">${data.short_id}</a>`,
+ commitStats,
+ },
+ false,
+ );
+
+ commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true });
+};
+
+export const checkCommitStatus = ({ rootState }) =>
+ service
+ .getBranchData(rootState.currentProjectId, rootState.currentBranchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ const selectedBranch =
+ rootState.projects[rootState.currentProjectId].branches[
+ rootState.currentBranchId
+ ];
+
+ if (selectedBranch.workingReference !== id) {
+ return true;
+ }
+
+ return false;
+ })
+ .catch(() =>
+ flash(
+ __('Error checking branch data. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ ),
+ );
+
+export const updateFilesAfterCommit = (
+ { commit, dispatch, state, rootState, rootGetters },
+ { data, branch },
+) => {
+ const selectedProject = rootState.projects[rootState.currentProjectId];
+ const lastCommit = {
+ commit_path: `${selectedProject.web_url}/commit/${data.id}`,
+ commit: {
+ id: data.id,
+ message: data.message,
+ authored_date: data.committed_date,
+ author_name: data.committer_name,
+ },
+ };
+
+ commit(
+ rootTypes.SET_BRANCH_WORKING_REFERENCE,
+ {
+ projectId: rootState.currentProjectId,
+ branchId: rootState.currentBranchId,
+ reference: data.id,
+ },
+ { root: true },
+ );
+
+ rootState.changedFiles.forEach(entry => {
+ commit(
+ rootTypes.SET_LAST_COMMIT_DATA,
+ {
+ entry,
+ lastCommit,
+ },
+ { root: true },
+ );
+
+ eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content);
+
+ commit(
+ rootTypes.SET_FILE_RAW_DATA,
+ {
+ file: entry,
+ raw: entry.content,
+ },
+ { root: true },
+ );
+
+ commit(
+ rootTypes.TOGGLE_FILE_CHANGED,
+ {
+ file: entry,
+ changed: false,
+ },
+ { root: true },
+ );
+ });
+
+ commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true });
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
+ router.push(
+ `/project/${rootState.currentProjectId}/blob/${branch}/${
+ rootGetters.activeFile.path
+ }`,
+ );
+ }
+
+ dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH);
+};
+
+export const commitChanges = ({
+ commit,
+ state,
+ getters,
+ dispatch,
+ rootState,
+}) => {
+ const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
+ const payload = createCommitPayload(
+ getters.branchName,
+ newBranch,
+ state,
+ rootState,
+ );
+ const getCommitStatus = newBranch
+ ? Promise.resolve(false)
+ : dispatch('checkCommitStatus');
+
+ commit(types.UPDATE_LOADING, true);
+
+ return getCommitStatus
+ .then(
+ branchChanged =>
+ new Promise(resolve => {
+ if (branchChanged) {
+ // show the modal with a Bootstrap call
+ $('#ide-create-branch-modal').modal('show');
+ } else {
+ resolve();
+ }
+ }),
+ )
+ .then(() => service.commit(rootState.currentProjectId, payload))
+ .then(({ data }) => {
+ commit(types.UPDATE_LOADING, false);
+
+ if (!data.short_id) {
+ flash(data.message, 'alert', document, null, false, true);
+ return;
+ }
+
+ dispatch('setLastCommitMessage', data);
+ dispatch('updateCommitMessage', '');
+
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
+ dispatch(
+ 'redirectToUrl',
+ createNewMergeRequestUrl(
+ rootState.projects[rootState.currentProjectId].web_url,
+ getters.branchName,
+ rootState.currentBranchId,
+ ),
+ { root: true },
+ );
+ } else {
+ dispatch('updateFilesAfterCommit', {
+ data,
+ branch: getters.branchName,
+ });
+ }
+ })
+ .catch(err => {
+ let errMsg = __('Error committing changes. Please try again.');
+ if (err.response.data && err.response.data.message) {
+ errMsg += ` (${stripHtml(err.response.data.message)})`;
+ }
+ flash(errMsg, 'alert', document, null, false, true);
+ window.dispatchEvent(new Event('resize'));
+
+ commit(types.UPDATE_LOADING, false);
+ });
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js
new file mode 100644
index 00000000000..230b0a3d9b5
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js
@@ -0,0 +1,3 @@
+export const COMMIT_TO_CURRENT_BRANCH = '1';
+export const COMMIT_TO_NEW_BRANCH = '2';
+export const COMMIT_TO_NEW_BRANCH_MR = '3';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
new file mode 100644
index 00000000000..f7cdd6adb0c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -0,0 +1,24 @@
+import * as consts from './constants';
+
+export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading;
+
+export const commitButtonDisabled = (state, getters, rootState) =>
+ getters.discardDraftButtonDisabled || !rootState.changedFiles.length;
+
+export const newBranchName = (state, _, rootState) =>
+ `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(-5)}`;
+
+export const branchName = (state, getters, rootState) => {
+ if (
+ state.commitAction === consts.COMMIT_TO_NEW_BRANCH ||
+ state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR
+ ) {
+ if (state.newBranchName === '') {
+ return getters.newBranchName;
+ }
+
+ return state.newBranchName;
+ }
+
+ return rootState.currentBranchId;
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/index.js b/app/assets/javascripts/ide/stores/modules/commit/index.js
new file mode 100644
index 00000000000..3bf65b02847
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/index.js
@@ -0,0 +1,12 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+import * as getters from './getters';
+
+export default {
+ namespaced: true,
+ state: state(),
+ mutations,
+ actions,
+ getters,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
new file mode 100644
index 00000000000..9221f054e9f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
@@ -0,0 +1,4 @@
+export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE';
+export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
+export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
+export const UPDATE_LOADING = 'UPDATE_LOADING';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
new file mode 100644
index 00000000000..797357e3df9
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
@@ -0,0 +1,24 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.UPDATE_COMMIT_MESSAGE](state, commitMessage) {
+ Object.assign(state, {
+ commitMessage,
+ });
+ },
+ [types.UPDATE_COMMIT_ACTION](state, commitAction) {
+ Object.assign(state, {
+ commitAction,
+ });
+ },
+ [types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
+ Object.assign(state, {
+ newBranchName,
+ });
+ },
+ [types.UPDATE_LOADING](state, submitCommitLoading) {
+ Object.assign(state, {
+ submitCommitLoading,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js
new file mode 100644
index 00000000000..8dae50961b0
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/commit/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ commitMessage: '',
+ commitAction: '1',
+ newBranchName: '',
+ submitCommitLoading: false,
+});
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
new file mode 100644
index 00000000000..e28f190897c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -0,0 +1,43 @@
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
+export const TOGGLE_LOADING = 'TOGGLE_LOADING';
+export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
+export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
+export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
+export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
+export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
+
+// 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 SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL';
+export const CREATE_TREE = 'CREATE_TREE';
+export const REMOVE_ALL_CHANGES_FILES = 'REMOVE_ALL_CHANGES_FILES';
+
+// File mutation types
+export const SET_FILE_DATA = 'SET_FILE_DATA';
+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 ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
+export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';
+export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED';
+export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH';
+export const SET_ENTRIES = 'SET_ENTRIES';
+export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY';
+export const UPDATE_VIEWER = 'UPDATE_VIEWER';
+export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
new file mode 100644
index 00000000000..da41fc9285c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -0,0 +1,106 @@
+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';
+
+export default {
+ [types.SET_INITIAL_DATA](state, data) {
+ Object.assign(state, data);
+ },
+ [types.TOGGLE_LOADING](state, { entry, forceValue = undefined }) {
+ if (entry.path) {
+ Object.assign(state.entries[entry.path], {
+ loading:
+ forceValue !== undefined
+ ? forceValue
+ : !state.entries[entry.path].loading,
+ });
+ } else {
+ Object.assign(entry, {
+ loading: forceValue !== undefined ? forceValue : !entry.loading,
+ });
+ }
+ },
+ [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) {
+ Object.assign(state, {
+ leftPanelCollapsed: collapsed,
+ });
+ },
+ [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) {
+ Object.assign(state, {
+ rightPanelCollapsed: collapsed,
+ });
+ },
+ [types.SET_RESIZING_STATUS](state, resizing) {
+ Object.assign(state, {
+ panelResizing: resizing,
+ });
+ },
+ [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,
+ });
+ },
+ [types.SET_LAST_COMMIT_MSG](state, lastCommitMsg) {
+ Object.assign(state, {
+ lastCommitMsg,
+ });
+ },
+ [types.SET_ENTRIES](state, entries) {
+ Object.assign(state, {
+ entries,
+ });
+ },
+ [types.CREATE_TMP_ENTRY](state, { data, projectId, branchId }) {
+ Object.keys(data.entries).reduce((acc, key) => {
+ const entry = data.entries[key];
+ const foundEntry = state.entries[key];
+
+ if (!foundEntry) {
+ Object.assign(state.entries, {
+ [key]: entry,
+ });
+ } else {
+ const tree = entry.tree.filter(
+ f => foundEntry.tree.find(e => e.path === f.path) === undefined,
+ );
+ Object.assign(foundEntry, {
+ tree: foundEntry.tree.concat(tree),
+ });
+ }
+
+ return acc.concat(key);
+ }, []);
+
+ const foundEntry = state.trees[`${projectId}/${branchId}`].tree.find(
+ e => e.path === data.treeList[0].path,
+ );
+
+ if (!foundEntry) {
+ Object.assign(state.trees[`${projectId}/${branchId}`], {
+ tree: state.trees[`${projectId}/${branchId}`].tree.concat(
+ data.treeList,
+ ),
+ });
+ }
+ },
+ [types.UPDATE_VIEWER](state, viewer) {
+ Object.assign(state, {
+ viewer,
+ });
+ },
+ [types.UPDATE_DELAY_VIEWER_CHANGE](state, delayViewerUpdated) {
+ Object.assign(state, {
+ delayViewerUpdated,
+ });
+ },
+ ...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..2972ba5e38e
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/branch.js
@@ -0,0 +1,26 @@
+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 }) {
+ Object.assign(state.projects[projectPath], {
+ branches: {
+ [branchName]: {
+ ...branch,
+ treeId: `${projectPath}/${branchName}`,
+ active: true,
+ workingReference: '',
+ },
+ },
+ });
+ },
+ [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) {
+ Object.assign(state.projects[projectId].branches[branchId], {
+ workingReference: reference,
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
new file mode 100644
index 00000000000..2500f13db7c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -0,0 +1,83 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.SET_FILE_ACTIVE](state, { path, active }) {
+ Object.assign(state.entries[path], {
+ active,
+ });
+ },
+ [types.TOGGLE_FILE_OPEN](state, path) {
+ Object.assign(state.entries[path], {
+ opened: !state.entries[path].opened,
+ });
+
+ if (state.entries[path].opened) {
+ state.openFiles.push(state.entries[path]);
+ } else {
+ Object.assign(state, {
+ openFiles: state.openFiles.filter(f => f.path !== path),
+ });
+ }
+ },
+ [types.SET_FILE_DATA](state, { data, file }) {
+ Object.assign(state.entries[file.path], {
+ id: data.id,
+ blamePath: data.blame_path,
+ commitsPath: data.commits_path,
+ permalink: data.permalink,
+ rawPath: data.raw_path,
+ binary: data.binary,
+ renderError: data.render_error,
+ });
+ },
+ [types.SET_FILE_RAW_DATA](state, { file, raw }) {
+ Object.assign(state.entries[file.path], {
+ raw,
+ });
+ },
+ [types.UPDATE_FILE_CONTENT](state, { path, content }) {
+ const changed = content !== state.entries[path].raw;
+
+ Object.assign(state.entries[path], {
+ content,
+ changed,
+ });
+ },
+ [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) {
+ Object.assign(state.entries[file.path], {
+ fileLanguage,
+ });
+ },
+ [types.SET_FILE_EOL](state, { file, eol }) {
+ Object.assign(state.entries[file.path], {
+ eol,
+ });
+ },
+ [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
+ Object.assign(state.entries[file.path], {
+ editorRow,
+ editorColumn,
+ });
+ },
+ [types.DISCARD_FILE_CHANGES](state, path) {
+ Object.assign(state.entries[path], {
+ content: state.entries[path].raw,
+ changed: false,
+ });
+ },
+ [types.ADD_FILE_TO_CHANGED](state, path) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.concat(state.entries[path]),
+ });
+ },
+ [types.REMOVE_FILE_FROM_CHANGED](state, path) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.filter(f => f.path !== path),
+ });
+ },
+ [types.TOGGLE_FILE_CHANGED](state, { file, changed }) {
+ Object.assign(state.entries[file.path], {
+ changed,
+ });
+ },
+};
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/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
new file mode 100644
index 00000000000..7f7e470c9bb
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -0,0 +1,38 @@
+import * as types from '../mutation_types';
+
+export default {
+ [types.TOGGLE_TREE_OPEN](state, path) {
+ Object.assign(state.entries[path], {
+ opened: !state.entries[path].opened,
+ });
+ },
+ [types.CREATE_TREE](state, { treePath }) {
+ Object.assign(state, {
+ trees: Object.assign({}, state.trees, {
+ [treePath]: {
+ tree: [],
+ loading: true,
+ },
+ }),
+ });
+ },
+ [types.SET_DIRECTORY_DATA](state, { data, treePath }) {
+ Object.assign(state, {
+ trees: Object.assign(state.trees, {
+ [treePath]: {
+ tree: data,
+ },
+ }),
+ });
+ },
+ [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
+ Object.assign(tree, {
+ lastCommitPath: url,
+ });
+ },
+ [types.REMOVE_ALL_CHANGES_FILES](state) {
+ Object.assign(state, {
+ changedFiles: [],
+ });
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
new file mode 100644
index 00000000000..6110f54951c
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -0,0 +1,19 @@
+export default () => ({
+ currentProjectId: '',
+ currentBranchId: '',
+ changedFiles: [],
+ endpoints: {},
+ lastCommitMsg: '',
+ lastCommitPath: '',
+ loading: false,
+ openFiles: [],
+ parentTreeUrl: '',
+ trees: {},
+ projects: {},
+ leftPanelCollapsed: false,
+ rightPanelCollapsed: false,
+ panelResizing: false,
+ entries: {},
+ viewer: 'editor',
+ delayViewerUpdated: false,
+});
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
new file mode 100644
index 00000000000..487ea1ead8e
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -0,0 +1,125 @@
+export const dataStructure = () => ({
+ id: '',
+ key: '',
+ type: '',
+ projectId: '',
+ branchId: '',
+ name: '',
+ url: '',
+ path: '',
+ tempFile: false,
+ tree: [],
+ loading: false,
+ opened: false,
+ active: false,
+ changed: false,
+ lastCommitPath: '',
+ lastCommit: {
+ id: '',
+ url: '',
+ message: '',
+ updatedAt: '',
+ author: '',
+ },
+ blamePath: '',
+ commitsPath: '',
+ permalink: '',
+ rawPath: '',
+ binary: false,
+ html: '',
+ raw: '',
+ content: '',
+ parentTreeUrl: '',
+ renderError: false,
+ base64: false,
+ editorRow: 1,
+ editorColumn: 1,
+ fileLanguage: '',
+ eol: '',
+});
+
+export const decorateData = (entity) => {
+ const {
+ id,
+ projectId,
+ branchId,
+ type,
+ url,
+ name,
+ path,
+ renderError,
+ content = '',
+ tempFile = false,
+ active = false,
+ opened = false,
+ changed = false,
+ parentTreeUrl = '',
+ base64 = false,
+
+ file_lock,
+
+ } = entity;
+
+ return {
+ ...dataStructure(),
+ id,
+ projectId,
+ branchId,
+ key: `${name}-${type}-${id}`,
+ type,
+ name,
+ url,
+ path,
+ tempFile,
+ opened,
+ active,
+ parentTreeUrl,
+ changed,
+ renderError,
+ content,
+ base64,
+
+ file_lock,
+
+ };
+};
+
+export const findEntry = (tree, type, name, prop = 'name') => tree.find(
+ f => f.type === type && f[prop] === name,
+);
+
+export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path);
+
+export const setPageTitle = (title) => {
+ document.title = title;
+};
+
+export const createCommitPayload = (branch, newBranch, state, rootState) => ({
+ branch,
+ commit_message: state.commitMessage,
+ actions: rootState.changedFiles.map(f => ({
+ action: f.tempFile ? 'create' : 'update',
+ file_path: f.path,
+ content: f.content,
+ encoding: f.base64 ? 'base64' : 'text',
+ })),
+ start_branch: newBranch ? rootState.currentBranchId : undefined,
+});
+
+export const createNewMergeRequestUrl = (projectUrl, source, target) =>
+ `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`;
+
+const sortTreesByTypeAndName = (a, b) => {
+ if (a.type === 'tree' && b.type === 'blob') {
+ return -1;
+ } else if (a.type === 'blob' && b.type === 'tree') {
+ return 1;
+ }
+ if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
+ if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
+ return 0;
+};
+
+export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, {
+ tree: entity.tree.length ? sortTree(entity.tree) : [],
+})).sort(sortTreesByTypeAndName);
diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
new file mode 100644
index 00000000000..e959130300b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
@@ -0,0 +1,91 @@
+import {
+ decorateData,
+ sortTree,
+} from '../utils';
+
+self.addEventListener('message', (e) => {
+ const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
+
+ const treeList = [];
+ let file;
+ const entries = data.reduce((acc, path) => {
+ const pathSplit = path.split('/');
+ const blobName = pathSplit.pop().trim();
+
+ if (pathSplit.length > 0) {
+ pathSplit.reduce((pathAcc, folderName) => {
+ const parentFolder = acc[pathAcc[pathAcc.length - 1]];
+ const folderPath = `${(parentFolder ? `${parentFolder.path}/` : '')}${folderName}`;
+ const foundEntry = acc[folderPath];
+
+ if (!foundEntry) {
+ const tree = decorateData({
+ projectId,
+ branchId,
+ id: folderPath,
+ name: folderName,
+ path: folderPath,
+ url: `/${projectId}/tree/${branchId}/${folderPath}`,
+ type: 'tree',
+ parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
+ tempFile,
+ changed: tempFile,
+ opened: tempFile,
+ });
+
+ Object.assign(acc, {
+ [folderPath]: tree,
+ });
+
+ if (parentFolder) {
+ parentFolder.tree.push(tree);
+ } else {
+ treeList.push(tree);
+ }
+
+ pathAcc.push(tree.path);
+ } else {
+ pathAcc.push(foundEntry.path);
+ }
+
+ return pathAcc;
+ }, []);
+ }
+
+ if (blobName !== '') {
+ const fileFolder = acc[pathSplit.join('/')];
+ file = decorateData({
+ projectId,
+ branchId,
+ id: path,
+ name: blobName,
+ path,
+ url: `/${projectId}/blob/${branchId}/${path}`,
+ type: 'blob',
+ parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
+ tempFile,
+ changed: tempFile,
+ content,
+ base64,
+ });
+
+ Object.assign(acc, {
+ [path]: file,
+ });
+
+ if (fileFolder) {
+ fileFolder.tree.push(file);
+ } else {
+ treeList.push(file);
+ }
+ }
+
+ return acc;
+ }, {});
+
+ self.postMessage({
+ entries,
+ treeList: sortTree(treeList),
+ file,
+ });
+});
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 470e3e5c52e..5a16adea4dc 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -1,28 +1,25 @@
/* eslint-disable import/prefer-default-export, func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, quotes, one-var, one-var-declaration-per-line, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, no-empty, max-len, consistent-return, no-unused-vars, no-return-assign, max-len, vars-on-top */
-
import $ from 'jquery';
+import { insertText } from '~/lib/utils/common_utils';
-const textUtils = {};
-
-textUtils.selectedText = function(text, textarea) {
+function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
-};
+}
-textUtils.lineBefore = function(text, textarea) {
+function lineBefore(text, textarea) {
var split;
split = text.substring(0, textarea.selectionStart).trim().split('\n');
return split[split.length - 1];
-};
+}
-textUtils.lineAfter = function(text, textarea) {
+function lineAfter(text, textarea) {
return text.substring(textarea.selectionEnd).trim().split('\n')[0];
-};
+}
-textUtils.blockTagText = function(text, textArea, blockTag, selected) {
- var lineAfter, lineBefore;
- lineBefore = this.lineBefore(text, textArea);
- lineAfter = this.lineAfter(text, textArea);
- if (lineBefore === blockTag && lineAfter === blockTag) {
+function blockTagText(text, textArea, blockTag, selected) {
+ const before = lineBefore(text, textArea);
+ const after = lineAfter(text, textArea);
+ if (before === blockTag && after === blockTag) {
// To remove the block tag we have to select the line before & after
if (blockTag != null) {
textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
@@ -32,10 +29,30 @@ textUtils.blockTagText = function(text, textArea, blockTag, selected) {
} else {
return blockTag + "\n" + selected + "\n" + blockTag;
}
-};
+}
-textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
- var insertText, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
+function moveCursor(textArea, tag, wrapped, removedLastNewLine) {
+ var pos;
+ if (!textArea.setSelectionRange) {
+ return;
+ }
+ if (textArea.selectionStart === textArea.selectionEnd) {
+ if (wrapped) {
+ pos = textArea.selectionStart - tag.length;
+ } else {
+ pos = textArea.selectionStart;
+ }
+
+ if (removedLastNewLine) {
+ pos -= 1;
+ }
+
+ return textArea.setSelectionRange(pos, pos);
+ }
+}
+
+export function insertMarkdownText(textArea, text, tag, blockTag, selected, wrap) {
+ var textToInsert, inserted, selectedSplit, startChar, removedLastNewLine, removedFirstNewLine, currentLineEmpty, lastNewLine;
removedLastNewLine = false;
removedFirstNewLine = false;
currentLineEmpty = false;
@@ -67,9 +84,9 @@ textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') {
- insertText = this.blockTagText(text, textArea, blockTag, selected);
+ textToInsert = blockTagText(text, textArea, blockTag, selected);
} else {
- insertText = selectedSplit.map(function(val) {
+ textToInsert = selectedSplit.map(function(val) {
if (val.indexOf(tag) === 0) {
return "" + (val.replace(tag, ''));
} else {
@@ -78,78 +95,42 @@ textUtils.insertText = function(textArea, text, tag, blockTag, selected, wrap) {
}).join('\n');
}
} else {
- insertText = "" + startChar + tag + selected + (wrap ? tag : ' ');
+ textToInsert = "" + startChar + tag + selected + (wrap ? tag : ' ');
}
if (removedFirstNewLine) {
- insertText = '\n' + insertText;
+ textToInsert = '\n' + textToInsert;
}
if (removedLastNewLine) {
- insertText += '\n';
+ textToInsert += '\n';
}
- if (document.queryCommandSupported('insertText')) {
- inserted = document.execCommand('insertText', false, insertText);
- }
- if (!inserted) {
- try {
- document.execCommand("ms-beginUndoUnit");
- } catch (error) {}
- textArea.value = this.replaceRange(text, textArea.selectionStart, textArea.selectionEnd, insertText);
- try {
- document.execCommand("ms-endUndoUnit");
- } catch (error) {}
- }
- return this.moveCursor(textArea, tag, wrap, removedLastNewLine);
-};
+ insertText(textArea, textToInsert);
+ return moveCursor(textArea, tag, wrap, removedLastNewLine);
+}
-textUtils.moveCursor = function(textArea, tag, wrapped, removedLastNewLine) {
- var pos;
- if (!textArea.setSelectionRange) {
- return;
- }
- if (textArea.selectionStart === textArea.selectionEnd) {
- if (wrapped) {
- pos = textArea.selectionStart - tag.length;
- } else {
- pos = textArea.selectionStart;
- }
-
- if (removedLastNewLine) {
- pos -= 1;
- }
-
- return textArea.setSelectionRange(pos, pos);
- }
-};
-
-textUtils.updateText = function(textArea, tag, blockTag, wrap) {
+function updateText(textArea, tag, blockTag, wrap) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
- selected = this.selectedText(text, textArea);
+ selected = selectedText(text, textArea);
$textArea.focus();
- return this.insertText(textArea, text, tag, blockTag, selected, wrap);
-};
+ return insertMarkdownText(textArea, text, tag, blockTag, selected, wrap);
+}
-textUtils.init = function(form) {
- var self;
- self = this;
+function replaceRange(s, start, end, substitute) {
+ return s.substring(0, start) + substitute + s.substring(end);
+}
+
+export function addMarkdownListeners(form) {
return $('.js-md', form).off('click').on('click', function() {
- var $this;
- $this = $(this);
- return self.updateText($this.closest('.md-area').find('textarea'), $this.data('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend'));
+ const $this = $(this);
+ return updateText($this.closest('.md-area').find('textarea'), $this.data('mdTag'), $this.data('mdBlock'), !$this.data('mdPrepend'));
});
-};
+}
-textUtils.removeListeners = function(form) {
+export function removeMarkdownListeners(form) {
return $('.js-md', form).off('click');
-};
-
-textUtils.replaceRange = function(s, start, end, substitute) {
- return s.substring(0, start) + substitute + s.substring(end);
-};
-
-export default textUtils;
+}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 870285f7940..cedb6ef19f7 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -32,7 +32,6 @@ import LazyLoader from './lazy_loader';
import initLogoAnimation from './logo';
import './milestone_select';
import './projects_dropdown';
-import './render_gfm';
import initBreadcrumbs from './breadcrumb';
import initDispatcher from './dispatcher';
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 8ca94ef3e2a..10b3a4d2fee 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -73,6 +73,10 @@
type: String,
required: true,
},
+ emptyNoDataSvgPath: {
+ type: String,
+ required: true,
+ },
emptyUnableToConnectSvgPath: {
type: String,
required: true,
@@ -188,6 +192,7 @@
:clusters-path="clustersPath"
:empty-getting-started-svg-path="emptyGettingStartedSvgPath"
:empty-loading-svg-path="emptyLoadingSvgPath"
+ :empty-no-data-svg-path="emptyNoDataSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
/>
</template>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index 9517b8ccb67..fbf451fce68 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -27,6 +27,10 @@
type: String,
required: true,
},
+ emptyNoDataSvgPath: {
+ type: String,
+ required: true,
+ },
emptyUnableToConnectSvgPath: {
type: String,
required: true,
@@ -54,7 +58,7 @@
buttonPath: this.documentationPath,
},
noData: {
- svgUrl: this.emptyUnableToConnectSvgPath,
+ svgUrl: this.emptyNoDataSvgPath,
title: 'No data found',
description: `You are connected to the Prometheus server, but there is currently
no data to display.`,
diff --git a/app/assets/javascripts/performance_bar.js b/app/assets/javascripts/performance_bar.js
deleted file mode 100644
index c22598ee665..00000000000
--- a/app/assets/javascripts/performance_bar.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import $ from 'jquery';
-import 'vendor/peek';
-import 'vendor/peek.performance_bar';
-import { getParameterValues } from './lib/utils/url_utility';
-
-export default class PerformanceBar {
- constructor(opts) {
- if (!PerformanceBar.singleton) {
- this.init(opts);
- PerformanceBar.singleton = this;
- }
- return PerformanceBar.singleton;
- }
-
- init(opts) {
- const $container = $(opts.container);
- this.$lineProfileLink = $container.find('.js-toggle-modal-peek-line-profile');
- this.$lineProfileModal = $('#modal-peek-line-profile');
- this.initEventListeners();
- this.showModalOnLoad();
- }
-
- initEventListeners() {
- this.$lineProfileLink.on('click', e => this.handleLineProfileLink(e));
- $(document).on('click', '.js-lineprof-file', PerformanceBar.toggleLineProfileFile);
- }
-
- showModalOnLoad() {
- // When a lineprofiler query-string param is present, we show the line
- // profiler modal upon page load
- if (/lineprofiler/.test(window.location.search)) {
- PerformanceBar.toggleModal(this.$lineProfileModal);
- }
- }
-
- handleLineProfileLink(e) {
- const lineProfilerParameter = getParameterValues('lineprofiler');
- const lineProfilerParameterRegex = new RegExp(`lineprofiler=${lineProfilerParameter[0]}`);
- const shouldToggleModal = lineProfilerParameter.length > 0 &&
- lineProfilerParameterRegex.test(e.currentTarget.href);
-
- if (shouldToggleModal) {
- e.preventDefault();
- PerformanceBar.toggleModal(this.$lineProfileModal);
- }
- }
-
- static toggleModal($modal) {
- if ($modal.length) {
- $modal.modal('toggle');
- }
- }
-
- static toggleLineProfileFile(e) {
- $(e.currentTarget).parents('.peek-rblineprof-file').find('.data').toggle();
- }
-}
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
new file mode 100644
index 00000000000..145465f4ee9
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -0,0 +1,78 @@
+<script>
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ metric: {
+ type: String,
+ required: true,
+ },
+ header: {
+ type: String,
+ required: true,
+ },
+ details: {
+ type: String,
+ required: true,
+ },
+ keys: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :id="`peek-view-${metric}`"
+ class="view"
+ >
+ <button
+ :data-target="`#modal-peek-${metric}-details`"
+ class="btn-blank btn-link bold"
+ type="button"
+ data-toggle="modal"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="bold"
+ >
+ {{ currentRequest.details[metric].duration }}
+ /
+ {{ currentRequest.details[metric].calls }}
+ </span>
+ </button>
+ <gl-modal
+ v-if="currentRequest.details"
+ :id="`modal-peek-${metric}-details`"
+ :header-title-text="header"
+ class="performance-bar-modal"
+ >
+ <table class="table">
+ <tr
+ v-for="(item, index) in currentRequest.details[metric][details]"
+ :key="index"
+ >
+ <td><strong>{{ item.duration }}ms</strong></td>
+ <td
+ v-for="key in keys"
+ :key="key"
+ >
+ {{ item[key] }}
+ </td>
+ </tr>
+ </table>
+
+ <div slot="footer">
+ </div>
+ </gl-modal>
+ {{ metric }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
new file mode 100644
index 00000000000..88345cf2ad9
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -0,0 +1,191 @@
+<script>
+import $ from 'jquery';
+
+import PerformanceBarService from '../services/performance_bar_service';
+import detailedMetric from './detailed_metric.vue';
+import requestSelector from './request_selector.vue';
+import simpleMetric from './simple_metric.vue';
+import upstreamPerformanceBar from './upstream_performance_bar.vue';
+
+import Flash from '../../flash';
+
+export default {
+ components: {
+ detailedMetric,
+ requestSelector,
+ simpleMetric,
+ upstreamPerformanceBar,
+ },
+ props: {
+ store: {
+ type: Object,
+ required: true,
+ },
+ env: {
+ type: String,
+ required: true,
+ },
+ requestId: {
+ type: String,
+ required: true,
+ },
+ peekUrl: {
+ type: String,
+ required: true,
+ },
+ profileUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ detailedMetrics: [
+ { metric: 'pg', header: 'SQL queries', details: 'queries', keys: ['sql'] },
+ {
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ details: 'details',
+ keys: ['feature', 'request'],
+ },
+ ],
+ simpleMetrics: ['redis', 'sidekiq'],
+ data() {
+ return { currentRequestId: '' };
+ },
+ computed: {
+ requests() {
+ return this.store.requestsWithDetails();
+ },
+ currentRequest: {
+ get() {
+ return this.store.findRequest(this.currentRequestId);
+ },
+ set(requestId) {
+ this.currentRequestId = requestId;
+ },
+ },
+ initialRequest() {
+ return this.currentRequestId === this.requestId;
+ },
+ lineProfileModal() {
+ return $('#modal-peek-line-profile');
+ },
+ },
+ mounted() {
+ this.interceptor = PerformanceBarService.registerInterceptor(
+ this.peekUrl,
+ this.loadRequestDetails,
+ );
+
+ this.loadRequestDetails(this.requestId, window.location.href);
+ this.currentRequest = this.requestId;
+
+ if (this.lineProfileModal.length) {
+ this.lineProfileModal.modal('toggle');
+ }
+ },
+ beforeDestroy() {
+ PerformanceBarService.removeInterceptor(this.interceptor);
+ },
+ methods: {
+ loadRequestDetails(requestId, requestUrl) {
+ if (!this.store.canTrackRequest(requestUrl)) {
+ return;
+ }
+
+ this.store.addRequest(requestId, requestUrl);
+
+ PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
+ .then(res => {
+ this.store.addRequestDetails(requestId, res.data.data);
+ })
+ .catch(() =>
+ Flash(`Error getting performance bar results for ${requestId}`),
+ );
+ },
+ changeCurrentRequest(newRequestId) {
+ this.currentRequest = newRequestId;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ id="js-peek"
+ :class="env"
+ >
+ <request-selector
+ v-if="currentRequest"
+ :current-request="currentRequest"
+ :requests="requests"
+ @change-current-request="changeCurrentRequest"
+ />
+ <div
+ id="peek-view-host"
+ class="view prepend-left-5"
+ >
+ <span
+ v-if="currentRequest && currentRequest.details"
+ class="current-host"
+ >
+ {{ currentRequest.details.host.hostname }}
+ </span>
+ </div>
+ <div
+ v-if="currentRequest"
+ class="wrapper"
+ >
+ <upstream-performance-bar
+ v-if="initialRequest && currentRequest.details"
+ />
+ <detailed-metric
+ v-for="metric in $options.detailedMetrics"
+ :key="metric.metric"
+ :current-request="currentRequest"
+ :metric="metric.metric"
+ :header="metric.header"
+ :details="metric.details"
+ :keys="metric.keys"
+ />
+ <div
+ v-if="initialRequest"
+ id="peek-view-rblineprof"
+ class="view"
+ >
+ <button
+ v-if="lineProfileModal.length"
+ class="btn-link btn-blank"
+ data-toggle="modal"
+ data-target="#modal-peek-line-profile"
+ >
+ profile
+ </button>
+ <a
+ v-else
+ :href="profileUrl"
+ >
+ profile
+ </a>
+ </div>
+ <simple-metric
+ v-for="metric in $options.simpleMetrics"
+ :current-request="currentRequest"
+ :key="metric"
+ :metric="metric"
+ />
+ <div
+ id="peek-view-gc"
+ class="view"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="bold"
+ >
+ <span title="Invoke Time">{{ currentRequest.details.gc.gc_time }}</span>ms
+ /
+ <span title="Invoke Count">{{ currentRequest.details.gc.invokes }}</span>
+ gc
+ </span>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
new file mode 100644
index 00000000000..2f360ea6f6c
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -0,0 +1,52 @@
+<script>
+export default {
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ requests: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentRequestId: this.currentRequest.id,
+ };
+ },
+ watch: {
+ currentRequestId(newRequestId) {
+ this.$emit('change-current-request', newRequestId);
+ },
+ },
+ methods: {
+ truncatedUrl(requestUrl) {
+ const components = requestUrl.replace(/\/$/, '').split('/');
+ let truncated = components[components.length - 1];
+
+ if (truncated.match(/^\d+$/)) {
+ truncated = `${components[components.length - 2]}/${truncated}`;
+ }
+
+ return truncated;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ id="peek-request-selector"
+ class="append-right-5 pull-right"
+ >
+ <select v-model="currentRequestId">
+ <option
+ v-for="request in requests"
+ :key="request.id"
+ :value="request.id"
+ >
+ {{ truncatedUrl(request.url) }}
+ </option>
+ </select>
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue
new file mode 100644
index 00000000000..b654bc66249
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/simple_metric.vue
@@ -0,0 +1,30 @@
+<script>
+export default {
+ props: {
+ currentRequest: {
+ type: Object,
+ required: true,
+ },
+ metric: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div
+ :id="`peek-view-${metric}`"
+ class="view"
+ >
+ <span
+ v-if="currentRequest.details"
+ class="bold"
+ >
+ {{ currentRequest.details[metric].duration }}
+ /
+ {{ currentRequest.details[metric].calls }}
+ </span>
+ {{ metric }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue
new file mode 100644
index 00000000000..d438b1ec27b
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/upstream_performance_bar.vue
@@ -0,0 +1,18 @@
+<script>
+export default {
+ mounted() {
+ const upstreamPerformanceBar = document
+ .getElementById('peek-view-performance-bar')
+ .cloneNode(true);
+
+ this.$refs.wrapper.appendChild(upstreamPerformanceBar);
+ },
+};
+</script>
+<template>
+ <div
+ id="peek-view-performance-bar-vue"
+ class="view"
+ ref="wrapper"
+ ></div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
new file mode 100644
index 00000000000..fca488120f6
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -0,0 +1,37 @@
+import 'vendor/peek.performance_bar';
+
+import Vue from 'vue';
+import performanceBarApp from './components/performance_bar_app.vue';
+import PerformanceBarStore from './stores/performance_bar_store';
+
+export default () =>
+ new Vue({
+ el: '#js-peek',
+ components: {
+ performanceBarApp,
+ },
+ data() {
+ const performanceBarData = document.querySelector(this.$options.el)
+ .dataset;
+ const store = new PerformanceBarStore();
+
+ return {
+ store,
+ env: performanceBarData.env,
+ requestId: performanceBarData.requestId,
+ peekUrl: performanceBarData.peekUrl,
+ profileUrl: performanceBarData.profileUrl,
+ };
+ },
+ render(createElement) {
+ return createElement('performance-bar-app', {
+ props: {
+ store: this.store,
+ env: this.env,
+ requestId: this.requestId,
+ peekUrl: this.peekUrl,
+ profileUrl: this.profileUrl,
+ },
+ });
+ },
+ });
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
new file mode 100644
index 00000000000..d8e792446c3
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -0,0 +1,24 @@
+import axios from '../../lib/utils/axios_utils';
+
+export default class PerformanceBarService {
+ static fetchRequestDetails(peekUrl, requestId) {
+ return axios.get(peekUrl, { params: { request_id: requestId } });
+ }
+
+ static registerInterceptor(peekUrl, callback) {
+ return axios.interceptors.response.use(response => {
+ const requestId = response.headers['x-request-id'];
+ const requestUrl = response.config.url;
+
+ if (requestUrl !== peekUrl && requestId) {
+ callback(requestId, requestUrl);
+ }
+
+ return response;
+ });
+ }
+
+ static removeInterceptor(interceptor) {
+ axios.interceptors.response.eject(interceptor);
+ }
+}
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
new file mode 100644
index 00000000000..c6b2f55243c
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -0,0 +1,39 @@
+export default class PerformanceBarStore {
+ constructor() {
+ this.requests = [];
+ }
+
+ addRequest(requestId, requestUrl, requestDetails) {
+ if (!this.findRequest(requestId)) {
+ this.requests.push({
+ id: requestId,
+ url: requestUrl,
+ details: requestDetails,
+ });
+ }
+
+ return this.requests;
+ }
+
+ findRequest(requestId) {
+ return this.requests.find(request => request.id === requestId);
+ }
+
+ addRequestDetails(requestId, requestDetails) {
+ const request = this.findRequest(requestId);
+
+ request.details = requestDetails;
+
+ return request;
+ }
+
+ requestsWithDetails() {
+ return this.requests.filter(request => request.details);
+ }
+
+ canTrackRequest(requestUrl) {
+ return (
+ this.requests.filter(request => request.url === requestUrl).length < 2
+ );
+ }
+}
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 3c1bef23446..0af34657d72 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,7 +1,6 @@
/* eslint-disable comma-dangle, no-unused-vars, class-methods-use-this, quotes, consistent-return, func-names, prefer-arrow-callback, space-before-function-paren, max-len */
import $ from 'jquery';
-import Cookies from 'js-cookie';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import flash from '../flash';
@@ -10,7 +9,6 @@ export default class Profile {
constructor({ form } = {}) {
this.onSubmitForm = this.onSubmitForm.bind(this);
this.form = form || $('.edit-user');
- this.newRepoActivated = Cookies.get('new_repo');
this.setRepoRadio();
this.bindEvents();
this.initAvatarGlCrop();
@@ -23,21 +21,28 @@ export default class Profile {
modalCrop: '.modal-profile-crop',
pickImageEl: '.js-choose-user-avatar-button',
uploadImageBtn: '.js-upload-user-avatar',
- modalCropImg: '.modal-profile-crop-image'
+ modalCropImg: '.modal-profile-crop-image',
};
- this.avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data('glcrop');
+ this.avatarGlCrop = $('.js-user-avatar-input')
+ .glCrop(cropOpts)
+ .data('glcrop');
}
bindEvents() {
- $('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm);
- $('input[name="user[multi_file]"]').on('change', this.setNewRepoCookie);
+ $('.js-preferences-form').on(
+ 'change.preference',
+ 'input[type=radio]',
+ this.submitForm,
+ );
$('#user_notification_email').on('change', this.submitForm);
$('#user_notified_of_own_activity').on('change', this.submitForm);
this.form.on('submit', this.onSubmitForm);
}
submitForm() {
- return $(this).parents('form').submit();
+ return $(this)
+ .parents('form')
+ .submit();
}
onSubmitForm(e) {
@@ -59,21 +64,13 @@ export default class Profile {
url: this.form.attr('action'),
data: formData,
})
- .then(({ data }) => flash(data.message, 'notice'))
- .then(() => {
- window.scrollTo(0, 0);
- // Enable submit button after requests ends
- self.form.find(':input[disabled]').enable();
- })
- .catch(error => flash(error.message));
- }
-
- setNewRepoCookie() {
- if (this.value === 'off') {
- Cookies.remove('new_repo');
- } else {
- Cookies.set('new_repo', true, { expires_in: 365 });
- }
+ .then(({ data }) => flash(data.message, 'notice'))
+ .then(() => {
+ window.scrollTo(0, 0);
+ // Enable submit button after requests ends
+ self.form.find(':input[disabled]').enable();
+ })
+ .catch(error => flash(error.message));
}
setRepoRadio() {
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index 3031230277d..193788f754f 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -3,7 +3,7 @@ import Mousetrap from 'mousetrap';
import _ from 'underscore';
import Sidebar from './right_sidebar';
import Shortcuts from './shortcuts';
-import { CopyAsGFM } from './behaviors/copy_as_gfm';
+import { CopyAsGFM } from './behaviors/markdown/copy_as_gfm';
export default class ShortcutsIssuable extends Shortcuts {
constructor(isMergeRequest) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
deleted file mode 100644
index 142ddf477f1..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import statusIcon from '../mr_widget_status_icon.vue';
-
-export default {
- name: 'MRWidgetSHAMismatch',
- components: {
- statusIcon,
- },
- template: `
- <div class="mr-widget-body media">
- <status-icon status="warning" :show-disabled-button="true" />
- <div class="media-body space-children">
- <span class="bold">
- The source branch HEAD has recently changed. Please reload the page and review the changes before merging
- </span>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
new file mode 100644
index 00000000000..04100871a94
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -0,0 +1,25 @@
+<script>
+import statusIcon from '../mr_widget_status_icon.vue';
+
+export default {
+ name: 'ShaMismatch',
+ components: {
+ statusIcon,
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon
+ status="warning"
+ :show-disabled-button="true"
+ />
+ <div class="media-body space-children">
+ <span class="bold">
+ The source branch HEAD has recently changed.
+ Please reload the page and review the changes before merging.
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
index 021c2237661..ed15fc6ab0f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -28,7 +28,7 @@ export { default as NothingToMergeState } from './components/states/nothing_to_m
export { default as MissingBranchState } from './components/states/mr_widget_missing_branch.vue';
export { default as NotAllowedState } from './components/states/mr_widget_not_allowed.vue';
export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge';
-export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch';
+export { default as ShaMismatchState } from './components/states/sha_mismatch.vue';
export { default as UnresolvedDiscussionsState } from './components/states/unresolved_discussions.vue';
export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked.vue';
export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed';
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 169adfe0a1d..0be5d9e5a55 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
@@ -19,7 +19,7 @@ import {
MissingBranchState,
NotAllowedState,
ReadyToMergeState,
- SHAMismatchState,
+ ShaMismatchState,
UnresolvedDiscussionsState,
PipelineBlockedState,
PipelineFailedState,
@@ -227,7 +227,7 @@ export default {
'mr-widget-not-allowed': NotAllowedState,
'mr-widget-missing-branch': MissingBranchState,
'mr-widget-ready-to-merge': ReadyToMergeState,
- 'mr-widget-sha-mismatch': SHAMismatchState,
+ 'mr-widget-sha-mismatch': ShaMismatchState,
'mr-widget-squash-before-merge': SquashBeforeMerge,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
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 483ad52b8cc..e080ce5c229 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
@@ -16,7 +16,7 @@ const stateToComponentMap = {
mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds',
failedToMerge: 'mr-widget-failed-to-merge',
autoMergeFailed: 'mr-widget-auto-merge-failed',
- shaMismatch: 'mr-widget-sha-mismatch',
+ shaMismatch: 'sha-mismatch',
rebase: 'mr-widget-rebase',
};
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index bea58bade9d..0136af76a13 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -1,60 +1,24 @@
.navbar-gitlab {
- &.navbar-gitlab {
- padding: 0 16px;
- z-index: 1000;
- margin-bottom: 0;
- min-height: $header-height;
- border: 0;
- border-bottom: 1px solid $border-color;
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- border-radius: 0;
-
- .logo-text {
- line-height: initial;
-
- svg {
- width: 55px;
- height: 14px;
- margin: 0;
- fill: $white-light;
- }
- }
-
- .container-fluid {
- padding: 0;
-
- .user-counter {
- svg {
- margin-right: 3px;
- }
- }
-
- .navbar-toggle {
- right: -10px;
- border-radius: 0;
- min-width: 45px;
- padding: 0;
- margin-right: -7px;
- font-size: 14px;
- text-align: center;
- color: currentColor;
-
- &:hover,
- &:focus,
- &.active {
- color: currentColor;
- background-color: transparent;
- }
-
- .more-icon,
- .close-icon {
- fill: $white-light;
- margin: auto;
- }
- }
+ padding: 0 16px;
+ z-index: 1000;
+ margin-bottom: 0;
+ min-height: $header-height;
+ border: 0;
+ border-bottom: 1px solid $border-color;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ border-radius: 0;
+
+ .logo-text {
+ line-height: initial;
+
+ svg {
+ width: 55px;
+ height: 14px;
+ margin: 0;
+ fill: $white-light;
}
}
@@ -184,6 +148,38 @@
}
.container-fluid {
+ padding: 0;
+
+ .user-counter {
+ svg {
+ margin-right: 3px;
+ }
+ }
+
+ .navbar-toggle {
+ right: -10px;
+ border-radius: 0;
+ min-width: 45px;
+ padding: 0;
+ margin-right: -7px;
+ font-size: 14px;
+ text-align: center;
+ color: currentColor;
+
+ &:hover,
+ &:focus,
+ &.active {
+ color: currentColor;
+ background-color: transparent;
+ }
+
+ .more-icon,
+ .close-icon {
+ fill: $white-light;
+ margin: auto;
+ }
+ }
+
.navbar-nav {
@media (max-width: $screen-xs-max) {
display: -webkit-flex;
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index 2d015ef086b..df1cafc9f8e 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -20,7 +20,7 @@
width: 100%;
}
- $image-widths: 250 306 394 430;
+ $image-widths: 80 250 306 394 430;
@each $width in $image-widths {
&.svg-#{$width} {
img,
@@ -39,12 +39,35 @@
svg {
fill: currentColor;
- &.s8 { @include svg-size(8px); }
- &.s12 { @include svg-size(12px); }
- &.s16 { @include svg-size(16px); }
- &.s18 { @include svg-size(18px); }
- &.s24 { @include svg-size(24px); }
- &.s32 { @include svg-size(32px); }
- &.s48 { @include svg-size(48px); }
- &.s72 { @include svg-size(72px); }
+ &.s8 {
+ @include svg-size(8px);
+ }
+
+ &.s12 {
+ @include svg-size(12px);
+ }
+
+ &.s16 {
+ @include svg-size(16px);
+ }
+
+ &.s18 {
+ @include svg-size(18px);
+ }
+
+ &.s24 {
+ @include svg-size(24px);
+ }
+
+ &.s32 {
+ @include svg-size(32px);
+ }
+
+ &.s48 {
+ @include svg-size(48px);
+ }
+
+ &.s72 {
+ @include svg-size(72px);
+ }
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 085a2e74328..81e98f358a8 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -140,12 +140,6 @@ ul.notes {
@include bulleted-list;
word-wrap: break-word;
- ul.task-list {
- ul:not(.task-list) {
- padding-left: 1.3em;
- }
- }
-
table {
@include markdown-table;
}
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index 8265b8370f7..7a8fbfc517d 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -19,6 +19,7 @@
.ide-view {
display: flex;
height: calc(100vh - #{$header-height});
+ margin-top: 40px;
color: $almost-black;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
@@ -28,6 +29,11 @@
max-width: 250px;
}
}
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ }
}
.ide-file-list {
@@ -40,31 +46,41 @@
background: $white-normal;
}
- .repo-file-name {
+ .ide-file-name {
+ flex: 1;
white-space: nowrap;
text-overflow: ellipsis;
+
+ svg {
+ vertical-align: middle;
+ margin-right: 2px;
+ }
+
+ .loading-container {
+ margin-right: 4px;
+ display: inline-block;
+ }
}
- .unsaved-icon {
- color: $indigo-700;
- float: right;
- font-size: smaller;
- line-height: 20px;
+ .ide-file-changed-icon {
+ margin-left: auto;
}
- .repo-new-btn {
+ .ide-new-btn {
display: none;
- margin-top: -4px;
margin-bottom: -4px;
+ margin-right: -8px;
}
&:hover {
- .repo-new-btn {
+ .ide-new-btn {
display: block;
}
+ }
- .unsaved-icon {
- display: none;
+ &.folder {
+ svg {
+ fill: $gl-text-color-secondary;
}
}
}
@@ -79,10 +95,10 @@
}
}
-.multi-file-table-name,
-.multi-file-table-col-commit-message {
+.file-name,
+.file-col-commit-message {
+ display: flex;
overflow: visible;
- max-width: 0;
padding: 6px 12px;
}
@@ -99,21 +115,6 @@
}
}
-table.table tr td.multi-file-table-name {
- width: 350px;
- padding: 6px 12px;
-
- svg {
- vertical-align: middle;
- margin-right: 2px;
- }
-
- .loading-container {
- margin-right: 4px;
- display: inline-block;
- }
-}
-
.multi-file-table-col-commit-message {
white-space: nowrap;
width: 50%;
@@ -129,13 +130,35 @@ table.table tr td.multi-file-table-name {
.multi-file-tabs {
display: flex;
- overflow-x: auto;
background-color: $white-normal;
box-shadow: inset 0 -1px $white-dark;
- > li {
+ > ul {
+ display: flex;
+ overflow-x: auto;
+ }
+
+ li {
position: relative;
}
+
+ .dropdown {
+ display: flex;
+ margin-left: auto;
+ margin-bottom: 1px;
+ padding: 0 $grid-size;
+ border-left: 1px solid $white-dark;
+ background-color: $white-light;
+
+ &.shadow {
+ box-shadow: 0 0 10px $dropdown-shadow-color;
+ }
+
+ .btn {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
}
.multi-file-tab {
@@ -160,20 +183,32 @@ table.table tr td.multi-file-table-name {
position: absolute;
right: 8px;
top: 50%;
+ width: 16px;
+ height: 16px;
padding: 0;
background: none;
border: 0;
- font-size: $gl-font-size;
- color: $gray-darkest;
+ border-radius: $border-radius-default;
+ color: $theme-gray-900;
transform: translateY(-50%);
- &:not(.modified):hover,
- &:not(.modified):focus {
- color: $hint-color;
+ svg {
+ position: relative;
+ top: -1px;
}
- &.modified {
- color: $indigo-700;
+ &:hover {
+ background-color: $theme-gray-200;
+ }
+
+ &:focus {
+ background-color: $blue-500;
+ color: $white-light;
+ outline: 0;
+
+ svg {
+ fill: currentColor;
+ }
}
}
@@ -192,6 +227,70 @@ table.table tr td.multi-file-table-name {
.vertical-center {
min-height: auto;
}
+
+ .monaco-editor .lines-content .cigr {
+ display: none;
+ }
+
+ .monaco-diff-editor.vs {
+ .editor.modified {
+ box-shadow: none;
+ }
+
+ .diagonal-fill {
+ display: none !important;
+ }
+
+ .diffOverview {
+ background-color: $white-light;
+ border-left: 1px solid $white-dark;
+ cursor: ns-resize;
+ }
+
+ .diffViewport {
+ display: none;
+ }
+
+ .char-insert {
+ background-color: $line-added-dark;
+ }
+
+ .char-delete {
+ background-color: $line-removed-dark;
+ }
+
+ .line-numbers {
+ color: $black-transparent;
+ }
+
+ .view-overlays {
+ .line-insert {
+ background-color: $line-added;
+ }
+
+ .line-delete {
+ background-color: $line-removed;
+ }
+ }
+
+ .margin {
+ background-color: $gray-light;
+ border-right: 1px solid $white-normal;
+
+ .line-insert {
+ border-right: 1px solid $line-added-dark;
+ }
+
+ .line-delete {
+ border-right: 1px solid $line-removed-dark;
+ }
+ }
+
+ .margin-view-overlays .insert-sign,
+ .margin-view-overlays .delete-sign {
+ opacity: 0.4;
+ }
+ }
}
.multi-file-editor-holder {
@@ -252,7 +351,7 @@ table.table tr td.multi-file-table-name {
display: flex;
position: relative;
flex-direction: column;
- width: 290px;
+ width: 340px;
padding: 0;
background-color: $gray-light;
padding-right: 3px;
@@ -350,6 +449,11 @@ table.table tr td.multi-file-table-name {
flex: 1;
}
+.multi-file-commit-empty-state-container {
+ align-items: center;
+ justify-content: center;
+}
+
.multi-file-commit-panel-header {
display: flex;
align-items: center;
@@ -376,7 +480,7 @@ table.table tr td.multi-file-table-name {
.multi-file-commit-panel-header-title {
display: flex;
flex: 1;
- padding: $gl-btn-padding;
+ padding: 0 $gl-btn-padding;
svg {
margin-right: $gl-btn-padding;
@@ -390,12 +494,34 @@ table.table tr td.multi-file-table-name {
.multi-file-commit-list {
flex: 1;
overflow: auto;
- padding: $gl-padding;
+ padding: $gl-padding 0;
+ min-height: 60px;
}
.multi-file-commit-list-item {
display: flex;
+ padding: 0;
align-items: center;
+
+ .multi-file-discard-btn {
+ display: none;
+ margin-left: auto;
+ color: $gl-link-color;
+ padding: 0 2px;
+
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ &:hover {
+ background: $white-normal;
+
+ .multi-file-discard-btn {
+ display: block;
+ }
+ }
}
.multi-file-addition {
@@ -414,29 +540,58 @@ table.table tr td.multi-file-table-name {
margin-left: auto;
margin-right: auto;
}
+
+ .file-status-icon {
+ width: 10px;
+ height: 10px;
+ margin-left: 3px;
+ }
}
.multi-file-commit-list-path {
+ padding: $grid-size / 2;
+ padding-left: $gl-padding;
+ background: none;
+ border: 0;
+ text-align: left;
+ width: 100%;
+ min-width: 0;
+
+ svg {
+ min-width: 16px;
+ vertical-align: middle;
+ display: inline-block;
+ }
+
+ &:hover,
+ &:focus {
+ outline: 0;
+ }
+}
+
+.multi-file-commit-list-file-path {
@include str-truncated(100%);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ text-decoration: none;
+ }
}
.multi-file-commit-form {
padding: $gl-padding;
border-top: 1px solid $white-dark;
-}
-
-.multi-file-commit-fieldset {
- display: flex;
- align-items: center;
- padding-bottom: 12px;
.btn {
- flex: 1;
+ font-size: $gl-font-size;
}
}
.multi-file-commit-message.form-control {
- height: 80px;
+ height: 160px;
resize: none;
}
@@ -468,7 +623,7 @@ table.table tr td.multi-file-table-name {
top: 0;
width: 100px;
height: 1px;
- background-color: rgba($red-500, .5);
+ background-color: rgba($red-500, 0.5);
}
}
}
@@ -487,7 +642,7 @@ table.table tr td.multi-file-table-name {
justify-content: center;
}
-.repo-new-btn {
+.ide-new-btn {
.dropdown-toggle svg {
margin-top: -2px;
margin-bottom: 2px;
@@ -505,36 +660,39 @@ table.table tr td.multi-file-table-name {
}
}
-.ide.nav-only {
- .flash-container {
- margin-top: $header-height;
- margin-bottom: 0;
- }
-
- .alert-wrapper .flash-container .flash-alert:last-child,
- .alert-wrapper .flash-container .flash-notice:last-child {
- margin-bottom: 0;
- }
+.ide {
+ overflow: hidden;
- .content {
- margin-top: $header-height;
- }
+ &.nav-only {
+ .flash-container {
+ margin-top: $header-height;
+ margin-bottom: 0;
+ }
- .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
- max-height: calc(100vh - #{$header-height + $context-header-height});
- }
+ .alert-wrapper .flash-container .flash-alert:last-child,
+ .alert-wrapper .flash-container .flash-notice:last-child {
+ margin-bottom: 0;
+ }
- &.flash-shown {
- .content {
- margin-top: 0;
+ .content-wrapper {
+ margin-top: $header-height;
+ padding-bottom: 0;
}
- .ide-view {
- height: calc(100vh - #{$header-height + $flash-height});
+ &.flash-shown {
+ .content-wrapper {
+ margin-top: 0;
+ }
+
+ .ide-view {
+ height: calc(100vh - #{$header-height + $flash-height});
+ }
}
- .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
- max-height: calc(100vh - #{$header-height + $flash-height + $context-header-height});
+ .projects-sidebar {
+ .multi-file-commit-panel-inner-scroll {
+ flex: 1;
+ }
}
}
}
@@ -544,34 +702,28 @@ table.table tr td.multi-file-table-name {
margin-top: #{$header-height + $performance-bar-height};
}
- .content {
+ .content-wrapper {
margin-top: #{$header-height + $performance-bar-height};
+ padding-bottom: 0;
}
.ide-view {
height: calc(100vh - #{$header-height + $performance-bar-height});
}
- .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
- max-height: calc(100vh - #{$header-height + $performance-bar-height + 60});
- }
-
&.flash-shown {
- .content {
+ .content-wrapper {
margin-top: 0;
}
.ide-view {
- height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height});
- }
-
- .multi-file-commit-panel .multi-file-commit-panel-inner-scroll {
- max-height: calc(100vh - #{$header-height + $performance-bar-height + $flash-height + $context-header-height});
+ height: calc(
+ 100vh - #{$header-height + $performance-bar-height + $flash-height}
+ );
}
}
}
-
.dragHandle {
position: absolute;
top: 0;
@@ -587,3 +739,44 @@ table.table tr td.multi-file-table-name {
left: 0;
}
}
+
+.ide-commit-radios {
+ label {
+ font-weight: normal;
+ }
+
+ .help-block {
+ margin-top: 0;
+ line-height: 0;
+ }
+}
+
+.ide-commit-new-branch {
+ margin-left: 25px;
+}
+
+.ide-external-links {
+ p {
+ margin: 0;
+ }
+}
+
+.ide-sidebar-link {
+ padding: $gl-padding-8 $gl-padding;
+ background: $indigo-700;
+ color: $white-light;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+
+ &:focus,
+ &:hover {
+ color: $white-light;
+ text-decoration: underline;
+ background: $indigo-500;
+ }
+
+ &:active {
+ background: $indigo-800;
+ }
+}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 6e539e39ca1..d06148a7bf8 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -1,8 +1,8 @@
-@import "framework/variables";
-@import "peek/views/performance_bar";
-@import "peek/views/rblineprof";
+@import 'framework/variables';
+@import 'peek/views/performance_bar';
+@import 'peek/views/rblineprof';
-#peek {
+#js-peek {
position: fixed;
left: 0;
top: 0;
@@ -21,14 +21,26 @@
&.production {
background-color: $perf-bar-production;
+
+ select {
+ background: $perf-bar-production;
+ }
}
&.staging {
background-color: $perf-bar-staging;
+
+ select {
+ background: $perf-bar-staging;
+ }
}
&.development {
background-color: $perf-bar-development;
+
+ select {
+ background: $perf-bar-development;
+ }
}
.wrapper {
@@ -42,11 +54,12 @@
background: $perf-bar-bucket-bg;
display: inline-block;
padding: 4px 6px;
- font-family: Consolas, "Liberation Mono", Courier, monospace;
+ font-family: Consolas, 'Liberation Mono', Courier, monospace;
line-height: 1;
color: $perf-bar-bucket-color;
border-radius: 3px;
- box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from, inset 0 1px 2px $perf-bar-bucket-box-shadow-to;
+ box-shadow: 0 1px 0 $perf-bar-bucket-box-shadow-from,
+ inset 0 1px 2px $perf-bar-bucket-box-shadow-to;
.hidden {
display: none;
@@ -94,6 +107,10 @@
max-width: 10000px !important;
}
}
+
+ .performance-bar-modal .modal-footer {
+ display: none;
+ }
}
#modal-peek-pg-queries-content {
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/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb
index 5c507fe8d50..2c8f21c2400 100644
--- a/app/finders/admin/projects_finder.rb
+++ b/app/finders/admin/projects_finder.rb
@@ -16,6 +16,7 @@ class Admin::ProjectsFinder
items = by_archived(items)
items = by_personal(items)
items = by_name(items)
+ items = items.includes(namespace: [:owner])
sort(items).page(params[:page])
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index af9c8bf1bd3..3ddf8eb3369 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -300,7 +300,7 @@ module ApplicationHelper
def linkedin_url(user)
name = user.linkedin
- if name =~ %r{\Ahttps?:\/\/(www\.)?linkedin\.com\/in\/}
+ if name =~ %r{\Ahttps?://(www\.)?linkedin\.com/in/}
name
else
"https://www.linkedin.com/in/#{name}"
@@ -309,10 +309,10 @@ module ApplicationHelper
def twitter_url(user)
name = user.twitter
- if name =~ %r{\Ahttps?:\/\/(www\.)?twitter\.com\/}
+ if name =~ %r{\Ahttps?://(www\.)?twitter\.com/}
name
else
- "https://www.twitter.com/#{name}"
+ "https://twitter.com/#{name}"
end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 5ff09b23a78..2b440e4d584 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -33,6 +33,17 @@ module BlobHelper
ref)
end
+ def ide_edit_button(project = @project, ref = @ref, path = @path, options = {})
+ return unless blob = readable_blob(options, path, project, ref)
+
+ edit_button_tag(blob,
+ 'btn btn-default',
+ _('Web IDE'),
+ ide_edit_path(project, ref, path, options),
+ project,
+ ref)
+ end
+
def modify_file_button(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:)
return unless current_user
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 240783bc7fd..f435c80c656 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -1,27 +1,4 @@
module ServicesHelper
- def service_event_description(event)
- case event
- when "push", "push_events"
- "Event will be triggered by a push to the repository"
- when "tag_push", "tag_push_events"
- "Event will be triggered when a new tag is pushed to the repository"
- when "note", "note_events"
- "Event will be triggered when someone adds a comment"
- when "issue", "issue_events"
- "Event will be triggered when an issue is created/updated/closed"
- when "confidential_issue", "confidential_issue_events"
- "Event will be triggered when a confidential issue is created/updated/closed"
- when "merge_request", "merge_request_events"
- "Event will be triggered when a merge request is created/updated/merged"
- when "pipeline", "pipeline_events"
- "Event will be triggered when a pipeline status changes"
- when "wiki_page", "wiki_page_events"
- "Event will be triggered when a wiki page is created/updated"
- when "commit", "commit_events"
- "Event will be triggered when a commit is created/updated"
- end
- end
-
def service_event_field_name(event)
event = event.pluralize if %w[merge_request issue confidential_issue].include?(event)
"#{event}_events"
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index c1da2081465..1e066b69c6e 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -140,7 +140,11 @@ module Ci
next if build.retries_max.zero?
if build.retries_count < build.retries_max
- Ci::Build.retry(build, build.user)
+ begin
+ Ci::Build.retry(build, build.user)
+ rescue Gitlab::Access::AccessDeniedError => ex
+ Rails.logger.error "Unable to auto-retry job #{build.id}: #{ex}"
+ end
end
end
@@ -328,8 +332,7 @@ module Ci
end
def erase_old_trace!
- write_attribute(:trace, nil)
- save
+ update_column(:trace, nil)
end
def needs_touch?
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index fd70e920c7e..e95655e19f8 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -35,7 +35,8 @@ class NotificationRecipient
# check this last because it's expensive
# nobody should receive notifications if they've specifically unsubscribed
- return false if unsubscribed?
+ # except if they were mentioned.
+ return false if @type != :mention && unsubscribed?
true
end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 601a6a077f5..ed4bbfb6cfc 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -14,9 +14,8 @@ class JiraService < IssueTrackerService
alias_method :project_url, :url
- # This is confusing, but JiraService does not really support these events.
- # The values here are required to display correct options in the service
- # configuration screen.
+ # When these are false GitLab does not create cross reference
+ # comments on JIRA except when an issue gets transitioned.
def self.supported_events
%w(commit merge_request)
end
@@ -318,4 +317,13 @@ class JiraService < IssueTrackerService
url_changed?
end
+
+ def self.event_description(event)
+ case event
+ when "merge_request", "merge_request_events"
+ "JIRA comments will be created when an issue gets referenced in a merge request."
+ when "commit", "commit_events"
+ "JIRA comments will be created when an issue gets referenced in a commit."
+ end
+ end
end
diff --git a/app/models/service.rb b/app/models/service.rb
index 2556db68146..1dcb79157a2 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -304,6 +304,29 @@ class Service < ActiveRecord::Base
end
end
+ def self.event_description(event)
+ case event
+ when "push", "push_events"
+ "Event will be triggered by a push to the repository"
+ when "tag_push", "tag_push_events"
+ "Event will be triggered when a new tag is pushed to the repository"
+ when "note", "note_events"
+ "Event will be triggered when someone adds a comment"
+ when "issue", "issue_events"
+ "Event will be triggered when an issue is created/updated/closed"
+ when "confidential_issue", "confidential_issue_events"
+ "Event will be triggered when a confidential issue is created/updated/closed"
+ when "merge_request", "merge_request_events"
+ "Event will be triggered when a merge request is created/updated/merged"
+ when "pipeline", "pipeline_events"
+ "Event will be triggered when a pipeline status changes"
+ when "wiki_page", "wiki_page_events"
+ "Event will be triggered when a wiki page is created/updated"
+ when "commit", "commit_events"
+ "Event will be triggered when a commit is created/updated"
+ end
+ end
+
def valid_recipients?
activated? && !importing?
end
diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml
new file mode 100644
index 00000000000..e0e8fe548d0
--- /dev/null
+++ b/app/views/ide/index.html.haml
@@ -0,0 +1,12 @@
+- @body_class = 'ide'
+- page_title 'IDE'
+
+- content_for :page_specific_javascripts do
+ = webpack_bundle_tag 'ide', force_same_domain: true
+
+#ide.ide-loading{ data: {"empty-state-svg-path" => image_path('illustrations/multi_file_editor_empty.svg'),
+ "no-changes-state-svg-path" => image_path('illustrations/multi-editor_no_changes_empty.svg'),
+ "committed-state-svg-path" => image_path('illustrations/multi-editor_all_changes_committed_empty.svg') } }
+ .text-center
+ = icon('spinner spin 2x')
+ %h2.clgray= _('Loading the GitLab IDE...')
diff --git a/app/views/peek/_bar.html.haml b/app/views/peek/_bar.html.haml
new file mode 100644
index 00000000000..14dafa197b5
--- /dev/null
+++ b/app/views/peek/_bar.html.haml
@@ -0,0 +1,12 @@
+- return unless peek_enabled?
+
+#js-peek{ data: { env: Peek.env,
+ request_id: Peek.request_id,
+ peek_url: peek_routes.results_url,
+ profile_url: url_for(params.merge(lineprofiler: 'true')) },
+ class: Peek.env }
+
+#peek-view-performance-bar
+ = render_server_response_time
+ %span#serverstats
+ %ul.performance-bar
diff --git a/app/views/peek/views/_gitaly.html.haml b/app/views/peek/views/_gitaly.html.haml
deleted file mode 100644
index 945bb287429..00000000000
--- a/app/views/peek/views/_gitaly.html.haml
+++ /dev/null
@@ -1,17 +0,0 @@
-- local_assigns.fetch(:view)
-
-%button.btn-blank.btn-link.bold{ type: 'button', data: { toggle: 'modal', target: '#modal-peek-gitaly-details' } }
- %span{ data: { defer_to: "#{view.defer_key}-duration" } }...
- \/
- %span{ data: { defer_to: "#{view.defer_key}-calls" } }...
-#modal-peek-gitaly-details.modal{ tabindex: -1, role: 'dialog' }
- .modal-dialog.modal-full
- .modal-content
- .modal-header
- %button.close{ type: 'button', data: { dismiss: 'modal' }, 'aria-label' => 'Close' }
- %span{ 'aria-hidden' => 'true' }
- &times;
- %h4
- Gitaly requests
- .modal-body{ data: { defer_to: "#{view.defer_key}-details" } }...
-gitaly
diff --git a/app/views/peek/views/_host.html.haml b/app/views/peek/views/_host.html.haml
deleted file mode 100644
index 40769b5c6f6..00000000000
--- a/app/views/peek/views/_host.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-%span.current-host
- = truncate(view.hostname)
diff --git a/app/views/peek/views/_mysql2.html.haml b/app/views/peek/views/_mysql2.html.haml
deleted file mode 100644
index ac811a10ef5..00000000000
--- a/app/views/peek/views/_mysql2.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- local_assigns.fetch(:view)
-
-= render 'peek/views/sql', view: view
-mysql
diff --git a/app/views/peek/views/_pg.html.haml b/app/views/peek/views/_pg.html.haml
deleted file mode 100644
index ee94c2f3274..00000000000
--- a/app/views/peek/views/_pg.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-- local_assigns.fetch(:view)
-
-= render 'peek/views/sql', view: view
-pg
diff --git a/app/views/peek/views/_rblineprof.html.haml b/app/views/peek/views/_rblineprof.html.haml
deleted file mode 100644
index 6c037930ca9..00000000000
--- a/app/views/peek/views/_rblineprof.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-Profile:
-
-= link_to 'all', url_for(lineprofiler: 'true'), class: 'js-toggle-modal-peek-line-profile'
-\/
-= link_to 'app & lib', url_for(lineprofiler: 'app'), class: 'js-toggle-modal-peek-line-profile'
-\/
-= link_to 'views', url_for(lineprofiler: 'views'), class: 'js-toggle-modal-peek-line-profile'
diff --git a/app/views/peek/views/_sql.html.haml b/app/views/peek/views/_sql.html.haml
deleted file mode 100644
index 36583df898a..00000000000
--- a/app/views/peek/views/_sql.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-%button.btn-blank.btn-link.bold{ type: 'button', data: { toggle: 'modal', target: '#modal-peek-pg-queries' } }
- %span{ data: { defer_to: "#{view.defer_key}-duration" } }...
- \/
- %span{ data: { defer_to: "#{view.defer_key}-calls" } }...
-#modal-peek-pg-queries.modal{ tabindex: -1 }
- .modal-dialog.modal-full
- .modal-content
- .modal-header
- %button.close{ type: 'button', data: { dismiss: 'modal' }, 'aria-label' => 'Close' }
- %span{ 'aria-hidden' => 'true' }
- &times;
- %h4
- SQL queries
- .modal-body{ data: { defer_to: "#{view.defer_key}-queries" } }...
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index f93bb02acb9..1b150ec3e5c 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_button
+ = ide_edit_button
- if current_user
= replace_blob_link
= delete_blob_link
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
index c151b5acdf7..d6f0b230b58 100644
--- a/app/views/projects/environments/metrics.html.haml
+++ b/app/views/projects/environments/metrics.html.haml
@@ -14,6 +14,7 @@
"documentation-path": help_page_path('administration/monitoring/prometheus/index.md'),
"empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'),
+ "empty-no-data-svg-path": image_path('illustrations/monitoring/no_data.svg'),
"empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'),
"metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json),
"deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json),
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 06bce52e709..67613949b7d 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -76,4 +76,8 @@
= render 'projects/find_file_link'
+ = succeed " " do
+ = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do
+ = _('Web IDE')
+
= render 'projects/buttons/download', project: @project, ref: @ref
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 355b3ac75ae..a41aaed66a3 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -33,7 +33,7 @@
= form.text_field field[:name], class: "form-control", placeholder: field[:placeholder]
%p.light
- = service_event_description(event)
+ = @service.class.event_description(event)
- @service.global_fields.each do |field|
- type = field[:type]
diff --git a/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml b/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml
new file mode 100644
index 00000000000..0e892a51bc5
--- /dev/null
+++ b/changelogs/unreleased/42880-loss-of-input-text-on-comments-after-preview.yml
@@ -0,0 +1,5 @@
+---
+title: Fix Firefox stealing formatting characters on issue notes
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/43933-always-notify-mentions.yml b/changelogs/unreleased/43933-always-notify-mentions.yml
new file mode 100644
index 00000000000..7b494d38541
--- /dev/null
+++ b/changelogs/unreleased/43933-always-notify-mentions.yml
@@ -0,0 +1,6 @@
+---
+title: Send @mention notifications even if a user has explicitly unsubscribed from
+ item
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/44232-docs-for-runner-ip-address.yml b/changelogs/unreleased/44232-docs-for-runner-ip-address.yml
new file mode 100644
index 00000000000..82485d31b24
--- /dev/null
+++ b/changelogs/unreleased/44232-docs-for-runner-ip-address.yml
@@ -0,0 +1,5 @@
+---
+title: Add documentation for runner IP address (#44232)
+merge_request: 17837
+author:
+type: other
diff --git a/changelogs/unreleased/44280-fix-code-search.yml b/changelogs/unreleased/44280-fix-code-search.yml
new file mode 100644
index 00000000000..07f3abb224c
--- /dev/null
+++ b/changelogs/unreleased/44280-fix-code-search.yml
@@ -0,0 +1,5 @@
+---
+title: Fix search results stripping last endline when parsing the results
+merge_request: 17777
+author: Jasper Maes
+type: fixed
diff --git a/changelogs/unreleased/44330-docs-for-ingress-ip.yml b/changelogs/unreleased/44330-docs-for-ingress-ip.yml
new file mode 100644
index 00000000000..3dfaea6e17e
--- /dev/null
+++ b/changelogs/unreleased/44330-docs-for-ingress-ip.yml
@@ -0,0 +1,5 @@
+---
+title: Add documentation for displayed K8s Ingress IP address (#44330)
+merge_request: 17836
+author:
+type: other
diff --git a/changelogs/unreleased/44383-cleanup-framework-header.yml b/changelogs/unreleased/44383-cleanup-framework-header.yml
new file mode 100644
index 00000000000..ef9be9f48de
--- /dev/null
+++ b/changelogs/unreleased/44383-cleanup-framework-header.yml
@@ -0,0 +1,5 @@
+---
+title: Clean up selectors in framework/header.scss
+merge_request: 17822
+author: Takuya Noguchi
+type: other
diff --git a/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml b/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml
new file mode 100644
index 00000000000..79c470ea4e1
--- /dev/null
+++ b/changelogs/unreleased/44384-cleanup-css-for-nested-lists.yml
@@ -0,0 +1,5 @@
+---
+title: Unify format for nested non-task lists
+merge_request: 17823
+author: Takuya Noguchi
+type: fixed
diff --git a/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml b/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml
new file mode 100644
index 00000000000..c21d02d4d87
--- /dev/null
+++ b/changelogs/unreleased/44388-update-rack-protection-to-2-0-1.yml
@@ -0,0 +1,5 @@
+---
+title: Update rack-protection to 2.0.1
+merge_request: 17835
+author: Takuya Noguchi
+type: security
diff --git a/changelogs/unreleased/ajax-requests-in-performance-bar.yml b/changelogs/unreleased/ajax-requests-in-performance-bar.yml
new file mode 100644
index 00000000000..88cc3678c2b
--- /dev/null
+++ b/changelogs/unreleased/ajax-requests-in-performance-bar.yml
@@ -0,0 +1,5 @@
+---
+title: Allow viewing timings for AJAX requests in the performance bar
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/fix-ci-job-auto-retry.yml b/changelogs/unreleased/fix-ci-job-auto-retry.yml
new file mode 100644
index 00000000000..442126461f0
--- /dev/null
+++ b/changelogs/unreleased/fix-ci-job-auto-retry.yml
@@ -0,0 +1,5 @@
+---
+title: Prevent auto-retry AccessDenied error from stopping transition to failed
+merge_request: 17862
+author:
+type: fixed
diff --git a/changelogs/unreleased/issue_25542.yml b/changelogs/unreleased/issue_25542.yml
new file mode 100644
index 00000000000..eba491f7e2a
--- /dev/null
+++ b/changelogs/unreleased/issue_25542.yml
@@ -0,0 +1,5 @@
+---
+title: Improve JIRA event descriptions
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/optional-api-delimiter.yml b/changelogs/unreleased/optional-api-delimiter.yml
new file mode 100644
index 00000000000..0bcd0787306
--- /dev/null
+++ b/changelogs/unreleased/optional-api-delimiter.yml
@@ -0,0 +1,5 @@
+---
+title: Make /-/ delimiter optional for search endpoints
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml b/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml
new file mode 100644
index 00000000000..ac41fe23d3d
--- /dev/null
+++ b/changelogs/unreleased/refactor-move-mr-widget-sha-mismatch-vue-component.yml
@@ -0,0 +1,5 @@
+---
+title: Move ShaMismatch vue component
+merge_request: 17546
+author: George Tsiolis
+type: performance
diff --git a/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml b/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml
new file mode 100644
index 00000000000..9c13bfbaf6f
--- /dev/null
+++ b/changelogs/unreleased/update-spec-import-path-for-vue-mount-component-helper.yml
@@ -0,0 +1,5 @@
+---
+title: Update spec import path for vue mount component helper
+merge_request: 17880
+author: George Tsiolis
+type: performance
diff --git a/config/initializers/active_record_locking.rb b/config/initializers/active_record_locking.rb
index 150aaa2a8c2..3e7111fd063 100644
--- a/config/initializers/active_record_locking.rb
+++ b/config/initializers/active_record_locking.rb
@@ -1,73 +1,77 @@
# rubocop:disable Lint/RescueException
-# This patch fixes https://github.com/rails/rails/issues/26024
-# TODO: Remove it when it's no longer necessary
-
-module ActiveRecord
- module Locking
- module Optimistic
- # We overwrite this method because we don't want to have default value
- # for newly created records
- def _create_record(attribute_names = self.attribute_names, *) # :nodoc:
- super
- end
+# Remove this entire initializer when we are at rails 5.0.
+# This file fixes the bug (see below) which has been fixed in the upstream.
+unless Gitlab.rails5?
+ # This patch fixes https://github.com/rails/rails/issues/26024
+ # TODO: Remove it when it's no longer necessary
+
+ module ActiveRecord
+ module Locking
+ module Optimistic
+ # We overwrite this method because we don't want to have default value
+ # for newly created records
+ def _create_record(attribute_names = self.attribute_names, *) # :nodoc:
+ super
+ end
- def _update_record(attribute_names = self.attribute_names) #:nodoc:
- return super unless locking_enabled?
- return 0 if attribute_names.empty?
+ def _update_record(attribute_names = self.attribute_names) #:nodoc:
+ return super unless locking_enabled?
+ return 0 if attribute_names.empty?
- lock_col = self.class.locking_column
+ lock_col = self.class.locking_column
- previous_lock_value = send(lock_col).to_i # rubocop:disable GitlabSecurity/PublicSend
+ previous_lock_value = send(lock_col).to_i # rubocop:disable GitlabSecurity/PublicSend
- # This line is added as a patch
- previous_lock_value = nil if previous_lock_value == '0' || previous_lock_value == 0
+ # This line is added as a patch
+ previous_lock_value = nil if previous_lock_value == '0' || previous_lock_value == 0
- increment_lock
+ increment_lock
- attribute_names += [lock_col]
- attribute_names.uniq!
+ attribute_names += [lock_col]
+ attribute_names.uniq!
- begin
- relation = self.class.unscoped
+ begin
+ relation = self.class.unscoped
- affected_rows = relation.where(
- self.class.primary_key => id,
- lock_col => previous_lock_value
- ).update_all(
- attributes_for_update(attribute_names).map do |name|
- [name, _read_attribute(name)]
- end.to_h
- )
+ affected_rows = relation.where(
+ self.class.primary_key => id,
+ lock_col => previous_lock_value
+ ).update_all(
+ attributes_for_update(attribute_names).map do |name|
+ [name, _read_attribute(name)]
+ end.to_h
+ )
- unless affected_rows == 1
- raise ActiveRecord::StaleObjectError.new(self, "update")
- end
+ unless affected_rows == 1
+ raise ActiveRecord::StaleObjectError.new(self, "update")
+ end
- affected_rows
+ affected_rows
- # If something went wrong, revert the version.
- rescue Exception
- send(lock_col + '=', previous_lock_value) # rubocop:disable GitlabSecurity/PublicSend
- raise
+ # If something went wrong, revert the version.
+ rescue Exception
+ send(lock_col + '=', previous_lock_value) # rubocop:disable GitlabSecurity/PublicSend
+ raise
+ end
end
- end
- # This is patched because we need it to query `lock_version IS NULL`
- # rather than `lock_version = 0` whenever lock_version is NULL.
- def relation_for_destroy
- return super unless locking_enabled?
+ # This is patched because we need it to query `lock_version IS NULL`
+ # rather than `lock_version = 0` whenever lock_version is NULL.
+ def relation_for_destroy
+ return super unless locking_enabled?
- column_name = self.class.locking_column
- super.where(self.class.arel_table[column_name].eq(self[column_name]))
+ column_name = self.class.locking_column
+ super.where(self.class.arel_table[column_name].eq(self[column_name]))
+ end
end
- end
- # This is patched because we want `lock_version` default to `NULL`
- # rather than `0`
- class LockingType < SimpleDelegator
- def type_cast_from_database(value)
- super
+ # This is patched because we want `lock_version` default to `NULL`
+ # rather than `0`
+ class LockingType < SimpleDelegator
+ def type_cast_from_database(value)
+ super
+ end
end
end
end
diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb
index 114c1cb512f..49fdd23064c 100644
--- a/config/initializers/lograge.rb
+++ b/config/initializers/lograge.rb
@@ -1,3 +1,21 @@
+# Monkey patch lograge until https://github.com/roidrage/lograge/pull/241 is released
+module Lograge
+ class RequestLogSubscriber < ActiveSupport::LogSubscriber
+ def strip_query_string(path)
+ index = path.index('?')
+ index ? path[0, index] : path
+ end
+
+ def extract_location
+ location = Thread.current[:lograge_location]
+ return {} unless location
+
+ Thread.current[:lograge_location] = nil
+ { location: strip_query_string(location) }
+ end
+ end
+end
+
# Only use Lograge for Rails
unless Sidekiq.server?
filename = File.join(Rails.root, 'log', "#{Rails.env}_json.log")
diff --git a/config/routes.rb b/config/routes.rb
index 35fd76fb119..52726f94753 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -44,7 +44,7 @@ Rails.application.routes.draw do
get 'readiness' => 'health#readiness'
post 'storage_check' => 'health#storage_check'
resources :metrics, only: [:index]
- mount Peek::Railtie => '/peek'
+ mount Peek::Railtie => '/peek', as: 'peek_routes'
# Boards resources shared between group and projects
resources :boards, only: [] do
@@ -61,6 +61,9 @@ Rails.application.routes.draw do
# UserCallouts
resources :user_callouts, only: [:create]
+
+ get 'ide' => 'ide#index'
+ get 'ide/*vueroute' => 'ide#index', format: false
end
# Koding route
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 3403c0c207d..f5fb7de6176 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -9,12 +9,14 @@ const StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin;
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const NameAllModulesPlugin = require('name-all-modules-plugin');
-const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
+const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
+ .BundleAnalyzerPlugin;
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const ROOT_PATH = path.resolve(__dirname, '..');
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
-const IS_DEV_SERVER = process.argv.join(' ').indexOf('webpack-dev-server') !== -1;
+const IS_DEV_SERVER =
+ process.argv.join(' ').indexOf('webpack-dev-server') !== -1;
const DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost';
const DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808;
const DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false';
@@ -27,10 +29,10 @@ let watchAutoEntries = [];
function generateEntries() {
// generate automatic entry points
const autoEntries = {};
- const pageEntries = glob.sync('pages/**/index.js', { cwd: path.join(ROOT_PATH, 'app/assets/javascripts') });
- watchAutoEntries = [
- path.join(ROOT_PATH, 'app/assets/javascripts/pages/'),
- ];
+ const pageEntries = glob.sync('pages/**/index.js', {
+ cwd: path.join(ROOT_PATH, 'app/assets/javascripts'),
+ });
+ watchAutoEntries = [path.join(ROOT_PATH, 'app/assets/javascripts/pages/')];
function generateAutoEntries(path, prefix = '.') {
const chunkPath = path.replace(/\/index\.js$/, '');
@@ -38,15 +40,16 @@ function generateEntries() {
autoEntries[chunkName] = `${prefix}/${path}`;
}
- pageEntries.forEach(( path ) => generateAutoEntries(path));
+ pageEntries.forEach(path => generateAutoEntries(path));
autoEntriesCount = Object.keys(autoEntries).length;
const manualEntries = {
- common: './commons/index.js',
- main: './main.js',
- raven: './raven/index.js',
- webpack_runtime: './webpack.js',
+ common: './commons/index.js',
+ main: './main.js',
+ raven: './raven/index.js',
+ webpack_runtime: './webpack.js',
+ ide: './ide/index.js',
};
return Object.assign(manualEntries, autoEntries);
@@ -60,8 +63,12 @@ const config = {
output: {
path: path.join(ROOT_PATH, 'public/assets/webpack'),
publicPath: '/assets/webpack/',
- filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js',
- chunkFilename: IS_PRODUCTION ? '[name].[chunkhash].chunk.js' : '[name].chunk.js',
+ filename: IS_PRODUCTION
+ ? '[name].[chunkhash].bundle.js'
+ : '[name].bundle.js',
+ chunkFilename: IS_PRODUCTION
+ ? '[name].[chunkhash].chunk.js'
+ : '[name].chunk.js',
},
module: {
@@ -90,8 +97,8 @@ const config = {
{
loader: 'worker-loader',
options: {
- inline: true
- }
+ inline: true,
+ },
},
{ loader: 'babel-loader' },
],
@@ -102,7 +109,7 @@ const config = {
loader: 'file-loader',
options: {
name: '[name].[hash].[ext]',
- }
+ },
},
{
test: /katex.css$/,
@@ -112,8 +119,8 @@ const config = {
{
loader: 'css-loader',
options: {
- name: '[name].[hash].[ext]'
- }
+ name: '[name].[hash].[ext]',
+ },
},
],
},
@@ -123,15 +130,18 @@ const config = {
loader: 'file-loader',
options: {
name: '[name].[hash].[ext]',
- }
+ },
},
{
test: /monaco-editor\/\w+\/vs\/loader\.js$/,
use: [
{ loader: 'exports-loader', options: 'l.global' },
- { loader: 'imports-loader', options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined' },
+ {
+ loader: 'imports-loader',
+ options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined',
+ },
],
- }
+ },
],
noParse: [/monaco-editor\/\w+\/vs\//],
@@ -149,10 +159,10 @@ const config = {
source: false,
chunks: false,
modules: false,
- assets: true
+ assets: true,
});
return JSON.stringify(stats, null, 2);
- }
+ },
}),
// prevent pikaday from including moment.js
@@ -169,7 +179,7 @@ const config = {
new NameAllModulesPlugin(),
// assign deterministic chunk ids
- new webpack.NamedChunksPlugin((chunk) => {
+ new webpack.NamedChunksPlugin(chunk => {
if (chunk.name) {
return chunk.name;
}
@@ -186,9 +196,12 @@ const config = {
const pagesBase = path.join(ROOT_PATH, 'app/assets/javascripts/pages');
if (m.resource.indexOf(pagesBase) === 0) {
- moduleNames.push(path.relative(pagesBase, m.resource)
- .replace(/\/index\.[a-z]+$/, '')
- .replace(/\//g, '__'));
+ moduleNames.push(
+ path
+ .relative(pagesBase, m.resource)
+ .replace(/\/index\.[a-z]+$/, '')
+ .replace(/\//g, '__'),
+ );
} else {
moduleNames.push(path.relative(m.context, m.resource));
}
@@ -196,7 +209,8 @@ const config = {
chunk.forEachModule(collectModuleNames);
- const hash = crypto.createHash('sha256')
+ const hash = crypto
+ .createHash('sha256')
.update(moduleNames.join('_'))
.digest('hex');
@@ -214,10 +228,17 @@ const config = {
// copy pre-compiled vendor libraries verbatim
new CopyWebpackPlugin([
{
- from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`),
+ from: path.join(
+ ROOT_PATH,
+ `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`,
+ ),
to: 'monaco-editor/vs',
transform: function(content, path) {
- if (/\.js$/.test(path) && !/worker/i.test(path) && !/typescript/i.test(path)) {
+ if (
+ /\.js$/.test(path) &&
+ !/worker/i.test(path) &&
+ !/typescript/i.test(path)
+ ) {
return (
'(function(){\n' +
'var define = this.define, require = this.require;\n' +
@@ -227,23 +248,23 @@ const config = {
);
}
return content;
- }
- }
+ },
+ },
]),
],
resolve: {
extensions: ['.js'],
alias: {
- '~': path.join(ROOT_PATH, 'app/assets/javascripts'),
- 'emojis': path.join(ROOT_PATH, 'fixtures/emojis'),
- 'empty_states': path.join(ROOT_PATH, 'app/views/shared/empty_states'),
- 'icons': path.join(ROOT_PATH, 'app/views/shared/icons'),
- 'images': path.join(ROOT_PATH, 'app/assets/images'),
- 'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'),
- 'vue$': 'vue/dist/vue.esm.js',
- 'spec': path.join(ROOT_PATH, 'spec/javascripts'),
- }
+ '~': path.join(ROOT_PATH, 'app/assets/javascripts'),
+ emojis: path.join(ROOT_PATH, 'fixtures/emojis'),
+ empty_states: path.join(ROOT_PATH, 'app/views/shared/empty_states'),
+ icons: path.join(ROOT_PATH, 'app/views/shared/icons'),
+ images: path.join(ROOT_PATH, 'app/assets/images'),
+ vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'),
+ vue$: 'vue/dist/vue.esm.js',
+ spec: path.join(ROOT_PATH, 'spec/javascripts'),
+ },
},
// sqljs requires fs
@@ -258,14 +279,14 @@ if (IS_PRODUCTION) {
new webpack.NoEmitOnErrorsPlugin(),
new webpack.LoaderOptionsPlugin({
minimize: true,
- debug: false
+ debug: false,
}),
new webpack.optimize.UglifyJsPlugin({
- sourceMap: true
+ sourceMap: true,
}),
new webpack.DefinePlugin({
- 'process.env': { NODE_ENV: JSON.stringify('production') }
- })
+ 'process.env': { NODE_ENV: JSON.stringify('production') },
+ }),
);
// compression can require a lot of compute time and is disabled in CI
@@ -283,7 +304,7 @@ if (IS_DEV_SERVER) {
headers: { 'Access-Control-Allow-Origin': '*' },
stats: 'errors-only',
hot: DEV_SERVER_LIVERELOAD,
- inline: DEV_SERVER_LIVERELOAD
+ inline: DEV_SERVER_LIVERELOAD,
};
config.plugins.push(
// watch node_modules for changes if we encounter a missing module compile error
@@ -299,12 +320,14 @@ if (IS_DEV_SERVER) {
];
// report our auto-generated bundle count
- console.log(`${autoEntriesCount} entries from '/pages' automatically added to webpack output.`);
+ console.log(
+ `${autoEntriesCount} entries from '/pages' automatically added to webpack output.`,
+ );
callback();
- })
+ });
},
- }
+ },
);
if (DEV_SERVER_LIVERELOAD) {
config.plugins.push(new webpack.HotModuleReplacementPlugin());
@@ -319,7 +342,7 @@ if (WEBPACK_REPORT) {
openAnalyzer: false,
reportFilename: path.join(ROOT_PATH, 'webpack-report/index.html'),
statsFilename: path.join(ROOT_PATH, 'webpack-report/stats.json'),
- })
+ }),
);
}
diff --git a/doc/administration/monitoring/performance/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md
index ec1cbce1bad..dc4f685d843 100644
--- a/doc/administration/monitoring/performance/performance_bar.md
+++ b/doc/administration/monitoring/performance/performance_bar.md
@@ -13,12 +13,16 @@ It allows you to see (from left to right):
![SQL profiling using the Performance Bar](img/performance_bar_sql_queries.png)
- time taken and number of [Gitaly] calls, click through for details of these calls
![Gitaly profiling using the Performance Bar](img/performance_bar_gitaly_calls.png)
-- profile of the code used to generate the page, line by line for either _all_, _app & lib_ , or _views_. In the profile view, the numbers in the left panel represent wall time, cpu time, and number of calls (based on [rblineprof](https://github.com/tmm1/rblineprof)).
+- profile of the code used to generate the page, line by line. In the profile view, the numbers in the left panel represent wall time, cpu time, and number of calls (based on [rblineprof](https://github.com/tmm1/rblineprof)).
![Line profiling using the Performance Bar](img/performance_bar_line_profiling.png)
- time taken and number of calls to Redis
- time taken and number of background jobs created by Sidekiq
- time taken and number of Ruby GC calls
+On the far right is a request selector that allows you to view the same metrics
+(excluding the page timing and line profiler) for any requests made while the
+page was open. Only the first two requests per unique URL are captured.
+
## Enable the Performance Bar via the Admin panel
GitLab Performance Bar is disabled by default. To enable it for a given group,
diff --git a/doc/api/search.md b/doc/api/search.md
index d441b556186..107ddaffa6a 100644
--- a/doc/api/search.md
+++ b/doc/api/search.md
@@ -289,7 +289,7 @@ Search within the specified group.
If a user is not a member of a group and the group is private, a `GET` request on that group will result to a `404` status code.
```
-GET /groups/:id/-/search
+GET /groups/:id/search
```
| Attribute | Type | Required | Description |
@@ -305,7 +305,7 @@ The response depends on the requested scope.
### Scope: projects
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=projects&search=flight
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=projects&search=flight
```
Example response:
@@ -336,7 +336,7 @@ Example response:
### Scope: issues
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=issues&search=file
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=issues&search=file
```
Example response:
@@ -401,7 +401,7 @@ Example response:
### Scope: merge_requests
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=merge_requests&search=file
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=merge_requests&search=file
```
Example response:
@@ -478,7 +478,7 @@ Example response:
### Scope: milestones
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/-/search?scope=milestones&search=release
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/3/search?scope=milestones&search=release
```
Example response:
@@ -507,7 +507,7 @@ Search within the specified project.
If a user is not a member of a project and the project is private, a `GET` request on that project will result to a `404` status code.
```
-GET /projects/:id/-/search
+GET /projects/:id/search
```
| Attribute | Type | Required | Description |
@@ -524,7 +524,7 @@ The response depends on the requested scope.
### Scope: issues
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/-/search?scope=issues&search=file
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/search?scope=issues&search=file
```
Example response:
@@ -589,7 +589,7 @@ Example response:
### Scope: merge_requests
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=merge_requests&search=file
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=merge_requests&search=file
```
Example response:
@@ -666,7 +666,7 @@ Example response:
### Scope: milestones
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/-/search?scope=milestones&search=release
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/12/search?scope=milestones&search=release
```
Example response:
@@ -691,7 +691,7 @@ Example response:
### Scope: notes
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=notes&search=maxime
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=notes&search=maxime
```
Example response:
@@ -723,7 +723,7 @@ Example response:
### Scope: wiki_blobs
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=wiki_blobs&search=bye
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=wiki_blobs&search=bye
```
Example response:
@@ -746,7 +746,7 @@ Example response:
### Scope: commits
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=commits&search=bye
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=commits&search=bye
```
Example response:
@@ -777,7 +777,7 @@ Example response:
### Scope: blobs
```bash
-curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/-/search?scope=blobs&search=installation
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/6/search?scope=blobs&search=installation
```
Example response:
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index f879ed62010..9f2538b9c9f 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -280,3 +280,36 @@ We're always looking for contributions that can mitigate these
[register]: http://docs.gitlab.com/runner/register/
[protected branches]: ../../user/project/protected_branches.md
[protected tags]: ../../user/project/protected_tags.md
+
+## Determining the IP address of a Runner
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17286) in GitLab 10.6.
+
+It may be useful to know the IP address of a Runner so you can troubleshoot
+issues with that Runner. GitLab stores and displays the IP address by viewing
+the source of the HTTP requests it makes to GitLab when polling for jobs. The
+IP address is always kept up to date so if the Runner IP changes it will be
+automatically updated in GitLab.
+
+The IP address for shared Runners and specific Runners can be found in
+different places.
+
+### Shared Runners
+
+To view the IP address of a shared Runner you must have admin access to
+the GitLab instance. To determine this:
+
+1. Visit **Admin area ➔ Overview ➔ Runners**
+1. Look for the Runner in the table and you should see a column for "IP Address"
+
+![shared Runner IP address](img/shared_runner_ip_address.png)
+
+### Specific Runners
+
+You can find the IP address of a Runner for a specific project by:
+
+1. Visit your project's **Settings ➔ CI/CD**
+1. Find the Runner and click on it's ID which links you to the details page
+1. On the details page you should see a row for "IP Address"
+
+![specific Runner IP address](img/specific_runner_ip_address.png)
diff --git a/doc/ci/runners/img/shared_runner_ip_address.png b/doc/ci/runners/img/shared_runner_ip_address.png
new file mode 100644
index 00000000000..3b1542d59d3
--- /dev/null
+++ b/doc/ci/runners/img/shared_runner_ip_address.png
Binary files differ
diff --git a/doc/ci/runners/img/specific_runner_ip_address.png b/doc/ci/runners/img/specific_runner_ip_address.png
new file mode 100644
index 00000000000..3b4c3e9f2eb
--- /dev/null
+++ b/doc/ci/runners/img/specific_runner_ip_address.png
Binary files differ
diff --git a/doc/development/new_fe_guide/development/performance.md b/doc/development/new_fe_guide/development/performance.md
index 26b07874f0f..244dfb3756f 100644
--- a/doc/development/new_fe_guide/development/performance.md
+++ b/doc/development/new_fe_guide/development/performance.md
@@ -1,3 +1,16 @@
# Performance
-> TODO: Add content
+## Monitoring
+
+We have a performance dashboard available in one of our [grafana instances](https://performance.gprd.gitlab.com/dashboard/db/sitespeed-page-summary?orgId=1). This dashboard automatically aggregates metric data from [sitespeed.io](https://sitespeed.io) every 6 hours. These changes are displayed after a set number of pages are aggregated.
+
+These pages can be found inside a text file in the gitlab-build-images [repository](https://gitlab.com/gitlab-org/gitlab-build-images) called [gitlab.txt](https://gitlab.com/gitlab-org/gitlab-build-images/blob/master/scripts/gitlab.txt)
+Any frontend engineer can contribute to this dashboard. They can contribute by adding or removing urls of pages from this text file. Please have a [frontend monitoring expert](https://about.gitlab.com/team) review your changes before assigning to a maintainer of the `gitlab-build-images` project. The changes will go live on the next scheduled run after the changes are merged into `master`.
+
+There are 3 recommended high impact metrics to review on each page
+
+* [First visual change](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint)
+* [Speed Index](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index)
+* [Visual Complete 95%](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index)
+
+For these metrics, lower numbers are better as it means that the website is more performant.
diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md
index 3a1707a54a1..aa9b8777359 100644
--- a/doc/install/kubernetes/index.md
+++ b/doc/install/kubernetes/index.md
@@ -10,7 +10,7 @@ should be deployed, upgraded, and configured.
## Chart Overview
* **[GitLab-Omnibus](gitlab_omnibus.md)**: The best way to run GitLab on Kubernetes today, suited for small deployments. The chart is in beta and will be deprecated by the [cloud native GitLab chart](#cloud-native-gitlab-chart).
-* **[Cloud Native GitLab Chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md)**: The next generation GitLab chart, currently in development. Will support large deployments with horizontal scaling of individual GitLab components.
+* **[Cloud Native GitLab Chart](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md)**: The next generation GitLab chart, currently in alpha. Will support large deployments with horizontal scaling of individual GitLab components.
* Other Charts
* [GitLab Runner Chart](gitlab_runner_chart.md): For deploying just the GitLab Runner.
* [Advanced GitLab Installation](gitlab_chart.md): Deprecated, being replaced by the [cloud native GitLab chart](#cloud-native-gitlab-chart). Provides additional deployment options, but provides less functionality out-of-the-box.
@@ -35,7 +35,7 @@ By offering individual containers and charts, we will be able to provide a numbe
* Potential for rolling updates and canaries within a service,
* and plenty more.
-This is a large project and will be worked on over the span of multiple releases. For the most up-to-date status and release information, please see our [tracking issue](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/2420). We are planning to launch this chart in beta by the end of 2017.
+Presently this chart is available in alpha for testing, and not recommended for production use.
Learn more about the [cloud native GitLab chart here ](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) and [here [Video]](https://youtu.be/Z6jWR8Z8dv8).
diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md
index 661697aaeb7..bd9bcfadb99 100644
--- a/doc/user/project/clusters/index.md
+++ b/doc/user/project/clusters/index.md
@@ -167,6 +167,17 @@ external IP address with the following procedure. It can be deployed using the
In order to publish your web application, you first need to find the external IP
address associated to your load balancer.
+### Let GitLab fetch the IP address
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17052) in GitLab 10.6.
+
+If you installed the Ingress [via the **Applications**](#installing-applications),
+you should see the Ingress IP address on this same page within a few minutes.
+If you don't see this, GitLab might not be able to determine the IP address of
+your ingress application in which case you should manually determine it.
+
+### Manually determining the IP address
+
If the cluster is on GKE, click on the **Google Kubernetes Engine** link in the
**Advanced settings**, or go directly to the
[Google Kubernetes Engine dashboard](https://console.cloud.google.com/kubernetes/)
@@ -193,6 +204,24 @@ The output is the external IP address of your cluster. This information can then
be used to set up DNS entries and forwarding rules that allow external access to
your deployed applications.
+### Using a static IP
+
+By default, an ephemeral external IP address is associated to the cluster's load
+balancer. If you associate the ephemeral IP with your DNS and the IP changes,
+your apps will not be able to be reached, and you'd have to change the DNS
+record again. In order to avoid that, you should change it into a static
+reserved IP.
+
+[Read how to promote an ephemeral external IP address in GKE.](https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#promote_ephemeral_ip)
+
+### Pointing your DNS at the cluster IP
+
+Once you've set up the static IP, you should associate it to a [wildcard DNS
+record](https://en.wikipedia.org/wiki/Wildcard_DNS_record), in order to be able
+to reach your apps. This heavily depends on your domain provider, but in case
+you aren't sure, just create an A record with a wildcard host like
+`*.example.com.`.
+
## Setting the environment scope
NOTE: **Note:**
diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md
index 249463fb86e..fa7e504c4aa 100644
--- a/doc/user/project/integrations/prometheus.md
+++ b/doc/user/project/integrations/prometheus.md
@@ -2,7 +2,7 @@
> [Introduced][ce-8935] in GitLab 9.0.
-GitLab offers powerful integration with [Prometheus] for monitoring key metrics your apps, directly within GitLab.
+GitLab offers powerful integration with [Prometheus] for monitoring key metrics of your apps, directly within GitLab.
Metrics for each environment are retrieved from Prometheus, and then displayed
within the GitLab interface.
@@ -12,17 +12,21 @@ There are two ways to setup Prometheus integration, depending on where your apps
* For deployments on Kubernetes, GitLab can automatically [deploy and manage Prometheus](#managed-prometheus-on-kubernetes)
* For other deployment targets, simply [specify the Prometheus server](#manual-configuration-of-prometheus).
-## Managed Prometheus on Kubernetes
+Once enabled, GitLab will automatically detect metrics from known services in the [metric library](#monitoring-ci-cd-environments).
+
+## Enabling Prometheus Integration
+
+### Managed Prometheus on Kubernetes
> **Note**: [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/28916) in GitLab 10.5
GitLab can seamlessly deploy and manage Prometheus on a [connected Kubernetes cluster](../clusters/index.md), making monitoring of your apps easy.
-### Requirements
+#### Requirements
* A [connected Kubernetes cluster](../clusters/index.md)
* Helm Tiller [installed by GitLab](../clusters/index.md#installing-applications)
-### Getting started
+#### Getting started
Once you have a connected Kubernetes cluster with Helm installed, deploying a managed Prometheus is as easy as a single click.
@@ -32,7 +36,7 @@ Once you have a connected Kubernetes cluster with Helm installed, deploying a ma
![Managed Prometheus Deploy](img/prometheus_deploy.png)
-### About managed Prometheus deployments
+#### About managed Prometheus deployments
Prometheus is deployed into the `gitlab-managed-apps` namespace, using the [official Helm chart](https://github.com/kubernetes/charts/tree/master/stable/prometheus). Prometheus is only accessible within the cluster, with GitLab communicating through the [Kubernetes API](https://kubernetes.io/docs/concepts/overview/kubernetes-api/).
@@ -45,9 +49,9 @@ CPU and Memory consumption is monitored, but requires [naming conventions](prome
The [NGINX Ingress](../clusters/index.md#installing-applications) that is deployed by GitLab to clusters, is automatically annotated for monitoring providing key response metrics: latency, throughput, and error rates.
-## Manual configuration of Prometheus
+### Manual configuration of Prometheus
-### Requirements
+#### Requirements
Integration with Prometheus requires the following:
@@ -56,7 +60,7 @@ Integration with Prometheus requires the following:
1. Each metric must be have a label to indicate the environment
1. GitLab must have network connectivity to the Prometheus server
-### Getting started
+#### Getting started
Installing and configuring Prometheus to monitor applications is fairly straight forward.
@@ -64,7 +68,7 @@ Installing and configuring Prometheus to monitor applications is fairly straight
1. Set up one of the [supported monitoring targets](prometheus_library/metrics.md)
1. Configure the Prometheus server to [collect their metrics](https://prometheus.io/docs/operating/configuration/#scrape_config)
-### Configuration in GitLab
+#### Configuration in GitLab
The actual configuration of Prometheus integration within GitLab is very simple.
All you will need is the DNS or IP address of the Prometheus server you'd like
@@ -83,9 +87,9 @@ to integrate with.
Once configured, GitLab will attempt to retrieve performance metrics for any
environment which has had a successful deployment.
-GitLab will automatically scan the Prometheus server for known metrics and attempt to identify the metrics for a particular environment. The supported metrics and scan process is detailed in our [Prometheus Metric Library documentation](prometheus_library/metrics.html).
+GitLab will automatically scan the Prometheus server for metrics from known serves like Kubernetes and NGINX, and attempt to identify individual environment. The supported metrics and scan process is detailed in our [Prometheus Metric Library documentation](prometheus_library/metrics.html).
-[Learn more about monitoring environments.](../../../ci/environments.md#monitoring-environments)
+You can view the performance dashboard for an environment by [clicking on the monitoring button](../../../ci/environments.md#monitoring-environments).
## Determining the performance impact of a merge
@@ -93,7 +97,7 @@ GitLab will automatically scan the Prometheus server for known metrics and attem
> GitLab 9.3 added the [numeric comparison](https://gitlab.com/gitlab-org/gitlab-ce/issues/27439) of the 30 minute averages.
> Requires [Kubernetes](prometheus_library/kubernetes.md) metrics
-Developers can view theperformance impact of their changes within the merge
+Developers can view the performance impact of their changes within the merge
request workflow. When a source branch has been deployed to an environment, a sparkline and numeric comparison of the average memory consumption will appear. On the sparkline, a dot
indicates when the current changes were deployed, with up to 30 minutes of
performance data displayed before and after. The comparison shows the difference between the 30 minute average before and after the deployment. This information is updated after
@@ -109,7 +113,7 @@ Prometheus server.
## Troubleshooting
-If the "Attempting to load performance data" screen continues to appear, it could be due to:
+If the "No data found" screen continues to appear, it could be due to:
- No successful deployments have occurred to this environment.
- Prometheus does not have performance data for this environment, or the metrics
diff --git a/doc/user/project/merge_requests/maintainer_access.md b/doc/user/project/merge_requests/maintainer_access.md
index 7feccc28f6b..c9763a3fe02 100644
--- a/doc/user/project/merge_requests/maintainer_access.md
+++ b/doc/user/project/merge_requests/maintainer_access.md
@@ -1,12 +1,17 @@
# Allow maintainer pushes for merge requests across forks
+> [Introduced][ce-17395] in GitLab 10.6.
+
This feature is available for merge requests across forked projects that are
-publicly accessible. It makes it easier for maintainers of projects to collaborate
-on merge requests across forks.
+publicly accessible. It makes it easier for maintainers of projects to
+collaborate on merge requests across forks.
-When enabling this feature for a merge request, you give can give members with push access to the target project rights to edit files on the source branch of the merge request.
+When enabled for a merge request, members with merge access to the target
+branch of the project will be granted write permissions to the source branch
+of the merge request.
-The feature can only be enabled by users who already have push access to the source project. And only lasts while the merge request is open.
+The feature can only be enabled by users who already have push access to the
+source project, and only lasts while the merge request is open.
Enable this functionality while creating a merge request:
diff --git a/lib/api/search.rb b/lib/api/search.rb
index 3556ad98c52..5d9ec617cb7 100644
--- a/lib/api/search.rb
+++ b/lib/api/search.rb
@@ -84,7 +84,7 @@ module API
values: %w(projects issues merge_requests milestones)
use :pagination
end
- get ':id/-/search' do
+ get ':id/(-/)search' do
present search(group_id: user_group.id), with: entity
end
end
@@ -103,7 +103,7 @@ module API
values: %w(issues merge_requests milestones notes wiki_blobs commits blobs)
use :pagination
end
- get ':id/-/search' do
+ get ':id/(-/)search' do
present search(project_id: user_project.id), with: entity
end
end
diff --git a/lib/api/services.rb b/lib/api/services.rb
index 6c97659166d..794fdab8f2b 100644
--- a/lib/api/services.rb
+++ b/lib/api/services.rb
@@ -735,7 +735,7 @@ module API
required: false,
name: event_name.to_sym,
type: String,
- desc: ServicesHelper.service_event_description(event_name)
+ desc: service.event_description(event_name)
}
end
end
diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb
index 4001b8a85e3..8b2f05fffec 100644
--- a/lib/banzai/pipeline/gfm_pipeline.rb
+++ b/lib/banzai/pipeline/gfm_pipeline.rb
@@ -2,10 +2,10 @@ module Banzai
module Pipeline
class GfmPipeline < BasePipeline
# These filters convert GitLab Flavored Markdown (GFM) to HTML.
- # The handlers defined in app/assets/javascripts/copy_as_gfm.js
+ # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
# consequently convert that same HTML to GFM to be copied to the clipboard.
# Every filter that generates HTML from GFM should have a handler in
- # app/assets/javascripts/copy_as_gfm.js, in reverse order.
+ # app/assets/javascripts/behaviors/markdown/copy_as_gfm.js, in reverse order.
# The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
def self.filters
@filters ||= FilterArray[
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 9811c447a01..208710b0935 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -1390,7 +1390,7 @@ module Gitlab
offset = 2
args = %W(grep -i -I -n -z --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref})
- run_git(args).first.scrub.split(/^--$/)
+ run_git(args).first.scrub.split(/^--\n/)
end
def can_be_merged?(source_sha, target_branch)
diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb
index 29277ec6481..390efda326a 100644
--- a/lib/gitlab/project_search_results.rb
+++ b/lib/gitlab/project_search_results.rb
@@ -58,7 +58,7 @@ module Gitlab
data = ""
startline = 0
- result.strip.each_line.each_with_index do |line, index|
+ result.each_line.each_with_index do |line, index|
prefix ||= line.match(/^(?<ref>[^:]*):(?<filename>.*)\x00(?<startline>\d+)\x00/)&.tap do |matches|
ref = matches[:ref]
filename = matches[:filename]
diff --git a/lib/peek/views/host.rb b/lib/peek/views/host.rb
new file mode 100644
index 00000000000..43c8a35c7ea
--- /dev/null
+++ b/lib/peek/views/host.rb
@@ -0,0 +1,9 @@
+module Peek
+ module Views
+ class Host < View
+ def results
+ { hostname: Gitlab::Environment.hostname }
+ end
+ end
+ end
+end
diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb
index 35875487da8..2f2506f08fb 100644
--- a/qa/qa/page/merge_request/show.rb
+++ b/qa/qa/page/merge_request/show.rb
@@ -4,6 +4,7 @@ module QA
class Show < Page::Base
view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js' do
element :merge_button
+ element :fast_forward_message, 'Fast-forward merge without a merge commit'
end
view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue' do
@@ -12,19 +13,19 @@ module QA
view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue' do
element :mr_rebase_button
- element :fast_forward_nessage, "Fast-forward merge is not possible"
+ element :no_fast_forward_message, 'Fast-forward merge is not possible'
end
def rebase!
- wait(reload: false) do
- click_element :mr_rebase_button
+ click_element :mr_rebase_button
- has_text?("The source branch HEAD has recently changed.")
+ wait(reload: false) do
+ has_text?('Fast-forward merge without a merge commit')
end
end
def fast_forward_possible?
- !has_text?("Fast-forward merge is not possible")
+ !has_text?('Fast-forward merge is not possible')
end
def has_merge_button?
@@ -34,10 +35,10 @@ module QA
end
def merge!
- wait(reload: false) do
- click_element :merge_button
+ click_element :merge_button
- has_text?("The changes were merged into")
+ wait(reload: false) do
+ has_text?('The changes were merged into')
end
end
end
diff --git a/spec/controllers/admin/projects_controller_spec.rb b/spec/controllers/admin/projects_controller_spec.rb
index d5a3c250f31..cc200b9fed9 100644
--- a/spec/controllers/admin/projects_controller_spec.rb
+++ b/spec/controllers/admin/projects_controller_spec.rb
@@ -31,5 +31,15 @@ describe Admin::ProjectsController do
expect(response.body).not_to match(pending_delete_project.name)
expect(response.body).to match(project.name)
end
+
+ it 'does not have N+1 queries', :use_clean_rails_memory_store_caching, :request_store do
+ get :index
+
+ control_count = ActiveRecord::QueryRecorder.new { get :index }.count
+
+ create(:project)
+
+ expect { get :index }.not_to exceed_query_limit(control_count)
+ end
end
end
diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb
index f82ed6300cc..4d897f09b57 100644
--- a/spec/features/markdown/copy_as_gfm_spec.rb
+++ b/spec/features/markdown/copy_as_gfm_spec.rb
@@ -20,7 +20,7 @@ describe 'Copy as GFM', :js do
end
# The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML.
- # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM.
+ # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js consequently convert that same HTML to GFM.
# To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle
# by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper.
diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb
new file mode 100644
index 00000000000..d96c7e655ba
--- /dev/null
+++ b/spec/features/projects/tree/create_directory_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+
+feature 'Multi-file editor new directory', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_tree_path(project, :master)
+
+ wait_for_requests
+
+ click_link('Web IDE')
+
+ wait_for_requests
+ end
+
+ after do
+ set_cookie('new_repo', 'false')
+ end
+
+ it 'creates directory in current directory' do
+ find('.add-to-tree').click
+
+ click_link('New directory')
+
+ page.within('.modal') do
+ find('.form-control').set('folder name')
+
+ click_button('Create directory')
+ end
+
+ find('.add-to-tree').click
+
+ click_link('New file')
+
+ page.within('.modal-dialog') do
+ find('.form-control').set('file name')
+
+ click_button('Create file')
+ end
+
+ wait_for_requests
+
+ fill_in('commit-message', with: 'commit message ide')
+
+ click_button('Commit')
+
+ expect(page).to have_content('folder name')
+ end
+end
diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb
new file mode 100644
index 00000000000..a4cbd5cf766
--- /dev/null
+++ b/spec/features/projects/tree/create_file_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+feature 'Multi-file editor new file', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_path(project)
+
+ wait_for_requests
+
+ click_link('Web IDE')
+
+ wait_for_requests
+ end
+
+ after do
+ set_cookie('new_repo', 'false')
+ end
+
+ it 'creates file in current directory' do
+ find('.add-to-tree').click
+
+ click_link('New file')
+
+ page.within('.modal') do
+ find('.form-control').set('file name')
+
+ click_button('Create file')
+ end
+
+ wait_for_requests
+
+ fill_in('commit-message', with: 'commit message ide')
+
+ click_button('Commit')
+
+ expect(page).to have_content('file name')
+ end
+end
diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb
new file mode 100644
index 00000000000..8e53ae15700
--- /dev/null
+++ b/spec/features/projects/tree/upload_file_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+feature 'Multi-file editor upload file', :js do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+ let(:txt_file) { File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt') }
+ let(:img_file) { File.join(Rails.root, 'spec', 'fixtures', 'dk.png') }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+
+ visit project_tree_path(project, :master)
+
+ wait_for_requests
+
+ click_link('Web IDE')
+
+ wait_for_requests
+ end
+
+ after do
+ set_cookie('new_repo', 'false')
+ end
+
+ it 'uploads text file' do
+ find('.add-to-tree').click
+
+ # make the field visible so capybara can use it
+ execute_script('document.querySelector("#file-upload").classList.remove("hidden")')
+ attach_file('file-upload', txt_file)
+
+ find('.add-to-tree').click
+
+ expect(page).to have_selector('.multi-file-tab', text: 'doc_sample.txt')
+ expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline))
+ end
+
+ it 'uploads image file' do
+ find('.add-to-tree').click
+
+ # make the field visible so capybara can use it
+ execute_script('document.querySelector("#file-upload").classList.remove("hidden")')
+ attach_file('file-upload', img_file)
+
+ find('.add-to-tree').click
+
+ expect(page).to have_selector('.multi-file-tab', text: 'dk.png')
+ expect(page).not_to have_selector('.monaco-editor')
+ end
+end
diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb
index 975c157bcf5..e069c2fddd1 100644
--- a/spec/features/user_can_display_performance_bar_spec.rb
+++ b/spec/features/user_can_display_performance_bar_spec.rb
@@ -3,7 +3,7 @@ require 'rails_helper'
describe 'User can display performance bar', :js do
shared_examples 'performance bar cannot be displayed' do
it 'does not show the performance bar by default' do
- expect(page).not_to have_css('#peek')
+ expect(page).not_to have_css('#js-peek')
end
context 'when user press `pb`' do
@@ -12,14 +12,14 @@ describe 'User can display performance bar', :js do
end
it 'does not show the performance bar by default' do
- expect(page).not_to have_css('#peek')
+ expect(page).not_to have_css('#js-peek')
end
end
end
shared_examples 'performance bar can be displayed' do
it 'does not show the performance bar by default' do
- expect(page).not_to have_css('#peek')
+ expect(page).not_to have_css('#js-peek')
end
context 'when user press `pb`' do
@@ -28,7 +28,7 @@ describe 'User can display performance bar', :js do
end
it 'shows the performance bar' do
- expect(page).to have_css('#peek')
+ expect(page).to have_css('#js-peek')
end
end
end
@@ -41,7 +41,7 @@ describe 'User can display performance bar', :js do
it 'shows the performance bar by default' do
refresh # Because we're stubbing Rails.env after the 1st visit to root_path
- expect(page).to have_css('#peek')
+ expect(page).to have_css('#js-peek')
end
end
diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js
index b8155144e2a..efbe09a10a2 100644
--- a/spec/javascripts/behaviors/copy_as_gfm_spec.js
+++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js
@@ -1,4 +1,4 @@
-import { CopyAsGFM } from '~/behaviors/copy_as_gfm';
+import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
describe('CopyAsGFM', () => {
describe('CopyAsGFM.pasteGFM', () => {
diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js
new file mode 100644
index 00000000000..987aea7befc
--- /dev/null
+++ b/spec/javascripts/ide/components/changed_file_icon_spec.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import changedFileIcon from '~/ide/components/changed_file_icon.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('IDE changed file icon', () => {
+ let vm;
+
+ beforeEach(() => {
+ const component = Vue.extend(changedFileIcon);
+
+ vm = createComponent(component, {
+ file: {
+ tempFile: false,
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('changedIcon', () => {
+ it('equals file-modified when not a temp file', () => {
+ expect(vm.changedIcon).toBe('file-modified');
+ });
+
+ it('equals file-addition when a temp file', () => {
+ vm.file.tempFile = true;
+
+ expect(vm.changedIcon).toBe('file-addition');
+ });
+ });
+
+ describe('changedIconClass', () => {
+ it('includes multi-file-modified when not a temp file', () => {
+ expect(vm.changedIconClass).toContain('multi-file-modified');
+ });
+
+ it('includes multi-file-addition when a temp file', () => {
+ vm.file.tempFile = true;
+
+ expect(vm.changedIconClass).toContain('multi-file-addition');
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js
new file mode 100644
index 00000000000..144e78d14b5
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import commitActions from '~/ide/components/commit_sidebar/actions.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from 'spec/ide/helpers';
+
+describe('IDE commit sidebar actions', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(commitActions);
+
+ vm = createComponentWithStore(Component, store);
+
+ vm.$store.state.currentBranchId = 'master';
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders 3 groups', () => {
+ expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3);
+ });
+
+ it('renders current branch text', () => {
+ expect(vm.$el.textContent).toContain('Commit to master branch');
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js
new file mode 100644
index 00000000000..5b402886b55
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { file } from '../../helpers';
+
+describe('Multi-file editor commit sidebar list collapsed', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(listCollapsed);
+
+ vm = createComponentWithStore(Component, store);
+
+ vm.$store.state.changedFiles.push(file('file1'), file('file2'));
+ vm.$store.state.changedFiles[0].tempFile = true;
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders added & modified files count', () => {
+ expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1');
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js
new file mode 100644
index 00000000000..15b66952d99
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js
@@ -0,0 +1,85 @@
+import Vue from 'vue';
+import listItem from '~/ide/components/commit_sidebar/list_item.vue';
+import router from '~/ide/ide_router';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { file } from '../../helpers';
+
+describe('Multi-file editor commit sidebar list item', () => {
+ let vm;
+ let f;
+
+ beforeEach(() => {
+ const Component = Vue.extend(listItem);
+
+ f = file('test-file');
+
+ vm = mountComponent(Component, {
+ file: f,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders file path', () => {
+ expect(
+ vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim(),
+ ).toBe(f.path);
+ });
+
+ it('calls discardFileChanges when clicking discard button', () => {
+ spyOn(vm, 'discardFileChanges');
+
+ vm.$el.querySelector('.multi-file-discard-btn').click();
+
+ expect(vm.discardFileChanges).toHaveBeenCalled();
+ });
+
+ it('opens a closed file in the editor when clicking the file path', () => {
+ spyOn(vm, 'openFileInEditor').and.callThrough();
+ spyOn(vm, 'updateViewer');
+ spyOn(router, 'push');
+
+ vm.$el.querySelector('.multi-file-commit-list-path').click();
+
+ expect(vm.openFileInEditor).toHaveBeenCalled();
+ expect(router.push).toHaveBeenCalled();
+ });
+
+ it('calls updateViewer with diff when clicking file', () => {
+ spyOn(vm, 'openFileInEditor').and.callThrough();
+ spyOn(vm, 'updateViewer');
+ spyOn(router, 'push');
+
+ vm.$el.querySelector('.multi-file-commit-list-path').click();
+
+ expect(vm.updateViewer).toHaveBeenCalledWith('diff');
+ });
+
+ describe('computed', () => {
+ describe('iconName', () => {
+ it('returns modified when not a tempFile', () => {
+ expect(vm.iconName).toBe('file-modified');
+ });
+
+ it('returns addition when not a tempFile', () => {
+ f.tempFile = true;
+
+ expect(vm.iconName).toBe('file-addition');
+ });
+ });
+
+ describe('iconClass', () => {
+ it('returns modified when not a tempFile', () => {
+ expect(vm.iconClass).toContain('multi-file-modified');
+ });
+
+ it('returns addition when not a tempFile', () => {
+ f.tempFile = true;
+
+ expect(vm.iconClass).toContain('multi-file-addition');
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js
new file mode 100644
index 00000000000..a62c0a28340
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import commitSidebarList from '~/ide/components/commit_sidebar/list.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { file } from '../../helpers';
+
+describe('Multi-file editor commit sidebar list', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(commitSidebarList);
+
+ vm = createComponentWithStore(Component, store, {
+ title: 'Staged',
+ fileList: [],
+ });
+
+ vm.$store.state.rightPanelCollapsed = false;
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('with a list of files', () => {
+ beforeEach(done => {
+ const f = file('file name');
+ f.changed = true;
+ vm.fileList.push(f);
+
+ Vue.nextTick(done);
+ });
+
+ it('renders list', () => {
+ expect(vm.$el.querySelectorAll('li').length).toBe(1);
+ });
+ });
+
+ describe('collapsed', () => {
+ beforeEach(done => {
+ vm.$store.state.rightPanelCollapsed = true;
+
+ Vue.nextTick(done);
+ });
+
+ it('hides list', () => {
+ expect(vm.$el.querySelector('.list-unstyled')).toBeNull();
+ expect(vm.$el.querySelector('.help-block')).toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js
new file mode 100644
index 00000000000..4e8243439f3
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js
@@ -0,0 +1,130 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from 'spec/ide/helpers';
+
+describe('IDE commit sidebar radio group', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '2';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('uses label if present', () => {
+ expect(vm.$el.textContent).toContain('test');
+ });
+
+ it('uses slot if label is not present', done => {
+ vm.$destroy();
+
+ vm = new Vue({
+ components: {
+ radioGroup,
+ },
+ store,
+ template: `
+ <radio-group
+ value="1"
+ >
+ Testing slot
+ </radio-group>
+ `,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(() => {
+ expect(vm.$el.textContent).toContain('Testing slot');
+
+ done();
+ });
+ });
+
+ it('updates store when changing radio button', done => {
+ vm.$el.querySelector('input').dispatchEvent(new Event('change'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.commitAction).toBe('1');
+
+ done();
+ });
+ });
+
+ it('renders helpText tooltip', done => {
+ vm.helpText = 'help text';
+
+ Vue.nextTick(() => {
+ const help = vm.$el.querySelector('.help-block');
+
+ expect(help).not.toBeNull();
+ expect(help.getAttribute('data-original-title')).toBe('help text');
+
+ done();
+ });
+ });
+
+ describe('with input', () => {
+ beforeEach(done => {
+ vm.$destroy();
+
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '1';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ showInput: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders input box when commitAction matches value', () => {
+ expect(vm.$el.querySelector('.form-control')).not.toBeNull();
+ });
+
+ it('hides input when commitAction doesnt match value', done => {
+ store.state.commit.commitAction = '2';
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.form-control')).toBeNull();
+ done();
+ });
+ });
+
+ it('updates branch name in store on input', done => {
+ const input = vm.$el.querySelector('.form-control');
+ input.value = 'testing-123';
+ input.dispatchEvent(new Event('input'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.newBranchName).toBe('testing-123');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js
new file mode 100644
index 00000000000..e17b051f137
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_context_bar_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import ideContextBar from '~/ide/components/ide_context_bar.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+
+describe('Multi-file editor right context bar', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ideContextBar);
+
+ vm = createComponentWithStore(Component, store, {
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ });
+
+ vm.$store.state.rightPanelCollapsed = false;
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('collapsed', () => {
+ beforeEach(done => {
+ vm.$store.state.rightPanelCollapsed = true;
+
+ Vue.nextTick(done);
+ });
+
+ it('adds collapsed class', () => {
+ expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js
new file mode 100644
index 00000000000..9f6cb459f3b
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_external_links_spec.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import ideExternalLinks from '~/ide/components/ide_external_links.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('ide external links component', () => {
+ let vm;
+ let fakeReferrer;
+ let Component;
+
+ const fakeProjectUrl = '/project/';
+
+ beforeEach(() => {
+ Component = Vue.extend(ideExternalLinks);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('goBackUrl', () => {
+ it('renders the Go Back link with the referrer when present', () => {
+ fakeReferrer = '/example/README.md';
+ spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
+
+ vm = createComponent(Component, {
+ projectUrl: fakeProjectUrl,
+ }).$mount();
+
+ expect(vm.goBackUrl).toEqual(fakeReferrer);
+ });
+
+ it('renders the Go Back link with the project url when referrer is not present', () => {
+ fakeReferrer = '';
+ spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
+
+ vm = createComponent(Component, {
+ projectUrl: fakeProjectUrl,
+ }).$mount();
+
+ expect(vm.goBackUrl).toEqual(fakeProjectUrl);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js
new file mode 100644
index 00000000000..e0fbc90ca61
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import ideRepoTree from '~/ide/components/ide_repo_tree.vue';
+import createComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('IdeRepoTree', () => {
+ let vm;
+ let tree;
+
+ beforeEach(() => {
+ const IdeRepoTree = Vue.extend(ideRepoTree);
+
+ tree = {
+ tree: [file()],
+ loading: false,
+ };
+
+ vm = createComponent(IdeRepoTree, {
+ tree,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a sidebar', () => {
+ expect(vm.$el.querySelector('.loading-file')).toBeNull();
+ expect(vm.$el.querySelector('.file')).not.toBeNull();
+ });
+
+ it('renders 3 loading files if tree is loading', done => {
+ tree.loading = true;
+
+ vm.$nextTick(() => {
+ expect(
+ vm.$el.querySelectorAll('.multi-file-loading-container').length,
+ ).toEqual(3);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js
new file mode 100644
index 00000000000..699dae1ce2f
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_side_bar_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import ideSidebar from '~/ide/components/ide_side_bar.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from '../helpers';
+
+describe('IdeSidebar', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ideSidebar);
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a sidebar', () => {
+ expect(
+ vm.$el.querySelector('.multi-file-commit-panel-inner'),
+ ).not.toBeNull();
+ });
+
+ it('renders loading icon component', done => {
+ vm.$store.state.loading = true;
+
+ vm.$nextTick(() => {
+ expect(
+ vm.$el.querySelector('.multi-file-loading-container'),
+ ).not.toBeNull();
+ expect(
+ vm.$el.querySelectorAll('.multi-file-loading-container').length,
+ ).toBe(3);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js
new file mode 100644
index 00000000000..5bd890094cc
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_spec.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import ide from '~/ide/components/ide.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { file, resetStore } from '../helpers';
+
+describe('ide component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ide);
+
+ vm = createComponentWithStore(Component, store, {
+ emptyStateSvgPath: 'svg',
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('does not render panel right when no files open', () => {
+ expect(vm.$el.querySelector('.panel-right')).toBeNull();
+ });
+
+ it('renders panel right when files are open', done => {
+ vm.$store.state.trees['abcproject/mybranch'] = {
+ tree: [file()],
+ };
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.panel-right')).toBeNull();
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js
new file mode 100644
index 00000000000..e08abe7d849
--- /dev/null
+++ b/spec/javascripts/ide/components/new_dropdown/index_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import newDropdown from '~/ide/components/new_dropdown/index.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { resetStore } from '../../helpers';
+
+describe('new dropdown component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const component = Vue.extend(newDropdown);
+
+ vm = createComponentWithStore(component, store, {
+ branch: 'master',
+ path: '',
+ });
+
+ vm.$store.state.currentProjectId = 'abcproject';
+ vm.$store.state.path = '';
+ vm.$store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders new file, upload and new directory links', () => {
+ expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file');
+ expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe(
+ 'Upload file',
+ );
+ expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe(
+ 'New directory',
+ );
+ });
+
+ describe('createNewItem', () => {
+ it('sets modalType to blob when new file is clicked', () => {
+ vm.$el.querySelectorAll('a')[0].click();
+
+ expect(vm.modalType).toBe('blob');
+ });
+
+ it('sets modalType to tree when new directory is clicked', () => {
+ vm.$el.querySelectorAll('a')[2].click();
+
+ expect(vm.modalType).toBe('tree');
+ });
+
+ it('opens modal when link is clicked', done => {
+ vm.$el.querySelectorAll('a')[0].click();
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.modal')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
+
+ describe('hideModal', () => {
+ beforeAll(done => {
+ vm.openModal = true;
+ Vue.nextTick(done);
+ });
+
+ it('closes modal after toggling', done => {
+ vm.hideModal();
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.modal')).toBeNull();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js
new file mode 100644
index 00000000000..a6e1e5a0d35
--- /dev/null
+++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js
@@ -0,0 +1,82 @@
+import Vue from 'vue';
+import modal from '~/ide/components/new_dropdown/modal.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('new file modal component', () => {
+ const Component = Vue.extend(modal);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ ['tree', 'blob'].forEach(type => {
+ describe(type, () => {
+ beforeEach(() => {
+ vm = createComponent(Component, {
+ type,
+ branchId: 'master',
+ path: '',
+ });
+
+ vm.entryName = 'testing';
+ });
+
+ it(`sets modal title as ${type}`, () => {
+ const title = type === 'tree' ? 'directory' : 'file';
+
+ expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(
+ `Create new ${title}`,
+ );
+ });
+
+ it(`sets button label as ${type}`, () => {
+ const title = type === 'tree' ? 'directory' : 'file';
+
+ expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(
+ `Create ${title}`,
+ );
+ });
+
+ it(`sets form label as ${type}`, () => {
+ const title = type === 'tree' ? 'Directory' : 'File';
+
+ expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(
+ `${title} name`,
+ );
+ });
+
+ describe('createEntryInStore', () => {
+ it('$emits create', () => {
+ spyOn(vm, '$emit');
+
+ vm.createEntryInStore();
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ branchId: 'master',
+ name: 'testing',
+ type,
+ });
+ });
+ });
+ });
+ });
+
+ it('focuses field on mount', () => {
+ document.body.innerHTML += '<div class="js-test"></div>';
+
+ vm = createComponent(
+ Component,
+ {
+ type: 'tree',
+ branchId: 'master',
+ path: '',
+ },
+ '.js-test',
+ );
+
+ expect(document.activeElement).toBe(vm.$refs.fieldName);
+
+ vm.$el.remove();
+ });
+});
diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js
new file mode 100644
index 00000000000..2bc5d701601
--- /dev/null
+++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js
@@ -0,0 +1,87 @@
+import Vue from 'vue';
+import upload from '~/ide/components/new_dropdown/upload.vue';
+import createComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('new dropdown upload', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(upload);
+
+ vm = createComponent(Component, {
+ branchId: 'master',
+ path: '',
+ });
+
+ vm.entryName = 'testing';
+
+ spyOn(vm, '$emit');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('readFile', () => {
+ beforeEach(() => {
+ spyOn(FileReader.prototype, 'readAsText');
+ spyOn(FileReader.prototype, 'readAsDataURL');
+ });
+
+ it('calls readAsText for text files', () => {
+ const file = {
+ type: 'text/html',
+ };
+
+ vm.readFile(file);
+
+ expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file);
+ });
+
+ it('calls readAsDataURL for non-text files', () => {
+ const file = {
+ type: 'images/png',
+ };
+
+ vm.readFile(file);
+
+ expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
+ });
+ });
+
+ describe('createFile', () => {
+ const target = {
+ result: 'content',
+ };
+ const binaryTarget = {
+ result: 'base64,base64content',
+ };
+ const file = {
+ name: 'file',
+ };
+
+ it('creates new file', () => {
+ vm.createFile(target, file, true);
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: file.name,
+ branchId: 'master',
+ type: 'blob',
+ content: target.result,
+ base64: false,
+ });
+ });
+
+ it('splits content on base64 if binary', () => {
+ vm.createFile(binaryTarget, file, false);
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: file.name,
+ branchId: 'master',
+ type: 'blob',
+ content: binaryTarget.result.split('base64,')[1],
+ base64: true,
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js
new file mode 100644
index 00000000000..113ade269e9
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_commit_section_spec.js
@@ -0,0 +1,173 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import repoCommitSection from '~/ide/components/repo_commit_section.vue';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
+import { file, resetStore } from '../helpers';
+
+describe('RepoCommitSection', () => {
+ let vm;
+
+ function createComponent() {
+ const Component = Vue.extend(repoCommitSection);
+
+ vm = createComponentWithStore(Component, store, {
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'commitsvg',
+ });
+
+ vm.$store.state.currentProjectId = 'abcproject';
+ vm.$store.state.currentBranchId = 'master';
+ vm.$store.state.projects.abcproject = {
+ web_url: '',
+ branches: {
+ master: {
+ workingReference: '1',
+ },
+ },
+ };
+
+ vm.$store.state.rightPanelCollapsed = false;
+ vm.$store.state.currentBranch = 'master';
+ vm.$store.state.changedFiles = [file('file1'), file('file2')];
+ vm.$store.state.changedFiles.forEach(f =>
+ Object.assign(f, {
+ changed: true,
+ content: 'testing',
+ }),
+ );
+
+ return vm.$mount();
+ }
+
+ beforeEach(done => {
+ vm = createComponent();
+
+ spyOn(service, 'getTreeData').and.returnValue(
+ Promise.resolve({
+ headers: {
+ 'page-title': 'test',
+ },
+ json: () =>
+ Promise.resolve({
+ last_commit_path: 'last_commit_path',
+ parent_tree_url: 'parent_tree_url',
+ path: '/',
+ trees: [{ name: 'tree' }],
+ blobs: [{ name: 'blob' }],
+ submodules: [{ name: 'submodule' }],
+ }),
+ }),
+ );
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('empty Stage', () => {
+ it('renders no changes text', () => {
+ resetStore(vm.$store);
+ const Component = Vue.extend(repoCommitSection);
+
+ vm = createComponentWithStore(Component, store, {
+ noChangesStateSvgPath: 'nochangessvg',
+ committedStateSvgPath: 'svg',
+ }).$mount();
+
+ expect(
+ vm.$el.querySelector('.js-empty-state').textContent.trim(),
+ ).toContain('No changes');
+ expect(
+ vm.$el.querySelector('.js-empty-state img').getAttribute('src'),
+ ).toBe('nochangessvg');
+ });
+ });
+
+ it('renders a commit section', () => {
+ const changedFileElements = [
+ ...vm.$el.querySelectorAll('.multi-file-commit-list li'),
+ ];
+ const submitCommit = vm.$el.querySelector('form .btn');
+
+ expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull();
+ expect(changedFileElements.length).toEqual(2);
+
+ changedFileElements.forEach((changedFile, i) => {
+ expect(changedFile.textContent.trim()).toContain(
+ vm.$store.state.changedFiles[i].path,
+ );
+ });
+
+ expect(submitCommit.disabled).toBeTruthy();
+ expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull();
+ });
+
+ it('updates commitMessage in store on input', done => {
+ const textarea = vm.$el.querySelector('textarea');
+
+ textarea.value = 'testing commit message';
+
+ textarea.dispatchEvent(new Event('input'));
+
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(vm.$store.state.commit.commitMessage).toBe(
+ 'testing commit message',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ describe('discard draft button', () => {
+ it('hidden when commitMessage is empty', () => {
+ expect(
+ vm.$el.querySelector('.multi-file-commit-form .btn-default'),
+ ).toBeNull();
+ });
+
+ it('resets commitMessage when clicking discard button', done => {
+ vm.$store.state.commit.commitMessage = 'testing commit message';
+
+ getSetTimeoutPromise()
+ .then(() => {
+ vm.$el.querySelector('.multi-file-commit-form .btn-default').click();
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.$store.state.commit.commitMessage).not.toBe(
+ 'testing commit message',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('when submitting', () => {
+ beforeEach(() => {
+ spyOn(vm, 'commitChanges');
+ });
+
+ it('calls commitChanges', done => {
+ vm.$store.state.commit.commitMessage = 'testing commit message';
+
+ getSetTimeoutPromise()
+ .then(() => {
+ vm.$el.querySelector('.multi-file-commit-form .btn-success').click();
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.commitChanges).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js
new file mode 100644
index 00000000000..ae657e8c881
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_editor_spec.js
@@ -0,0 +1,137 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoEditor from '~/ide/components/repo_editor.vue';
+import monacoLoader from '~/ide/monaco_loader';
+import Editor from '~/ide/lib/editor';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { file, resetStore } from '../helpers';
+
+describe('RepoEditor', () => {
+ let vm;
+
+ beforeEach(done => {
+ const f = file();
+ const RepoEditor = Vue.extend(repoEditor);
+
+ vm = createComponentWithStore(RepoEditor, store, {
+ file: f,
+ });
+
+ f.active = true;
+ f.tempFile = true;
+ f.html = 'testing';
+ vm.$store.state.openFiles.push(f);
+ vm.$store.state.entries[f.path] = f;
+ vm.monaco = true;
+
+ vm.$mount();
+
+ monacoLoader(['vs/editor/editor.main'], () => {
+ setTimeout(done, 0);
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+
+ Editor.editorInstance.modelManager.dispose();
+ });
+
+ it('renders an ide container', done => {
+ Vue.nextTick(() => {
+ expect(vm.shouldHideEditor).toBeFalsy();
+
+ done();
+ });
+ });
+
+ describe('when open file is binary and not raw', () => {
+ beforeEach(done => {
+ vm.file.binary = true;
+
+ vm.$nextTick(done);
+ });
+
+ it('does not render the IDE', () => {
+ expect(vm.shouldHideEditor).toBeTruthy();
+ });
+
+ it('shows activeFile html', () => {
+ expect(vm.$el.textContent).toContain('testing');
+ });
+ });
+
+ describe('createEditorInstance', () => {
+ it('calls createInstance when viewer is editor', done => {
+ spyOn(vm.editor, 'createInstance');
+
+ vm.createEditorInstance();
+
+ vm.$nextTick(() => {
+ expect(vm.editor.createInstance).toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('calls createDiffInstance when viewer is diff', done => {
+ vm.$store.state.viewer = 'diff';
+
+ spyOn(vm.editor, 'createDiffInstance');
+
+ vm.createEditorInstance();
+
+ vm.$nextTick(() => {
+ expect(vm.editor.createDiffInstance).toHaveBeenCalled();
+
+ done();
+ });
+ });
+ });
+
+ describe('setupEditor', () => {
+ it('creates new model', () => {
+ spyOn(vm.editor, 'createModel').and.callThrough();
+
+ Editor.editorInstance.modelManager.dispose();
+
+ vm.setupEditor();
+
+ expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file);
+ expect(vm.model).not.toBeNull();
+ });
+
+ it('attaches model to editor', () => {
+ spyOn(vm.editor, 'attachModel').and.callThrough();
+
+ Editor.editorInstance.modelManager.dispose();
+
+ vm.setupEditor();
+
+ expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model);
+ });
+
+ it('adds callback methods', () => {
+ spyOn(vm.editor, 'onPositionChange').and.callThrough();
+
+ Editor.editorInstance.modelManager.dispose();
+
+ vm.setupEditor();
+
+ expect(vm.editor.onPositionChange).toHaveBeenCalled();
+ expect(vm.model.events.size).toBe(1);
+ });
+
+ it('updates state when model content changed', done => {
+ vm.model.setValue('testing 123');
+
+ setTimeout(() => {
+ expect(vm.file.content).toBe('testing 123');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_file_buttons_spec.js b/spec/javascripts/ide/components/repo_file_buttons_spec.js
new file mode 100644
index 00000000000..c86bdb132b4
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_file_buttons_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import repoFileButtons from '~/ide/components/repo_file_buttons.vue';
+import createVueComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('RepoFileButtons', () => {
+ const activeFile = file();
+ let vm;
+
+ function createComponent() {
+ const RepoFileButtons = Vue.extend(repoFileButtons);
+
+ activeFile.rawPath = 'test';
+ activeFile.blamePath = 'test';
+ activeFile.commitsPath = 'test';
+
+ return createVueComponent(RepoFileButtons, {
+ file: activeFile,
+ });
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders Raw, Blame, History, Permalink and Preview toggle', done => {
+ vm = createComponent();
+
+ vm.$nextTick(() => {
+ const raw = vm.$el.querySelector('.raw');
+ const blame = vm.$el.querySelector('.blame');
+ const history = vm.$el.querySelector('.history');
+
+ expect(raw.href).toMatch(`/${activeFile.rawPath}`);
+ expect(raw.textContent.trim()).toEqual('Raw');
+ expect(blame.href).toMatch(`/${activeFile.blamePath}`);
+ expect(blame.textContent.trim()).toEqual('Blame');
+ expect(history.href).toMatch(`/${activeFile.commitsPath}`);
+ expect(history.textContent.trim()).toEqual('History');
+ expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual(
+ 'Permalink',
+ );
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js
new file mode 100644
index 00000000000..ff391cb4351
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_file_spec.js
@@ -0,0 +1,80 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoFile from '~/ide/components/repo_file.vue';
+import router from '~/ide/ide_router';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('RepoFile', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const RepoFile = Vue.extend(repoFile);
+
+ vm = createComponentWithStore(RepoFile, store, propsData);
+
+ vm.$mount();
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders link, icon and name', () => {
+ createComponent({
+ file: file('t4'),
+ level: 0,
+ });
+
+ const name = vm.$el.querySelector('.ide-file-name');
+
+ expect(name.href).toMatch('');
+ expect(name.textContent.trim()).toEqual(vm.file.name);
+ });
+
+ it('fires clickFile when the link is clicked', done => {
+ spyOn(router, 'push');
+ createComponent({
+ file: file('t3'),
+ level: 0,
+ });
+
+ vm.$el.querySelector('.file-name').click();
+
+ setTimeout(() => {
+ expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`);
+
+ done();
+ });
+ });
+
+ describe('locked file', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file('locked file');
+ f.file_lock = {
+ user: {
+ name: 'testuser',
+ updated_at: new Date(),
+ },
+ };
+
+ createComponent({
+ file: f,
+ level: 0,
+ });
+ });
+
+ it('renders lock icon', () => {
+ expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
+ });
+
+ it('renders a tooltip', () => {
+ expect(
+ vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset
+ .originalTitle,
+ ).toContain('Locked by testuser');
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_loading_file_spec.js b/spec/javascripts/ide/components/repo_loading_file_spec.js
new file mode 100644
index 00000000000..8f9644216bc
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_loading_file_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoLoadingFile from '~/ide/components/repo_loading_file.vue';
+import { resetStore } from '../helpers';
+
+describe('RepoLoadingFile', () => {
+ let vm;
+
+ function createComponent() {
+ const RepoLoadingFile = Vue.extend(repoLoadingFile);
+
+ return new RepoLoadingFile({
+ store,
+ }).$mount();
+ }
+
+ function assertLines(lines) {
+ lines.forEach((line, n) => {
+ const index = n + 1;
+ expect(line.classList.contains(`skeleton-line-${index}`)).toBeTruthy();
+ });
+ }
+
+ function assertColumns(columns) {
+ columns.forEach(column => {
+ const container = column.querySelector('.animation-container');
+ const lines = [...container.querySelectorAll(':scope > div')];
+
+ expect(container).toBeTruthy();
+ expect(lines.length).toEqual(6);
+ assertLines(lines);
+ });
+ }
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders 3 columns of animated LoC', () => {
+ vm = createComponent();
+ const columns = [...vm.$el.querySelectorAll('td')];
+
+ expect(columns.length).toEqual(3);
+ assertColumns(columns);
+ });
+
+ it('renders 1 column of animated LoC if isMini', done => {
+ vm = createComponent();
+ vm.$store.state.leftPanelCollapsed = true;
+ vm.$store.state.openFiles.push('test');
+
+ vm.$nextTick(() => {
+ const columns = [...vm.$el.querySelectorAll('td')];
+
+ expect(columns.length).toEqual(1);
+ assertColumns(columns);
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js
new file mode 100644
index 00000000000..ddb5204e3a7
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_tab_spec.js
@@ -0,0 +1,165 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoTab from '~/ide/components/repo_tab.vue';
+import router from '~/ide/ide_router';
+import { file, resetStore } from '../helpers';
+
+describe('RepoTab', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const RepoTab = Vue.extend(repoTab);
+
+ return new RepoTab({
+ store,
+ propsData,
+ }).$mount();
+ }
+
+ beforeEach(() => {
+ spyOn(router, 'push');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a close link and a name link', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+ vm.$store.state.openFiles.push(vm.tab);
+ const close = vm.$el.querySelector('.multi-file-tab-close');
+ const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`);
+
+ expect(close.innerHTML).toContain('#close');
+ expect(name.textContent.trim()).toEqual(vm.tab.name);
+ });
+
+ it('fires clickFile when the link is clicked', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ spyOn(vm, 'clickFile');
+
+ vm.$el.click();
+
+ expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('calls closeFile when clicking close button', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ spyOn(vm, 'closeFile');
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path);
+ });
+
+ it('changes icon on hover', done => {
+ const tab = file();
+ tab.changed = true;
+ vm = createComponent({
+ tab,
+ });
+
+ vm.$el.dispatchEvent(new Event('mouseover'));
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.multi-file-modified')).toBeNull();
+
+ vm.$el.dispatchEvent(new Event('mouseout'));
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ describe('locked file', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file('locked file');
+ f.file_lock = {
+ user: {
+ name: 'testuser',
+ updated_at: new Date(),
+ },
+ };
+
+ vm = createComponent({
+ tab: f,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders lock icon', () => {
+ expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
+ });
+
+ it('renders a tooltip', () => {
+ expect(
+ vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle,
+ ).toContain('Locked by testuser');
+ });
+ });
+
+ describe('methods', () => {
+ describe('closeTab', () => {
+ it('closes tab if file has changed', done => {
+ const tab = file();
+ tab.changed = true;
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.changedFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+ expect(vm.$store.state.changedFiles.length).toBe(1);
+
+ done();
+ });
+ });
+
+ it('closes tab when clicking close btn', done => {
+ const tab = file('lose');
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js
new file mode 100644
index 00000000000..ceb0416aff8
--- /dev/null
+++ b/spec/javascripts/ide/components/repo_tabs_spec.js
@@ -0,0 +1,81 @@
+import Vue from 'vue';
+import repoTabs from '~/ide/components/repo_tabs.vue';
+import createComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('RepoTabs', () => {
+ const openedFiles = [file('open1'), file('open2')];
+ const RepoTabs = Vue.extend(repoTabs);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a list of tabs', done => {
+ vm = createComponent(RepoTabs, {
+ files: openedFiles,
+ viewer: 'editor',
+ hasChanges: false,
+ });
+ openedFiles[0].active = true;
+
+ vm.$nextTick(() => {
+ const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')];
+
+ expect(tabs.length).toEqual(2);
+ expect(tabs[0].classList.contains('active')).toEqual(true);
+ expect(tabs[1].classList.contains('active')).toEqual(false);
+
+ done();
+ });
+ });
+
+ describe('updated', () => {
+ it('sets showShadow as true when scroll width is larger than width', done => {
+ const el = document.createElement('div');
+ el.innerHTML = '<div id="test-app"></div>';
+ document.body.appendChild(el);
+
+ const style = document.createElement('style');
+ style.innerText = `
+ .multi-file-tabs {
+ width: 100px;
+ }
+
+ .multi-file-tabs .list-unstyled {
+ display: flex;
+ overflow-x: auto;
+ }
+ `;
+ document.head.appendChild(style);
+
+ vm = createComponent(
+ RepoTabs,
+ {
+ files: [],
+ viewer: 'editor',
+ hasChanges: false,
+ },
+ '#test-app',
+ );
+
+ vm
+ .$nextTick()
+ .then(() => {
+ expect(vm.showShadow).toEqual(false);
+
+ vm.files = openedFiles;
+ })
+ .then(vm.$nextTick)
+ .then(() => {
+ expect(vm.showShadow).toEqual(true);
+
+ style.remove();
+ el.remove();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js
new file mode 100644
index 00000000000..98db6defc7a
--- /dev/null
+++ b/spec/javascripts/ide/helpers.js
@@ -0,0 +1,22 @@
+import { decorateData } from '~/ide/stores/utils';
+import state from '~/ide/stores/state';
+import commitState from '~/ide/stores/modules/commit/state';
+
+export const resetStore = store => {
+ const newState = {
+ ...state(),
+ commit: commitState(),
+ };
+ store.replaceState(newState);
+};
+
+export const file = (name = 'name', id = name, type = '') =>
+ decorateData({
+ id,
+ type,
+ icon: 'icon',
+ url: 'url',
+ name,
+ path: name,
+ lastCommit: {},
+ });
diff --git a/spec/javascripts/ide/lib/common/disposable_spec.js b/spec/javascripts/ide/lib/common/disposable_spec.js
new file mode 100644
index 00000000000..af12ca15369
--- /dev/null
+++ b/spec/javascripts/ide/lib/common/disposable_spec.js
@@ -0,0 +1,44 @@
+import Disposable from '~/ide/lib/common/disposable';
+
+describe('Multi-file editor library disposable class', () => {
+ let instance;
+ let disposableClass;
+
+ beforeEach(() => {
+ instance = new Disposable();
+
+ disposableClass = {
+ dispose: jasmine.createSpy('dispose'),
+ };
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ });
+
+ describe('add', () => {
+ it('adds disposable classes', () => {
+ instance.add(disposableClass);
+
+ expect(instance.disposers.size).toBe(1);
+ });
+ });
+
+ describe('dispose', () => {
+ beforeEach(() => {
+ instance.add(disposableClass);
+ });
+
+ it('calls dispose on all cached disposers', () => {
+ instance.dispose();
+
+ expect(disposableClass.dispose).toHaveBeenCalled();
+ });
+
+ it('clears cached disposers', () => {
+ instance.dispose();
+
+ expect(instance.disposers.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js
new file mode 100644
index 00000000000..4381f6fcfd0
--- /dev/null
+++ b/spec/javascripts/ide/lib/common/model_manager_spec.js
@@ -0,0 +1,129 @@
+/* global monaco */
+import eventHub from '~/ide/eventhub';
+import monacoLoader from '~/ide/monaco_loader';
+import ModelManager from '~/ide/lib/common/model_manager';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model manager', () => {
+ let instance;
+
+ beforeEach(done => {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ instance = new ModelManager(monaco);
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ });
+
+ describe('addModel', () => {
+ it('caches model', () => {
+ instance.addModel(file());
+
+ expect(instance.models.size).toBe(1);
+ });
+
+ it('caches model by file path', () => {
+ instance.addModel(file('path-name'));
+
+ expect(instance.models.keys().next().value).toBe('path-name');
+ });
+
+ it('adds model into disposable', () => {
+ spyOn(instance.disposable, 'add').and.callThrough();
+
+ instance.addModel(file());
+
+ expect(instance.disposable.add).toHaveBeenCalled();
+ });
+
+ it('returns cached model', () => {
+ spyOn(instance.models, 'get').and.callThrough();
+
+ instance.addModel(file());
+ instance.addModel(file());
+
+ expect(instance.models.get).toHaveBeenCalled();
+ });
+
+ it('adds eventHub listener', () => {
+ const f = file();
+ spyOn(eventHub, '$on').and.callThrough();
+
+ instance.addModel(f);
+
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.path}`,
+ jasmine.anything(),
+ );
+ });
+ });
+
+ describe('hasCachedModel', () => {
+ it('returns false when no models exist', () => {
+ expect(instance.hasCachedModel('path')).toBeFalsy();
+ });
+
+ it('returns true when model exists', () => {
+ instance.addModel(file('path-name'));
+
+ expect(instance.hasCachedModel('path-name')).toBeTruthy();
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns cached model', () => {
+ instance.addModel(file('path-name'));
+
+ expect(instance.getModel('path-name')).not.toBeNull();
+ });
+ });
+
+ describe('removeCachedModel', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file();
+
+ instance.addModel(f);
+ });
+
+ it('clears cached model', () => {
+ instance.removeCachedModel(f);
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ spyOn(eventHub, '$off').and.callThrough();
+
+ instance.removeCachedModel(f);
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.path}`,
+ jasmine.anything(),
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached models', () => {
+ instance.addModel(file());
+
+ instance.dispose();
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('calls disposable dispose', () => {
+ spyOn(instance.disposable, 'dispose').and.callThrough();
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js
new file mode 100644
index 00000000000..adc6a93c06b
--- /dev/null
+++ b/spec/javascripts/ide/lib/common/model_spec.js
@@ -0,0 +1,113 @@
+/* global monaco */
+import eventHub from '~/ide/eventhub';
+import monacoLoader from '~/ide/monaco_loader';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model', () => {
+ let model;
+
+ beforeEach(done => {
+ spyOn(eventHub, '$on').and.callThrough();
+
+ monacoLoader(['vs/editor/editor.main'], () => {
+ model = new Model(monaco, file('path'));
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ model.dispose();
+ });
+
+ it('creates original model & new model', () => {
+ expect(model.originalModel).not.toBeNull();
+ expect(model.model).not.toBeNull();
+ });
+
+ it('adds eventHub listener', () => {
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.path}`,
+ jasmine.anything(),
+ );
+ });
+
+ describe('path', () => {
+ it('returns file path', () => {
+ expect(model.path).toBe('path');
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns model', () => {
+ expect(model.getModel()).toBe(model.model);
+ });
+ });
+
+ describe('getOriginalModel', () => {
+ it('returns original model', () => {
+ expect(model.getOriginalModel()).toBe(model.originalModel);
+ });
+ });
+
+ describe('setValue', () => {
+ it('updates models value', () => {
+ model.setValue('testing 123');
+
+ expect(model.getModel().getValue()).toBe('testing 123');
+ });
+ });
+
+ describe('onChange', () => {
+ it('caches event by path', () => {
+ model.onChange(() => {});
+
+ expect(model.events.size).toBe(1);
+ expect(model.events.keys().next().value).toBe('path');
+ });
+
+ it('calls callback on change', done => {
+ const spy = jasmine.createSpy();
+ model.onChange(spy);
+
+ model.getModel().setValue('123');
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalledWith(model, jasmine.anything());
+ done();
+ });
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ spyOn(model.disposable, 'dispose').and.callThrough();
+
+ model.dispose();
+
+ expect(model.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('clears events', () => {
+ model.onChange(() => {});
+
+ expect(model.events.size).toBe(1);
+
+ model.dispose();
+
+ expect(model.events.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ spyOn(eventHub, '$off').and.callThrough();
+
+ model.dispose();
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.path}`,
+ jasmine.anything(),
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js
new file mode 100644
index 00000000000..092170d086a
--- /dev/null
+++ b/spec/javascripts/ide/lib/decorations/controller_spec.js
@@ -0,0 +1,139 @@
+/* global monaco */
+import monacoLoader from '~/ide/monaco_loader';
+import editor from '~/ide/lib/editor';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library decorations controller', () => {
+ let editorInstance;
+ let controller;
+ let model;
+
+ beforeEach(done => {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ editorInstance = editor.create(monaco);
+ editorInstance.createInstance(document.createElement('div'));
+
+ controller = new DecorationsController(editorInstance);
+ model = new Model(monaco, file('path'));
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ model.dispose();
+ editorInstance.dispose();
+ controller.dispose();
+ });
+
+ describe('getAllDecorationsForModel', () => {
+ it('returns empty array when no decorations exist for model', () => {
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations).toEqual([]);
+ });
+
+ it('returns decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [
+ { decoration: 'decorationValue' },
+ ]);
+
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
+ });
+ });
+
+ describe('addDecorations', () => {
+ it('caches decorations in a new map', () => {
+ controller.addDecorations(model, 'key', [
+ { decoration: 'decorationValue' },
+ ]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('does not create new cache model', () => {
+ controller.addDecorations(model, 'key', [
+ { decoration: 'decorationValue' },
+ ]);
+ controller.addDecorations(model, 'key', [
+ { decoration: 'decorationValue2' },
+ ]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [
+ { decoration: 'decorationValue' },
+ ]);
+
+ expect(controller.decorations.size).toBe(1);
+ expect(controller.decorations.keys().next().value).toBe('path');
+ });
+
+ it('calls decorate method', () => {
+ spyOn(controller, 'decorate');
+
+ controller.addDecorations(model, 'key', [
+ { decoration: 'decorationValue' },
+ ]);
+
+ expect(controller.decorate).toHaveBeenCalled();
+ });
+ });
+
+ describe('decorate', () => {
+ it('sets decorations on editor instance', () => {
+ spyOn(controller.editor.instance, 'deltaDecorations');
+
+ controller.decorate(model);
+
+ expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith(
+ [],
+ [],
+ );
+ });
+
+ it('caches decorations', () => {
+ spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.keys().next().value).toBe('path');
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached decorations', () => {
+ controller.addDecorations(model, 'key', [
+ { decoration: 'decorationValue' },
+ ]);
+
+ controller.dispose();
+
+ expect(controller.decorations.size).toBe(0);
+ });
+
+ it('clears cached editorDecorations', () => {
+ controller.addDecorations(model, 'key', [
+ { decoration: 'decorationValue' },
+ ]);
+
+ controller.dispose();
+
+ expect(controller.editorDecorations.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js
new file mode 100644
index 00000000000..c8f3e9f4830
--- /dev/null
+++ b/spec/javascripts/ide/lib/diff/controller_spec.js
@@ -0,0 +1,196 @@
+/* global monaco */
+import monacoLoader from '~/ide/monaco_loader';
+import editor from '~/ide/lib/editor';
+import ModelManager from '~/ide/lib/common/model_manager';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import DirtyDiffController, {
+ getDiffChangeType,
+ getDecorator,
+} from '~/ide/lib/diff/controller';
+import { computeDiff } from '~/ide/lib/diff/diff';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library dirty diff controller', () => {
+ let editorInstance;
+ let controller;
+ let modelManager;
+ let decorationsController;
+ let model;
+
+ beforeEach(done => {
+ monacoLoader(['vs/editor/editor.main'], () => {
+ editorInstance = editor.create(monaco);
+ editorInstance.createInstance(document.createElement('div'));
+
+ modelManager = new ModelManager(monaco);
+ decorationsController = new DecorationsController(editorInstance);
+
+ model = modelManager.addModel(file('path'));
+
+ controller = new DirtyDiffController(modelManager, decorationsController);
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ controller.dispose();
+ model.dispose();
+ decorationsController.dispose();
+ editorInstance.dispose();
+ });
+
+ describe('getDiffChangeType', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDiffChangeType(change)).toBe(type);
+ });
+ });
+ });
+
+ describe('getDecorator', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns with linesDecorationsClassName for ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDecorator(change).options.linesDecorationsClassName).toBe(
+ `dirty-diff dirty-diff-${type}`,
+ );
+ });
+
+ it('returns with line numbers', () => {
+ const change = {
+ lineNumber: 1,
+ endLineNumber: 2,
+ [type]: true,
+ };
+
+ const range = getDecorator(change).range;
+
+ expect(range.startLineNumber).toBe(1);
+ expect(range.endLineNumber).toBe(2);
+ expect(range.startColumn).toBe(1);
+ expect(range.endColumn).toBe(1);
+ });
+ });
+ });
+
+ describe('attachModel', () => {
+ it('adds change event callback', () => {
+ spyOn(model, 'onChange');
+
+ controller.attachModel(model);
+
+ expect(model.onChange).toHaveBeenCalled();
+ });
+
+ it('calls throttledComputeDiff on change', () => {
+ spyOn(controller, 'throttledComputeDiff');
+
+ controller.attachModel(model);
+
+ model.getModel().setValue('123');
+
+ expect(controller.throttledComputeDiff).toHaveBeenCalled();
+ });
+ });
+
+ describe('computeDiff', () => {
+ it('posts to worker', () => {
+ spyOn(controller.dirtyDiffWorker, 'postMessage');
+
+ controller.computeDiff(model);
+
+ expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
+ path: model.path,
+ originalContent: '',
+ newContent: '',
+ });
+ });
+ });
+
+ describe('reDecorate', () => {
+ it('calls decorations controller decorate', () => {
+ spyOn(controller.decorationsController, 'decorate');
+
+ controller.reDecorate(model);
+
+ expect(controller.decorationsController.decorate).toHaveBeenCalledWith(
+ model,
+ );
+ });
+ });
+
+ describe('decorate', () => {
+ it('adds decorations into decorations controller', () => {
+ spyOn(controller.decorationsController, 'addDecorations');
+
+ controller.decorate({ data: { changes: [], path: 'path' } });
+
+ expect(
+ controller.decorationsController.addDecorations,
+ ).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything());
+ });
+
+ it('adds decorations into editor', () => {
+ const spy = spyOn(
+ controller.decorationsController.editor.instance,
+ 'deltaDecorations',
+ );
+
+ controller.decorate({
+ data: { changes: computeDiff('123', '1234'), path: 'path' },
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ [],
+ [
+ {
+ range: new monaco.Range(1, 1, 1, 1),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
+ },
+ },
+ ],
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ spyOn(controller.disposable, 'dispose').and.callThrough();
+
+ controller.dispose();
+
+ expect(controller.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('terminates worker', () => {
+ spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough();
+
+ controller.dispose();
+
+ expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
+ });
+
+ it('removes worker event listener', () => {
+ spyOn(
+ controller.dirtyDiffWorker,
+ 'removeEventListener',
+ ).and.callThrough();
+
+ controller.dispose();
+
+ expect(
+ controller.dirtyDiffWorker.removeEventListener,
+ ).toHaveBeenCalledWith('message', jasmine.anything());
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/diff/diff_spec.js b/spec/javascripts/ide/lib/diff/diff_spec.js
new file mode 100644
index 00000000000..57f3ac3d365
--- /dev/null
+++ b/spec/javascripts/ide/lib/diff/diff_spec.js
@@ -0,0 +1,80 @@
+import { computeDiff } from '~/ide/lib/diff/diff';
+
+describe('Multi-file editor library diff calculator', () => {
+ describe('computeDiff', () => {
+ it('returns empty array if no changes', () => {
+ const diff = computeDiff('123', '123');
+
+ expect(diff).toEqual([]);
+ });
+
+ describe('modified', () => {
+ it('', () => {
+ const diff = computeDiff('123', '1234')[0];
+
+ expect(diff.added).toBeTruthy();
+ expect(diff.modified).toBeTruthy();
+ expect(diff.removed).toBeUndefined();
+ });
+
+ it('', () => {
+ const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0];
+
+ expect(diff.added).toBeTruthy();
+ expect(diff.modified).toBeTruthy();
+ expect(diff.removed).toBeUndefined();
+ expect(diff.lineNumber).toBe(2);
+ });
+ });
+
+ describe('added', () => {
+ it('', () => {
+ const diff = computeDiff('123', '123\n123')[0];
+
+ expect(diff.added).toBeTruthy();
+ expect(diff.modified).toBeUndefined();
+ expect(diff.removed).toBeUndefined();
+ });
+
+ it('', () => {
+ const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0];
+
+ expect(diff.added).toBeTruthy();
+ expect(diff.modified).toBeUndefined();
+ expect(diff.removed).toBeUndefined();
+ expect(diff.lineNumber).toBe(3);
+ });
+ });
+
+ describe('removed', () => {
+ it('', () => {
+ const diff = computeDiff('123', '')[0];
+
+ expect(diff.added).toBeUndefined();
+ expect(diff.modified).toBeUndefined();
+ expect(diff.removed).toBeTruthy();
+ });
+
+ it('', () => {
+ const diff = computeDiff('123\n123\n123', '123\n123')[0];
+
+ expect(diff.added).toBeUndefined();
+ expect(diff.modified).toBeTruthy();
+ expect(diff.removed).toBeTruthy();
+ expect(diff.lineNumber).toBe(2);
+ });
+ });
+
+ it('includes line number of change', () => {
+ const diff = computeDiff('123', '')[0];
+
+ expect(diff.lineNumber).toBe(1);
+ });
+
+ it('includes end line number of change', () => {
+ const diff = computeDiff('123', '')[0];
+
+ expect(diff.endLineNumber).toBe(1);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/lib/editor_options_spec.js b/spec/javascripts/ide/lib/editor_options_spec.js
new file mode 100644
index 00000000000..d149a883166
--- /dev/null
+++ b/spec/javascripts/ide/lib/editor_options_spec.js
@@ -0,0 +1,11 @@
+import editorOptions from '~/ide/lib/editor_options';
+
+describe('Multi-file editor library editor options', () => {
+ it('returns an array', () => {
+ expect(editorOptions).toEqual(jasmine.any(Array));
+ });
+
+ it('contains readOnly option', () => {
+ expect(editorOptions[0].readOnly).toBeDefined();
+ });
+});
diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js
new file mode 100644
index 00000000000..d6df35c90e8
--- /dev/null
+++ b/spec/javascripts/ide/lib/editor_spec.js
@@ -0,0 +1,197 @@
+/* global monaco */
+import monacoLoader from '~/ide/monaco_loader';
+import editor from '~/ide/lib/editor';
+import { file } from '../helpers';
+
+describe('Multi-file editor library', () => {
+ let instance;
+ let el;
+ let holder;
+
+ beforeEach(done => {
+ el = document.createElement('div');
+ holder = document.createElement('div');
+ el.appendChild(holder);
+
+ document.body.appendChild(el);
+
+ monacoLoader(['vs/editor/editor.main'], () => {
+ instance = editor.create(monaco);
+
+ done();
+ });
+ });
+
+ afterEach(() => {
+ instance.dispose();
+
+ el.remove();
+ });
+
+ it('creates instance of editor', () => {
+ expect(editor.editorInstance).not.toBeNull();
+ });
+
+ it('creates instance returns cached instance', () => {
+ expect(editor.create(monaco)).toEqual(instance);
+ });
+
+ describe('createInstance', () => {
+ it('creates editor instance', () => {
+ spyOn(instance.monaco.editor, 'create').and.callThrough();
+
+ instance.createInstance(holder);
+
+ expect(instance.monaco.editor.create).toHaveBeenCalled();
+ });
+
+ it('creates dirty diff controller', () => {
+ instance.createInstance(holder);
+
+ expect(instance.dirtyDiffController).not.toBeNull();
+ });
+
+ it('creates model manager', () => {
+ instance.createInstance(holder);
+
+ expect(instance.modelManager).not.toBeNull();
+ });
+ });
+
+ describe('createDiffInstance', () => {
+ it('creates editor instance', () => {
+ spyOn(instance.monaco.editor, 'createDiffEditor').and.callThrough();
+
+ instance.createDiffInstance(holder);
+
+ expect(instance.monaco.editor.createDiffEditor).toHaveBeenCalledWith(
+ holder,
+ {
+ model: null,
+ contextmenu: true,
+ minimap: {
+ enabled: false,
+ },
+ readOnly: true,
+ scrollBeyondLastLine: false,
+ },
+ );
+ });
+ });
+
+ describe('createModel', () => {
+ it('calls model manager addModel', () => {
+ spyOn(instance.modelManager, 'addModel');
+
+ instance.createModel('FILE');
+
+ expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE');
+ });
+ });
+
+ describe('attachModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createInstance(document.createElement('div'));
+
+ model = instance.createModel(file());
+ });
+
+ it('sets the current model on the instance', () => {
+ instance.attachModel(model);
+
+ expect(instance.currentModel).toBe(model);
+ });
+
+ it('attaches the model to the current instance', () => {
+ spyOn(instance.instance, 'setModel');
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
+ });
+
+ it('sets original & modified when diff editor', () => {
+ spyOn(instance.instance, 'getEditorType').and.returnValue(
+ 'vs.editor.IDiffEditor',
+ );
+ spyOn(instance.instance, 'setModel');
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+ });
+
+ it('attaches the model to the dirty diff controller', () => {
+ spyOn(instance.dirtyDiffController, 'attachModel');
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(
+ model,
+ );
+ });
+
+ it('re-decorates with the dirty diff controller', () => {
+ spyOn(instance.dirtyDiffController, 'reDecorate');
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(
+ model,
+ );
+ });
+ });
+
+ describe('clearEditor', () => {
+ it('resets the editor model', () => {
+ instance.createInstance(document.createElement('div'));
+
+ spyOn(instance.instance, 'setModel');
+
+ instance.clearEditor();
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposble dispose method', () => {
+ spyOn(instance.disposable, 'dispose').and.callThrough();
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('resets instance', () => {
+ instance.createInstance(document.createElement('div'));
+
+ expect(instance.instance).not.toBeNull();
+
+ instance.dispose();
+
+ expect(instance.instance).toBeNull();
+ });
+
+ it('does not dispose modelManager', () => {
+ spyOn(instance.modelManager, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.modelManager.dispose).not.toHaveBeenCalled();
+ });
+
+ it('does not dispose decorationsController', () => {
+ spyOn(instance.decorationsController, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.decorationsController.dispose).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/monaco_loader_spec.js b/spec/javascripts/ide/monaco_loader_spec.js
new file mode 100644
index 00000000000..7ab315aa8c8
--- /dev/null
+++ b/spec/javascripts/ide/monaco_loader_spec.js
@@ -0,0 +1,15 @@
+import monacoContext from 'monaco-editor/dev/vs/loader';
+import monacoLoader from '~/ide/monaco_loader';
+
+describe('MonacoLoader', () => {
+ it('calls require.config and exports require', () => {
+ expect(monacoContext.require.getConfig()).toEqual(
+ jasmine.objectContaining({
+ paths: {
+ vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase
+ },
+ }),
+ );
+ expect(monacoLoader).toBe(monacoContext.require);
+ });
+});
diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js
new file mode 100644
index 00000000000..5b7c8365641
--- /dev/null
+++ b/spec/javascripts/ide/stores/actions/file_spec.js
@@ -0,0 +1,421 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import router from '~/ide/ide_router';
+import eventHub from '~/ide/eventhub';
+import { file, resetStore } from '../../helpers';
+
+describe('Multi-file store file actions', () => {
+ beforeEach(() => {
+ spyOn(router, 'push');
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('closeFile', () => {
+ let localFile;
+
+ beforeEach(() => {
+ localFile = file('testFile');
+ localFile.active = true;
+ localFile.opened = true;
+ localFile.parentTreeUrl = 'parentTreeUrl';
+
+ store.state.openFiles.push(localFile);
+ store.state.entries[localFile.path] = localFile;
+ });
+
+ it('closes open files', done => {
+ store
+ .dispatch('closeFile', localFile.path)
+ .then(() => {
+ expect(localFile.opened).toBeFalsy();
+ expect(localFile.active).toBeFalsy();
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('closes file even if file has changes', done => {
+ store.state.changedFiles.push(localFile);
+
+ store
+ .dispatch('closeFile', localFile.path)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+ expect(store.state.changedFiles.length).toBe(1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('closes file & opens next available file', done => {
+ const f = {
+ ...file('newOpenFile'),
+ url: '/newOpenFile',
+ };
+
+ store.state.openFiles.push(f);
+ store.state.entries[f.path] = f;
+
+ store
+ .dispatch('closeFile', localFile.path)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(router.push).toHaveBeenCalledWith(`/project${f.url}`);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('setFileActive', () => {
+ let localFile;
+ let scrollToTabSpy;
+ let oldScrollToTab;
+
+ beforeEach(() => {
+ scrollToTabSpy = jasmine.createSpy('scrollToTab');
+ oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line
+ store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line
+
+ localFile = file('setThisActive');
+
+ store.state.entries[localFile.path] = localFile;
+ });
+
+ afterEach(() => {
+ store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line
+ });
+
+ it('calls scrollToTab', done => {
+ store
+ .dispatch('setFileActive', localFile.path)
+ .then(() => {
+ expect(scrollToTabSpy).toHaveBeenCalled();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the file active', done => {
+ store
+ .dispatch('setFileActive', localFile.path)
+ .then(() => {
+ expect(localFile.active).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns early if file is already active', done => {
+ localFile.active = true;
+
+ store
+ .dispatch('setFileActive', localFile.path)
+ .then(() => {
+ expect(scrollToTabSpy).not.toHaveBeenCalled();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets current active file to not active', done => {
+ const f = file('newActive');
+ store.state.entries[f.path] = f;
+ localFile.active = true;
+ store.state.openFiles.push(localFile);
+
+ store
+ .dispatch('setFileActive', f.path)
+ .then(() => {
+ expect(localFile.active).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('resets location.hash for line highlighting', done => {
+ location.hash = 'test';
+
+ store
+ .dispatch('setFileActive', localFile.path)
+ .then(() => {
+ expect(location.hash).not.toBe('test');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('getFileData', () => {
+ let localFile;
+
+ beforeEach(() => {
+ spyOn(service, 'getFileData').and.returnValue(
+ Promise.resolve({
+ headers: {
+ 'page-title': 'testing getFileData',
+ },
+ json: () =>
+ Promise.resolve({
+ blame_path: 'blame_path',
+ commits_path: 'commits_path',
+ permalink: 'permalink',
+ raw_path: 'raw_path',
+ binary: false,
+ html: '123',
+ render_error: '',
+ }),
+ }),
+ );
+
+ localFile = file(`newCreate-${Math.random()}`);
+ localFile.url = 'getFileDataURL';
+ store.state.entries[localFile.path] = localFile;
+ });
+
+ it('calls the service', done => {
+ store
+ .dispatch('getFileData', localFile)
+ .then(() => {
+ expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the file data', done => {
+ store
+ .dispatch('getFileData', localFile)
+ .then(() => {
+ expect(localFile.blamePath).toBe('blame_path');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets document title', done => {
+ store
+ .dispatch('getFileData', localFile)
+ .then(() => {
+ expect(document.title).toBe('testing getFileData');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets the file as active', done => {
+ store
+ .dispatch('getFileData', localFile)
+ .then(() => {
+ expect(localFile.active).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds the file to open files', done => {
+ store
+ .dispatch('getFileData', localFile)
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].name).toBe(localFile.name);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('getRawFileData', () => {
+ let tmpFile;
+
+ beforeEach(() => {
+ spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw'));
+
+ tmpFile = file('tmpFile');
+ store.state.entries[tmpFile.path] = tmpFile;
+ });
+
+ it('calls getRawFileData service method', done => {
+ store
+ .dispatch('getRawFileData', tmpFile)
+ .then(() => {
+ expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('updates file raw data', done => {
+ store
+ .dispatch('getRawFileData', tmpFile)
+ .then(() => {
+ expect(tmpFile.raw).toBe('raw');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('changeFileContent', () => {
+ let tmpFile;
+
+ beforeEach(() => {
+ tmpFile = file('tmpFile');
+ store.state.entries[tmpFile.path] = tmpFile;
+ });
+
+ it('updates file content', done => {
+ store
+ .dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content',
+ })
+ .then(() => {
+ expect(tmpFile.content).toBe('content');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds file into changedFiles array', done => {
+ store
+ .dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content',
+ })
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds file once into changedFiles array', done => {
+ store
+ .dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content',
+ })
+ .then(() =>
+ store.dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content 123',
+ }),
+ )
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(1);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('removes file from changedFiles array if not changed', done => {
+ store
+ .dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: 'content',
+ })
+ .then(() =>
+ store.dispatch('changeFileContent', {
+ path: tmpFile.path,
+ content: '',
+ }),
+ )
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('discardFileChanges', () => {
+ let tmpFile;
+
+ beforeEach(() => {
+ spyOn(eventHub, '$on');
+
+ tmpFile = file();
+ tmpFile.content = 'testing';
+
+ store.state.changedFiles.push(tmpFile);
+ store.state.entries[tmpFile.path] = tmpFile;
+ });
+
+ it('resets file content', done => {
+ store
+ .dispatch('discardFileChanges', tmpFile.path)
+ .then(() => {
+ expect(tmpFile.content).not.toBe('testing');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('removes file from changedFiles array', done => {
+ store
+ .dispatch('discardFileChanges', tmpFile.path)
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('closes temp file', done => {
+ tmpFile.tempFile = true;
+ tmpFile.opened = true;
+
+ store
+ .dispatch('discardFileChanges', tmpFile.path)
+ .then(() => {
+ expect(tmpFile.opened).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('does not re-open a closed temp file', done => {
+ tmpFile.tempFile = true;
+
+ expect(tmpFile.opened).toBeFalsy();
+
+ store
+ .dispatch('discardFileChanges', tmpFile.path)
+ .then(() => {
+ expect(tmpFile.opened).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js
new file mode 100644
index 00000000000..381f038067b
--- /dev/null
+++ b/spec/javascripts/ide/stores/actions/tree_spec.js
@@ -0,0 +1,172 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import router from '~/ide/ide_router';
+import { file, resetStore } from '../../helpers';
+
+describe('Multi-file store tree actions', () => {
+ let projectTree;
+
+ const basicCallParameters = {
+ endpoint: 'rootEndpoint',
+ projectId: 'abcproject',
+ branch: 'master',
+ branchId: 'master',
+ };
+
+ beforeEach(() => {
+ spyOn(router, 'push');
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = {
+ web_url: '',
+ branches: {
+ master: {
+ workingReference: '1',
+ },
+ },
+ };
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('getFiles', () => {
+ beforeEach(() => {
+ spyOn(service, 'getFiles').and.returnValue(
+ Promise.resolve({
+ json: () =>
+ Promise.resolve([
+ 'file.txt',
+ 'folder/fileinfolder.js',
+ 'folder/subfolder/fileinsubfolder.js',
+ ]),
+ }),
+ );
+ });
+
+ it('calls service getFiles', done => {
+ store
+ .dispatch('getFiles', basicCallParameters)
+ .then(() => {
+ expect(service.getFiles).toHaveBeenCalledWith('', 'master');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds data into tree', done => {
+ store
+ .dispatch('getFiles', basicCallParameters)
+ .then(() => {
+ projectTree = store.state.trees['abcproject/master'];
+ expect(projectTree.tree.length).toBe(2);
+ expect(projectTree.tree[0].type).toBe('tree');
+ expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js');
+ expect(projectTree.tree[1].type).toBe('blob');
+ expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob');
+ expect(projectTree.tree[0].tree[0].tree[0].name).toBe(
+ 'fileinsubfolder.js',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('toggleTreeOpen', () => {
+ let tree;
+
+ beforeEach(() => {
+ tree = file('testing', '1', 'tree');
+ store.state.entries[tree.path] = tree;
+ });
+
+ it('toggles the tree open', done => {
+ store
+ .dispatch('toggleTreeOpen', tree.path)
+ .then(() => {
+ expect(tree.opened).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('getLastCommitData', () => {
+ beforeEach(() => {
+ spyOn(service, 'getTreeLastCommit').and.returnValue(
+ Promise.resolve({
+ headers: {
+ 'more-logs-url': null,
+ },
+ json: () =>
+ Promise.resolve([
+ {
+ type: 'tree',
+ file_name: 'testing',
+ commit: {
+ message: 'commit message',
+ authored_date: '123',
+ },
+ },
+ ]),
+ }),
+ );
+
+ store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+
+ projectTree = store.state.trees['abcproject/mybranch'];
+ projectTree.tree.push(file('testing', '1', 'tree'));
+ projectTree.lastCommitPath = 'lastcommitpath';
+ });
+
+ it('calls service with lastCommitPath', done => {
+ store
+ .dispatch('getLastCommitData', projectTree)
+ .then(() => {
+ expect(service.getTreeLastCommit).toHaveBeenCalledWith(
+ 'lastcommitpath',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('updates trees last commit data', done => {
+ store
+ .dispatch('getLastCommitData', projectTree)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(projectTree.tree[0].lastCommit.message).toBe('commit message');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('does not update entry if not found', done => {
+ projectTree.tree[0].name = 'a';
+
+ store
+ .dispatch('getLastCommitData', projectTree)
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(projectTree.tree[0].lastCommit.message).not.toBe(
+ 'commit message',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js
new file mode 100644
index 00000000000..cec572f4507
--- /dev/null
+++ b/spec/javascripts/ide/stores/actions_spec.js
@@ -0,0 +1,306 @@
+import * as urlUtils from '~/lib/utils/url_utility';
+import store from '~/ide/stores';
+import router from '~/ide/ide_router';
+import { resetStore, file } from '../helpers';
+
+describe('Multi-file store actions', () => {
+ beforeEach(() => {
+ spyOn(router, 'push');
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('redirectToUrl', () => {
+ it('calls visitUrl', done => {
+ spyOn(urlUtils, 'visitUrl');
+
+ store
+ .dispatch('redirectToUrl', 'test')
+ .then(() => {
+ expect(urlUtils.visitUrl).toHaveBeenCalledWith('test');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('setInitialData', () => {
+ it('commits initial data', done => {
+ store
+ .dispatch('setInitialData', { canCommit: true })
+ .then(() => {
+ expect(store.state.canCommit).toBeTruthy();
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('discardAllChanges', () => {
+ beforeEach(() => {
+ const f = file('discardAll');
+ f.changed = true;
+
+ store.state.openFiles.push(f);
+ store.state.changedFiles.push(f);
+ store.state.entries[f.path] = f;
+ });
+
+ it('discards changes in file', done => {
+ store
+ .dispatch('discardAllChanges')
+ .then(() => {
+ expect(store.state.openFiles.changed).toBeFalsy();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('removes all files from changedFiles state', done => {
+ store
+ .dispatch('discardAllChanges')
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(0);
+ expect(store.state.openFiles.length).toBe(1);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('closeAllFiles', () => {
+ beforeEach(() => {
+ const f = file('closeAll');
+ store.state.openFiles.push(f);
+ store.state.openFiles[0].opened = true;
+ store.state.entries[f.path] = f;
+ });
+
+ it('closes all open files', done => {
+ store
+ .dispatch('closeAllFiles')
+ .then(() => {
+ expect(store.state.openFiles.length).toBe(0);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('createTempEntry', () => {
+ beforeEach(() => {
+ document.body.innerHTML += '<div class="flash-container"></div>';
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'mybranch';
+
+ store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+ store.state.projects.abcproject = {
+ web_url: '',
+ };
+ });
+
+ afterEach(() => {
+ document.querySelector('.flash-container').remove();
+ });
+
+ describe('tree', () => {
+ it('creates temp tree', done => {
+ store
+ .dispatch('createTempEntry', {
+ branchId: store.state.currentBranchId,
+ name: 'test',
+ type: 'tree',
+ })
+ .then(() => {
+ const entry = store.state.entries.test;
+
+ expect(entry).not.toBeNull();
+ expect(entry.type).toBe('tree');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('creates new folder inside another tree', done => {
+ const tree = {
+ type: 'tree',
+ name: 'testing',
+ path: 'testing',
+ tree: [],
+ };
+
+ store.state.entries[tree.path] = tree;
+
+ store
+ .dispatch('createTempEntry', {
+ branchId: store.state.currentBranchId,
+ name: 'testing/test',
+ type: 'tree',
+ })
+ .then(() => {
+ expect(tree.tree[0].tempFile).toBeTruthy();
+ expect(tree.tree[0].name).toBe('test');
+ expect(tree.tree[0].type).toBe('tree');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('does not create new tree if already exists', done => {
+ const tree = {
+ type: 'tree',
+ path: 'testing',
+ tempFile: false,
+ tree: [],
+ };
+
+ store.state.entries[tree.path] = tree;
+
+ store
+ .dispatch('createTempEntry', {
+ branchId: store.state.currentBranchId,
+ name: 'testing',
+ type: 'tree',
+ })
+ .then(() => {
+ expect(store.state.entries[tree.path].tempFile).toEqual(false);
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('blob', () => {
+ it('creates temp file', done => {
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(f => {
+ expect(f.tempFile).toBeTruthy();
+ expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(
+ 1,
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds tmp file to open files', done => {
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(f => {
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].name).toBe(f.name);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds tmp file to changed files', done => {
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(f => {
+ expect(store.state.changedFiles.length).toBe(1);
+ expect(store.state.changedFiles[0].name).toBe(f.name);
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets tmp file as active', done => {
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(f => {
+ expect(f.active).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('creates flash message if file already exists', done => {
+ const f = file('test', '1', 'blob');
+ store.state.trees['abcproject/mybranch'].tree = [f];
+ store.state.entries[f.path] = f;
+
+ store
+ .dispatch('createTempEntry', {
+ name: 'test',
+ branchId: 'mybranch',
+ type: 'blob',
+ })
+ .then(() => {
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+ });
+
+ describe('popHistoryState', () => {});
+
+ describe('scrollToTab', () => {
+ it('focuses the current active element', done => {
+ document.body.innerHTML +=
+ '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>';
+ const el = document.querySelector('.repo-tab');
+ spyOn(el, 'focus');
+
+ store
+ .dispatch('scrollToTab')
+ .then(() => {
+ setTimeout(() => {
+ expect(el.focus).toHaveBeenCalled();
+
+ document.getElementById('tabs').remove();
+
+ done();
+ });
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateViewer', () => {
+ it('updates viewer state', done => {
+ store
+ .dispatch('updateViewer', 'diff')
+ .then(() => {
+ expect(store.state.viewer).toBe('diff');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js
new file mode 100644
index 00000000000..a613f3a21cc
--- /dev/null
+++ b/spec/javascripts/ide/stores/getters_spec.js
@@ -0,0 +1,55 @@
+import * as getters from '~/ide/stores/getters';
+import state from '~/ide/stores/state';
+import { file } from '../helpers';
+
+describe('Multi-file store getters', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ });
+
+ describe('activeFile', () => {
+ it('returns the current active file', () => {
+ localState.openFiles.push(file());
+ localState.openFiles.push(file('active'));
+ localState.openFiles[1].active = true;
+
+ expect(getters.activeFile(localState).name).toBe('active');
+ });
+
+ it('returns undefined if no active files are found', () => {
+ localState.openFiles.push(file());
+ localState.openFiles.push(file('active'));
+
+ expect(getters.activeFile(localState)).toBeNull();
+ });
+ });
+
+ describe('modifiedFiles', () => {
+ it('returns a list of modified files', () => {
+ localState.openFiles.push(file());
+ localState.changedFiles.push(file('changed'));
+ localState.changedFiles[0].changed = true;
+
+ const modifiedFiles = getters.modifiedFiles(localState);
+
+ expect(modifiedFiles.length).toBe(1);
+ expect(modifiedFiles[0].name).toBe('changed');
+ });
+ });
+
+ describe('addedFiles', () => {
+ it('returns a list of added files', () => {
+ localState.openFiles.push(file());
+ localState.changedFiles.push(file('added'));
+ localState.changedFiles[0].changed = true;
+ localState.changedFiles[0].tempFile = true;
+
+ const modifiedFiles = getters.addedFiles(localState);
+
+ expect(modifiedFiles.length).toBe(1);
+ expect(modifiedFiles[0].name).toBe('added');
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js
new file mode 100644
index 00000000000..90ded940227
--- /dev/null
+++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js
@@ -0,0 +1,505 @@
+import store from '~/ide/stores';
+import service from '~/ide/services';
+import router from '~/ide/ide_router';
+import * as urlUtils from '~/lib/utils/url_utility';
+import eventHub from '~/ide/eventhub';
+import * as consts from '~/ide/stores/modules/commit/constants';
+import { resetStore, file } from 'spec/ide/helpers';
+
+describe('IDE commit module actions', () => {
+ beforeEach(() => {
+ spyOn(router, 'push');
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ describe('updateCommitMessage', () => {
+ it('updates store with new commit message', done => {
+ store
+ .dispatch('commit/updateCommitMessage', 'testing')
+ .then(() => {
+ expect(store.state.commit.commitMessage).toBe('testing');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('discardDraft', () => {
+ it('resets commit message to blank', done => {
+ store.state.commit.commitMessage = 'testing';
+
+ store
+ .dispatch('commit/discardDraft')
+ .then(() => {
+ expect(store.state.commit.commitMessage).not.toBe('testing');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateCommitAction', () => {
+ it('updates store with new commit action', done => {
+ store
+ .dispatch('commit/updateCommitAction', '1')
+ .then(() => {
+ expect(store.state.commit.commitAction).toBe('1');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateBranchName', () => {
+ it('updates store with new branch name', done => {
+ store
+ .dispatch('commit/updateBranchName', 'branch-name')
+ .then(() => {
+ expect(store.state.commit.newBranchName).toBe('branch-name');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('setLastCommitMessage', () => {
+ beforeEach(() => {
+ Object.assign(store.state, {
+ currentProjectId: 'abcproject',
+ projects: {
+ abcproject: {
+ web_url: 'http://testing',
+ },
+ },
+ });
+ });
+
+ it('updates commit message with short_id', done => {
+ store
+ .dispatch('commit/setLastCommitMessage', { short_id: '123' })
+ .then(() => {
+ expect(store.state.lastCommitMsg).toContain(
+ 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a>',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('updates commit message with stats', done => {
+ store
+ .dispatch('commit/setLastCommitMessage', {
+ short_id: '123',
+ stats: {
+ additions: '1',
+ deletions: '2',
+ },
+ })
+ .then(() => {
+ expect(store.state.lastCommitMsg).toBe(
+ 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('checkCommitStatus', () => {
+ beforeEach(() => {
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = {
+ branches: {
+ master: {
+ workingReference: '1',
+ },
+ },
+ };
+ });
+
+ it('calls service', done => {
+ spyOn(service, 'getBranchData').and.returnValue(
+ Promise.resolve({
+ data: {
+ commit: { id: '123' },
+ },
+ }),
+ );
+
+ store
+ .dispatch('commit/checkCommitStatus')
+ .then(() => {
+ expect(service.getBranchData).toHaveBeenCalledWith(
+ 'abcproject',
+ 'master',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns true if current ref does not equal returned ID', done => {
+ spyOn(service, 'getBranchData').and.returnValue(
+ Promise.resolve({
+ data: {
+ commit: { id: '123' },
+ },
+ }),
+ );
+
+ store
+ .dispatch('commit/checkCommitStatus')
+ .then(val => {
+ expect(val).toBeTruthy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('returns false if current ref equals returned ID', done => {
+ spyOn(service, 'getBranchData').and.returnValue(
+ Promise.resolve({
+ data: {
+ commit: { id: '1' },
+ },
+ }),
+ );
+
+ store
+ .dispatch('commit/checkCommitStatus')
+ .then(val => {
+ expect(val).toBeFalsy();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('updateFilesAfterCommit', () => {
+ const data = {
+ id: '123',
+ message: 'testing commit message',
+ committed_date: '123',
+ committer_name: 'root',
+ };
+ const branch = 'master';
+ let f;
+
+ beforeEach(() => {
+ spyOn(eventHub, '$emit');
+
+ f = file('changedFile');
+ Object.assign(f, {
+ active: true,
+ changed: true,
+ content: 'file content',
+ });
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = {
+ web_url: 'web_url',
+ branches: {
+ master: {
+ workingReference: '',
+ },
+ },
+ };
+ store.state.changedFiles.push(f, {
+ ...file('changedFile2'),
+ changed: true,
+ });
+ store.state.openFiles = store.state.changedFiles;
+
+ store.state.changedFiles.forEach(changedFile => {
+ store.state.entries[changedFile.path] = changedFile;
+ });
+ });
+
+ it('updates stores working reference', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(
+ store.state.projects.abcproject.branches.master.workingReference,
+ ).toBe(data.id);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('resets all files changed status', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ store.state.openFiles.forEach(entry => {
+ expect(entry.changed).toBeFalsy();
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('removes all changed files', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(store.state.changedFiles.length).toBe(0);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('sets files commit data', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(f.lastCommit.message).toBe(data.message);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('updates raw content for changed file', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(f.raw).toBe(f.content);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('emits changed event for file', done => {
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ `editor.update.model.content.${f.path}`,
+ f.content,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('pushes route to new branch if commitAction is new branch', done => {
+ store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
+
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(router.push).toHaveBeenCalledWith(
+ `/project/abcproject/blob/master/${f.path}`,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('resets stores commit actions', done => {
+ store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
+
+ store
+ .dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ })
+ .then(() => {
+ expect(store.state.commit.commitAction).not.toBe(
+ consts.COMMIT_TO_NEW_BRANCH,
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('commitChanges', () => {
+ beforeEach(() => {
+ spyOn(urlUtils, 'visitUrl');
+
+ document.body.innerHTML += '<div class="flash-container"></div>';
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = {
+ web_url: 'webUrl',
+ branches: {
+ master: {
+ workingReference: '1',
+ },
+ },
+ };
+ store.state.changedFiles.push(file('changed'));
+ store.state.changedFiles[0].active = true;
+ store.state.openFiles = store.state.changedFiles;
+
+ store.state.openFiles.forEach(f => {
+ store.state.entries[f.path] = f;
+ });
+
+ store.state.commit.commitAction = '2';
+ store.state.commit.commitMessage = 'testing 123';
+ });
+
+ afterEach(() => {
+ document.querySelector('.flash-container').remove();
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ spyOn(service, 'commit').and.returnValue(
+ Promise.resolve({
+ data: {
+ id: '123456',
+ short_id: '123',
+ message: 'test message',
+ committed_date: 'date',
+ stats: {
+ additions: '1',
+ deletions: '2',
+ },
+ },
+ }),
+ );
+ });
+
+ it('calls service', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(service.commit).toHaveBeenCalledWith('abcproject', {
+ branch: jasmine.anything(),
+ commit_message: 'testing 123',
+ actions: [
+ {
+ action: 'update',
+ file_path: jasmine.anything(),
+ content: jasmine.anything(),
+ encoding: jasmine.anything(),
+ },
+ ],
+ start_branch: 'master',
+ });
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('pushes router to new route', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(router.push).toHaveBeenCalledWith(
+ `/project/${store.state.currentProjectId}/blob/${
+ store.getters['commit/newBranchName']
+ }/changed`,
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('sets last Commit Msg', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(store.state.lastCommitMsg).toBe(
+ 'Your changes have been committed. Commit <a href="webUrl/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('adds commit data to changed files', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(store.state.openFiles[0].lastCommit.message).toBe(
+ 'test message',
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('redirects to new merge request page', done => {
+ spyOn(eventHub, '$on');
+
+ store.state.commit.commitAction = '3';
+
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(urlUtils.visitUrl).toHaveBeenCalledWith(
+ `webUrl/merge_requests/new?merge_request[source_branch]=${
+ store.getters['commit/newBranchName']
+ }&merge_request[target_branch]=master`,
+ );
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+
+ describe('failed', () => {
+ beforeEach(() => {
+ spyOn(service, 'commit').and.returnValue(
+ Promise.resolve({
+ data: {
+ message: 'failed message',
+ },
+ }),
+ );
+ });
+
+ it('shows failed message', done => {
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ const alert = document.querySelector('.flash-container');
+
+ expect(alert.textContent.trim()).toBe('failed message');
+
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js
new file mode 100644
index 00000000000..e396284ec2c
--- /dev/null
+++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js
@@ -0,0 +1,128 @@
+import commitState from '~/ide/stores/modules/commit/state';
+import * as consts from '~/ide/stores/modules/commit/constants';
+import * as getters from '~/ide/stores/modules/commit/getters';
+
+describe('IDE commit module getters', () => {
+ let state;
+
+ beforeEach(() => {
+ state = commitState();
+ });
+
+ describe('discardDraftButtonDisabled', () => {
+ it('returns true when commitMessage is empty', () => {
+ expect(getters.discardDraftButtonDisabled(state)).toBeTruthy();
+ });
+
+ it('returns false when commitMessage is not empty & loading is false', () => {
+ state.commitMessage = 'test';
+ state.submitCommitLoading = false;
+
+ expect(getters.discardDraftButtonDisabled(state)).toBeFalsy();
+ });
+
+ it('returns true when commitMessage is not empty & loading is true', () => {
+ state.commitMessage = 'test';
+ state.submitCommitLoading = true;
+
+ expect(getters.discardDraftButtonDisabled(state)).toBeTruthy();
+ });
+ });
+
+ describe('commitButtonDisabled', () => {
+ const localGetters = {
+ discardDraftButtonDisabled: false,
+ };
+ const rootState = {
+ changedFiles: ['a'],
+ };
+
+ it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
+ expect(
+ getters.commitButtonDisabled(state, localGetters, rootState),
+ ).toBeFalsy();
+ });
+
+ it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => {
+ rootState.changedFiles.length = 0;
+
+ expect(
+ getters.commitButtonDisabled(state, localGetters, rootState),
+ ).toBeTruthy();
+ });
+
+ it('returns true when discardDraftButtonDisabled is true', () => {
+ localGetters.discardDraftButtonDisabled = true;
+
+ expect(
+ getters.commitButtonDisabled(state, localGetters, rootState),
+ ).toBeTruthy();
+ });
+
+ it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
+ localGetters.discardDraftButtonDisabled = false;
+ rootState.changedFiles.length = 0;
+
+ expect(
+ getters.commitButtonDisabled(state, localGetters, rootState),
+ ).toBeTruthy();
+ });
+ });
+
+ describe('newBranchName', () => {
+ it('includes username, currentBranchId, patch & random number', () => {
+ gon.current_username = 'username';
+
+ const branch = getters.newBranchName(state, null, {
+ currentBranchId: 'testing',
+ });
+
+ expect(branch).toMatch(/username-testing-patch-\d{5}$/);
+ });
+ });
+
+ describe('branchName', () => {
+ const rootState = {
+ currentBranchId: 'master',
+ };
+ const localGetters = {
+ newBranchName: 'newBranchName',
+ };
+
+ beforeEach(() => {
+ Object.assign(state, {
+ newBranchName: 'state-newBranchName',
+ });
+ });
+
+ it('defualts to currentBranchId', () => {
+ expect(getters.branchName(state, null, rootState)).toBe('master');
+ });
+
+ ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach(type => {
+ describe(type, () => {
+ beforeEach(() => {
+ Object.assign(state, {
+ commitAction: consts[type],
+ });
+ });
+
+ it('uses newBranchName when not empty', () => {
+ expect(getters.branchName(state, localGetters, rootState)).toBe(
+ 'state-newBranchName',
+ );
+ });
+
+ it('uses getters newBranchName when state newBranchName is empty', () => {
+ Object.assign(state, {
+ newBranchName: '',
+ });
+
+ expect(getters.branchName(state, localGetters, rootState)).toBe(
+ 'newBranchName',
+ );
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js
new file mode 100644
index 00000000000..5de7a281d34
--- /dev/null
+++ b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js
@@ -0,0 +1,42 @@
+import commitState from '~/ide/stores/modules/commit/state';
+import mutations from '~/ide/stores/modules/commit/mutations';
+
+describe('IDE commit module mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = commitState();
+ });
+
+ describe('UPDATE_COMMIT_MESSAGE', () => {
+ it('updates commitMessage', () => {
+ mutations.UPDATE_COMMIT_MESSAGE(state, 'testing');
+
+ expect(state.commitMessage).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_COMMIT_ACTION', () => {
+ it('updates commitAction', () => {
+ mutations.UPDATE_COMMIT_ACTION(state, 'testing');
+
+ expect(state.commitAction).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_NEW_BRANCH_NAME', () => {
+ it('updates newBranchName', () => {
+ mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing');
+
+ expect(state.newBranchName).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_LOADING', () => {
+ it('updates submitCommitLoading', () => {
+ mutations.UPDATE_LOADING(state, true);
+
+ expect(state.submitCommitLoading).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js
new file mode 100644
index 00000000000..a7167537ef2
--- /dev/null
+++ b/spec/javascripts/ide/stores/mutations/branch_spec.js
@@ -0,0 +1,18 @@
+import mutations from '~/ide/stores/mutations/branch';
+import state from '~/ide/stores/state';
+
+describe('Multi-file store branch mutations', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ });
+
+ describe('SET_CURRENT_BRANCH', () => {
+ it('sets currentBranch', () => {
+ mutations.SET_CURRENT_BRANCH(localState, 'master');
+
+ expect(localState.currentBranchId).toBe('master');
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js
new file mode 100644
index 00000000000..131380248e8
--- /dev/null
+++ b/spec/javascripts/ide/stores/mutations/file_spec.js
@@ -0,0 +1,157 @@
+import mutations from '~/ide/stores/mutations/file';
+import state from '~/ide/stores/state';
+import { file } from '../../helpers';
+
+describe('Multi-file store file mutations', () => {
+ let localState;
+ let localFile;
+
+ beforeEach(() => {
+ localState = state();
+ localFile = file();
+
+ localState.entries[localFile.path] = localFile;
+ });
+
+ describe('SET_FILE_ACTIVE', () => {
+ it('sets the file active', () => {
+ mutations.SET_FILE_ACTIVE(localState, {
+ path: localFile.path,
+ active: true,
+ });
+
+ expect(localFile.active).toBeTruthy();
+ });
+ });
+
+ describe('TOGGLE_FILE_OPEN', () => {
+ beforeEach(() => {
+ mutations.TOGGLE_FILE_OPEN(localState, localFile.path);
+ });
+
+ it('adds into opened files', () => {
+ expect(localFile.opened).toBeTruthy();
+ expect(localState.openFiles.length).toBe(1);
+ });
+
+ it('removes from opened files', () => {
+ mutations.TOGGLE_FILE_OPEN(localState, localFile.path);
+
+ expect(localFile.opened).toBeFalsy();
+ expect(localState.openFiles.length).toBe(0);
+ });
+ });
+
+ describe('SET_FILE_DATA', () => {
+ it('sets extra file data', () => {
+ mutations.SET_FILE_DATA(localState, {
+ data: {
+ blame_path: 'blame',
+ commits_path: 'commits',
+ permalink: 'permalink',
+ raw_path: 'raw',
+ binary: true,
+ render_error: 'render_error',
+ },
+ file: localFile,
+ });
+
+ expect(localFile.blamePath).toBe('blame');
+ expect(localFile.commitsPath).toBe('commits');
+ expect(localFile.permalink).toBe('permalink');
+ expect(localFile.rawPath).toBe('raw');
+ expect(localFile.binary).toBeTruthy();
+ expect(localFile.renderError).toBe('render_error');
+ });
+ });
+
+ describe('SET_FILE_RAW_DATA', () => {
+ it('sets raw data', () => {
+ mutations.SET_FILE_RAW_DATA(localState, {
+ file: localFile,
+ raw: 'testing',
+ });
+
+ expect(localFile.raw).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_FILE_CONTENT', () => {
+ beforeEach(() => {
+ localFile.raw = 'test';
+ });
+
+ it('sets content', () => {
+ mutations.UPDATE_FILE_CONTENT(localState, {
+ path: localFile.path,
+ content: 'test',
+ });
+
+ expect(localFile.content).toBe('test');
+ });
+
+ it('sets changed if content does not match raw', () => {
+ mutations.UPDATE_FILE_CONTENT(localState, {
+ path: localFile.path,
+ content: 'testing',
+ });
+
+ expect(localFile.content).toBe('testing');
+ expect(localFile.changed).toBeTruthy();
+ });
+
+ it('sets changed if file is a temp file', () => {
+ localFile.tempFile = true;
+
+ mutations.UPDATE_FILE_CONTENT(localState, {
+ path: localFile.path,
+ content: '',
+ });
+
+ expect(localFile.changed).toBeTruthy();
+ });
+ });
+
+ describe('DISCARD_FILE_CHANGES', () => {
+ beforeEach(() => {
+ localFile.content = 'test';
+ localFile.changed = true;
+ });
+
+ it('resets content and changed', () => {
+ mutations.DISCARD_FILE_CHANGES(localState, localFile.path);
+
+ expect(localFile.content).toBe('');
+ expect(localFile.changed).toBeFalsy();
+ });
+ });
+
+ describe('ADD_FILE_TO_CHANGED', () => {
+ it('adds file into changed files array', () => {
+ mutations.ADD_FILE_TO_CHANGED(localState, localFile.path);
+
+ expect(localState.changedFiles.length).toBe(1);
+ });
+ });
+
+ describe('REMOVE_FILE_FROM_CHANGED', () => {
+ it('removes files from changed files array', () => {
+ localState.changedFiles.push(localFile);
+
+ mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path);
+
+ expect(localState.changedFiles.length).toBe(0);
+ });
+ });
+
+ describe('TOGGLE_FILE_CHANGED', () => {
+ it('updates file changed status', () => {
+ mutations.TOGGLE_FILE_CHANGED(localState, {
+ file: localFile,
+ changed: true,
+ });
+
+ expect(localFile.changed).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/mutations/tree_spec.js b/spec/javascripts/ide/stores/mutations/tree_spec.js
new file mode 100644
index 00000000000..e6c085eaff6
--- /dev/null
+++ b/spec/javascripts/ide/stores/mutations/tree_spec.js
@@ -0,0 +1,69 @@
+import mutations from '~/ide/stores/mutations/tree';
+import state from '~/ide/stores/state';
+import { file } from '../../helpers';
+
+describe('Multi-file store tree mutations', () => {
+ let localState;
+ let localTree;
+
+ beforeEach(() => {
+ localState = state();
+ localTree = file();
+
+ localState.entries[localTree.path] = localTree;
+ });
+
+ describe('TOGGLE_TREE_OPEN', () => {
+ it('toggles tree open', () => {
+ mutations.TOGGLE_TREE_OPEN(localState, localTree.path);
+
+ expect(localTree.opened).toBeTruthy();
+
+ mutations.TOGGLE_TREE_OPEN(localState, localTree.path);
+
+ expect(localTree.opened).toBeFalsy();
+ });
+ });
+
+ describe('SET_DIRECTORY_DATA', () => {
+ const data = [
+ {
+ name: 'tree',
+ },
+ {
+ name: 'submodule',
+ },
+ {
+ name: 'blob',
+ },
+ ];
+
+ it('adds directory data', () => {
+ localState.trees['project/master'] = {
+ tree: [],
+ };
+
+ mutations.SET_DIRECTORY_DATA(localState, {
+ data,
+ treePath: 'project/master',
+ });
+
+ const tree = localState.trees['project/master'];
+
+ expect(tree.tree.length).toBe(3);
+ expect(tree.tree[0].name).toBe('tree');
+ expect(tree.tree[1].name).toBe('submodule');
+ expect(tree.tree[2].name).toBe('blob');
+ });
+ });
+
+ describe('REMOVE_ALL_CHANGES_FILES', () => {
+ it('removes all files from changedFiles state', () => {
+ localState.changedFiles.push(file('REMOVE_ALL_CHANGES_FILES'));
+
+ mutations.REMOVE_ALL_CHANGES_FILES(localState);
+
+ expect(localState.changedFiles.length).toBe(0);
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js
new file mode 100644
index 00000000000..38162a470ad
--- /dev/null
+++ b/spec/javascripts/ide/stores/mutations_spec.js
@@ -0,0 +1,79 @@
+import mutations from '~/ide/stores/mutations';
+import state from '~/ide/stores/state';
+import { file } from '../helpers';
+
+describe('Multi-file store mutations', () => {
+ let localState;
+ let entry;
+
+ beforeEach(() => {
+ localState = state();
+ entry = file();
+
+ localState.entries[entry.path] = entry;
+ });
+
+ describe('SET_INITIAL_DATA', () => {
+ it('sets all initial data', () => {
+ mutations.SET_INITIAL_DATA(localState, {
+ test: 'test',
+ });
+
+ expect(localState.test).toBe('test');
+ });
+ });
+
+ describe('TOGGLE_LOADING', () => {
+ it('toggles loading of entry', () => {
+ mutations.TOGGLE_LOADING(localState, { entry });
+
+ expect(entry.loading).toBeTruthy();
+
+ mutations.TOGGLE_LOADING(localState, { entry });
+
+ expect(entry.loading).toBeFalsy();
+ });
+
+ it('toggles loading of entry and sets specific value', () => {
+ mutations.TOGGLE_LOADING(localState, { entry });
+
+ expect(entry.loading).toBeTruthy();
+
+ mutations.TOGGLE_LOADING(localState, { entry, forceValue: true });
+
+ expect(entry.loading).toBeTruthy();
+ });
+ });
+
+ describe('SET_LEFT_PANEL_COLLAPSED', () => {
+ it('sets left panel collapsed', () => {
+ mutations.SET_LEFT_PANEL_COLLAPSED(localState, true);
+
+ expect(localState.leftPanelCollapsed).toBeTruthy();
+
+ mutations.SET_LEFT_PANEL_COLLAPSED(localState, false);
+
+ expect(localState.leftPanelCollapsed).toBeFalsy();
+ });
+ });
+
+ describe('SET_RIGHT_PANEL_COLLAPSED', () => {
+ it('sets right panel collapsed', () => {
+ mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true);
+
+ expect(localState.rightPanelCollapsed).toBeTruthy();
+
+ mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false);
+
+ expect(localState.rightPanelCollapsed).toBeFalsy();
+ });
+ });
+
+ describe('UPDATE_VIEWER', () => {
+ it('sets viewer state', () => {
+ mutations.UPDATE_VIEWER(localState, 'diff');
+
+ expect(localState.viewer).toBe('diff');
+ });
+ });
+});
diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js
new file mode 100644
index 00000000000..f38ac6dd82f
--- /dev/null
+++ b/spec/javascripts/ide/stores/utils_spec.js
@@ -0,0 +1,66 @@
+import * as utils from '~/ide/stores/utils';
+
+describe('Multi-file store utils', () => {
+ describe('setPageTitle', () => {
+ it('sets the document page title', () => {
+ utils.setPageTitle('test');
+
+ expect(document.title).toBe('test');
+ });
+ });
+
+ describe('findIndexOfFile', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = [
+ {
+ path: '1',
+ },
+ {
+ path: '2',
+ },
+ ];
+ });
+
+ it('finds in the index of an entry by path', () => {
+ const index = utils.findIndexOfFile(localState, {
+ path: '2',
+ });
+
+ expect(index).toBe(1);
+ });
+ });
+
+ describe('findEntry', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = {
+ tree: [
+ {
+ type: 'tree',
+ name: 'test',
+ },
+ {
+ type: 'blob',
+ name: 'file',
+ },
+ ],
+ };
+ });
+
+ it('returns an entry found by name', () => {
+ const foundEntry = utils.findEntry(localState.tree, 'tree', 'test');
+
+ expect(foundEntry.type).toBe('tree');
+ expect(foundEntry.name).toBe('test');
+ });
+
+ it('returns undefined when no entry found', () => {
+ const foundEntry = utils.findEntry(localState.tree, 'blob', 'test');
+
+ expect(foundEntry).toBeUndefined();
+ });
+ });
+});
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 584db6c6632..d5a87b5ce20 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -1,8 +1,7 @@
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
-import '~/render_math';
-import '~/render_gfm';
+import '~/behaviors/markdown/render_gfm';
import * as urlUtils from '~/lib/utils/url_utility';
import issuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
diff --git a/spec/javascripts/lib/utils/text_markdown_spec.js b/spec/javascripts/lib/utils/text_markdown_spec.js
index a95a7e2a5be..ca0e7c395a0 100644
--- a/spec/javascripts/lib/utils/text_markdown_spec.js
+++ b/spec/javascripts/lib/utils/text_markdown_spec.js
@@ -1,4 +1,4 @@
-import textUtils from '~/lib/utils/text_markdown';
+import { insertMarkdownText } from '~/lib/utils/text_markdown';
describe('init markdown', () => {
let textArea;
@@ -21,7 +21,7 @@ describe('init markdown', () => {
textArea.selectionStart = 0;
textArea.selectionEnd = 0;
- textUtils.insertText(textArea, textArea.value, '*', null, '', false);
+ insertMarkdownText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
@@ -32,7 +32,7 @@ describe('init markdown', () => {
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
- textUtils.insertText(textArea, textArea.value, '*', null, '', false);
+ insertMarkdownText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}\n* `);
});
@@ -43,7 +43,7 @@ describe('init markdown', () => {
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
- textUtils.insertText(textArea, textArea.value, '*', null, '', false);
+ insertMarkdownText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
@@ -54,7 +54,7 @@ describe('init markdown', () => {
textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length);
- textUtils.insertText(textArea, textArea.value, '*', null, '', false);
+ insertMarkdownText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `);
});
diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js
index eb644e698da..dc9dc4d4249 100644
--- a/spec/javascripts/merge_request_notes_spec.js
+++ b/spec/javascripts/merge_request_notes_spec.js
@@ -3,8 +3,7 @@ import _ from 'underscore';
import 'autosize';
import '~/gl_form';
import '~/lib/utils/text_utility';
-import '~/render_gfm';
-import '~/render_math';
+import '~/behaviors/markdown/render_gfm';
import Notes from '~/notes';
const upArrowKeyCode = 38;
diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js
index 29b355307ef..eba6dcf47c5 100644
--- a/spec/javascripts/monitoring/dashboard_spec.js
+++ b/spec/javascripts/monitoring/dashboard_spec.js
@@ -18,6 +18,7 @@ describe('Dashboard', () => {
deploymentEndpoint: null,
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg',
+ emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
};
diff --git a/spec/javascripts/monitoring/dashboard_state_spec.js b/spec/javascripts/monitoring/dashboard_state_spec.js
index df3198dd3e2..b4c5f4baa78 100644
--- a/spec/javascripts/monitoring/dashboard_state_spec.js
+++ b/spec/javascripts/monitoring/dashboard_state_spec.js
@@ -2,13 +2,22 @@ import Vue from 'vue';
import EmptyState from '~/monitoring/components/empty_state.vue';
import { statePaths } from './mock_data';
-const createComponent = (propsData) => {
+function createComponent(props) {
const Component = Vue.extend(EmptyState);
return new Component({
- propsData,
+ propsData: {
+ ...props,
+ settingsPath: statePaths.settingsPath,
+ clustersPath: statePaths.clustersPath,
+ documentationPath: statePaths.documentationPath,
+ emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
+ emptyLoadingSvgPath: '/path/to/loading.svg',
+ emptyNoDataSvgPath: '/path/to/no-data.svg',
+ emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
+ },
}).$mount();
-};
+}
function getTextFromNode(component, selector) {
return component.$el.querySelector(selector).firstChild.nodeValue.trim();
@@ -19,11 +28,6 @@ describe('EmptyState', () => {
it('currentState', () => {
const component = createComponent({
selectedState: 'gettingStarted',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.currentState).toBe(component.states.gettingStarted);
@@ -32,11 +36,6 @@ describe('EmptyState', () => {
it('showButtonDescription returns a description with a link for the unableToConnect state', () => {
const component = createComponent({
selectedState: 'unableToConnect',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.showButtonDescription).toEqual(true);
@@ -45,11 +44,6 @@ describe('EmptyState', () => {
it('showButtonDescription returns the description without a link for any other state', () => {
const component = createComponent({
selectedState: 'loading',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.showButtonDescription).toEqual(false);
@@ -59,12 +53,6 @@ describe('EmptyState', () => {
it('should show the gettingStarted state', () => {
const component = createComponent({
selectedState: 'gettingStarted',
- settingsPath: statePaths.settingsPath,
- clustersPath: statePaths.clustersPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.$el.querySelector('svg')).toBeDefined();
@@ -76,11 +64,6 @@ describe('EmptyState', () => {
it('should show the loading state', () => {
const component = createComponent({
selectedState: 'loading',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.$el.querySelector('svg')).toBeDefined();
@@ -92,11 +75,6 @@ describe('EmptyState', () => {
it('should show the unableToConnect state', () => {
const component = createComponent({
selectedState: 'unableToConnect',
- settingsPath: statePaths.settingsPath,
- documentationPath: statePaths.documentationPath,
- emptyGettingStartedSvgPath: 'foo',
- emptyLoadingSvgPath: 'foo',
- emptyUnableToConnectSvgPath: 'foo',
});
expect(component.$el.querySelector('svg')).toBeDefined();
diff --git a/spec/javascripts/notes/components/diff_file_header_spec.js b/spec/javascripts/notes/components/diff_file_header_spec.js
index aed30a087a6..ef6d513444a 100644
--- a/spec/javascripts/notes/components/diff_file_header_spec.js
+++ b/spec/javascripts/notes/components/diff_file_header_spec.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import DiffFileHeader from '~/notes/components/diff_file_header.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const discussionFixture = 'merge_requests/diff_discussion.json';
diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js
index 7f1f4bf0bcd..f4ec7132dbd 100644
--- a/spec/javascripts/notes/components/diff_with_note_spec.js
+++ b/spec/javascripts/notes/components/diff_with_note_spec.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import DiffWithNote from '~/notes/components/diff_with_note.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const discussionFixture = 'merge_requests/diff_discussion.json';
const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json';
diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js
index ac39418c3e6..0e792eee5e9 100644
--- a/spec/javascripts/notes/components/note_app_spec.js
+++ b/spec/javascripts/notes/components/note_app_spec.js
@@ -3,7 +3,7 @@ import _ from 'underscore';
import Vue from 'vue';
import notesApp from '~/notes/components/notes_app.vue';
import service from '~/notes/services/notes_service';
-import '~/render_gfm';
+import '~/behaviors/markdown/render_gfm';
import * as mockData from '../mock_data';
const vueMatchers = {
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index ba0a70bed17..8f317b06792 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -7,7 +7,7 @@ import * as urlUtils from '~/lib/utils/url_utility';
import 'autosize';
import '~/gl_form';
import '~/lib/utils/text_utility';
-import '~/render_gfm';
+import '~/behaviors/markdown/render_gfm';
import Notes from '~/notes';
import timeoutPromise from './helpers/set_timeout_promise_helper';
diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
index ba2e07f02f7..080158a8ee0 100644
--- a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
+++ b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue';
import eventHub from '~/pages/projects/labels/event_hub';
import axios from '~/lib/utils/axios_utils';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Promote label modal', () => {
let vm;
diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
index bf044fe8fb5..22956929e7b 100644
--- a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
+++ b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue';
import eventHub from '~/pages/milestones/shared/event_hub';
import axios from '~/lib/utils/axios_utils';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Promote milestone modal', () => {
let vm;
diff --git a/spec/javascripts/performance_bar/components/detailed_metric_spec.js b/spec/javascripts/performance_bar/components/detailed_metric_spec.js
new file mode 100644
index 00000000000..eee0210a2a9
--- /dev/null
+++ b/spec/javascripts/performance_bar/components/detailed_metric_spec.js
@@ -0,0 +1,88 @@
+import Vue from 'vue';
+import detailedMetric from '~/performance_bar/components/detailed_metric.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('detailedMetric', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when the current request has no details', () => {
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(detailedMetric), {
+ currentRequest: {},
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ details: 'details',
+ keys: ['feature', 'request'],
+ });
+ });
+
+ it('does not display details', () => {
+ expect(vm.$el.innerText).not.toContain('/');
+ });
+
+ it('does not display the modal', () => {
+ expect(vm.$el.querySelector('.performance-bar-modal')).toBeNull();
+ });
+
+ it('displays the metric name', () => {
+ expect(vm.$el.innerText).toContain('gitaly');
+ });
+ });
+
+ describe('when the current request has details', () => {
+ const requestDetails = [
+ { duration: '100', feature: 'find_commit', request: 'abcdef' },
+ { duration: '23', feature: 'rebase_in_progress', request: '' },
+ ];
+
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(detailedMetric), {
+ currentRequest: {
+ details: {
+ gitaly: {
+ duration: '123ms',
+ calls: '456',
+ details: requestDetails,
+ },
+ },
+ },
+ metric: 'gitaly',
+ header: 'Gitaly calls',
+ details: 'details',
+ keys: ['feature', 'request'],
+ });
+ });
+
+ it('diplays details', () => {
+ expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456');
+ });
+
+ it('adds a modal with a table of the details', () => {
+ vm.$el
+ .querySelectorAll('.performance-bar-modal td strong')
+ .forEach((duration, index) => {
+ expect(duration.innerText).toContain(requestDetails[index].duration);
+ });
+
+ vm.$el
+ .querySelectorAll('.performance-bar-modal td:nth-child(2)')
+ .forEach((feature, index) => {
+ expect(feature.innerText).toContain(requestDetails[index].feature);
+ });
+
+ vm.$el
+ .querySelectorAll('.performance-bar-modal td:nth-child(3)')
+ .forEach((request, index) => {
+ expect(request.innerText).toContain(requestDetails[index].request);
+ });
+ });
+
+ it('displays the metric name', () => {
+ expect(vm.$el.innerText).toContain('gitaly');
+ });
+ });
+});
diff --git a/spec/javascripts/performance_bar/components/performance_bar_app_spec.js b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js
new file mode 100644
index 00000000000..9ab9ab1c9f4
--- /dev/null
+++ b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js
@@ -0,0 +1,88 @@
+import Vue from 'vue';
+import axios from '~/lib/utils/axios_utils';
+import performanceBarApp from '~/performance_bar/components/performance_bar_app.vue';
+import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
+import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import MockAdapter from 'axios-mock-adapter';
+
+describe('performance bar', () => {
+ let mock;
+ let vm;
+
+ beforeEach(() => {
+ const store = new PerformanceBarStore();
+
+ mock = new MockAdapter(axios);
+
+ mock.onGet('/-/peek/results').reply(
+ 200,
+ {
+ data: {
+ gc: {
+ invokes: 0,
+ invoke_time: '0.00',
+ use_size: 0,
+ total_size: 0,
+ total_object: 0,
+ gc_time: '0.00',
+ },
+ host: { hostname: 'web-01' },
+ },
+ },
+ {},
+ );
+
+ vm = mountComponent(Vue.extend(performanceBarApp), {
+ store,
+ env: 'development',
+ requestId: '123',
+ peekUrl: '/-/peek/results',
+ profileUrl: '?lineprofiler=true',
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ mock.restore();
+ });
+
+ it('sets the class to match the environment', () => {
+ expect(vm.$el.getAttribute('class')).toContain('development');
+ });
+
+ describe('loadRequestDetails', () => {
+ beforeEach(() => {
+ spyOn(vm.store, 'addRequest').and.callThrough();
+ });
+
+ it('does nothing if the request cannot be tracked', () => {
+ spyOn(vm.store, 'canTrackRequest').and.callFake(() => false);
+
+ vm.loadRequestDetails('123', 'https://gitlab.com/');
+
+ expect(vm.store.addRequest).not.toHaveBeenCalled();
+ });
+
+ it('adds the request immediately', () => {
+ vm.loadRequestDetails('123', 'https://gitlab.com/');
+
+ expect(vm.store.addRequest).toHaveBeenCalledWith(
+ '123',
+ 'https://gitlab.com/',
+ );
+ });
+
+ it('makes an HTTP request for the request details', () => {
+ spyOn(PerformanceBarService, 'fetchRequestDetails').and.callThrough();
+
+ vm.loadRequestDetails('456', 'https://gitlab.com/');
+
+ expect(PerformanceBarService.fetchRequestDetails).toHaveBeenCalledWith(
+ '/-/peek/results',
+ '456',
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/performance_bar/components/request_selector_spec.js b/spec/javascripts/performance_bar/components/request_selector_spec.js
new file mode 100644
index 00000000000..6108a29f8c4
--- /dev/null
+++ b/spec/javascripts/performance_bar/components/request_selector_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import requestSelector from '~/performance_bar/components/request_selector.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('request selector', () => {
+ const requests = [
+ { id: '123', url: 'https://gitlab.com/' },
+ {
+ id: '456',
+ url: 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1',
+ },
+ {
+ id: '789',
+ url:
+ 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1.json?serializer=widget',
+ },
+ ];
+
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(requestSelector), {
+ requests,
+ currentRequest: requests[1],
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ function optionText(requestId) {
+ return vm.$el.querySelector(`[value='${requestId}']`).innerText.trim();
+ }
+
+ it('displays the last component of the path', () => {
+ expect(optionText(requests[2].id)).toEqual('1.json?serializer=widget');
+ });
+
+ it('keeps the last two components of the path when the last component is numeric', () => {
+ expect(optionText(requests[1].id)).toEqual('merge_requests/1');
+ });
+
+ it('ignores trailing slashes', () => {
+ expect(optionText(requests[0].id)).toEqual('gitlab.com');
+ });
+});
diff --git a/spec/javascripts/performance_bar/components/simple_metric_spec.js b/spec/javascripts/performance_bar/components/simple_metric_spec.js
new file mode 100644
index 00000000000..98b843e9711
--- /dev/null
+++ b/spec/javascripts/performance_bar/components/simple_metric_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import simpleMetric from '~/performance_bar/components/simple_metric.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+describe('simpleMetric', () => {
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when the current request has no details', () => {
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(simpleMetric), {
+ currentRequest: {},
+ metric: 'gitaly',
+ });
+ });
+
+ it('does not display details', () => {
+ expect(vm.$el.innerText).not.toContain('/');
+ });
+
+ it('displays the metric name', () => {
+ expect(vm.$el.innerText).toContain('gitaly');
+ });
+ });
+
+ describe('when the current request has details', () => {
+ beforeEach(() => {
+ vm = mountComponent(Vue.extend(simpleMetric), {
+ currentRequest: {
+ details: { gitaly: { duration: '123ms', calls: '456' } },
+ },
+ metric: 'gitaly',
+ });
+ });
+
+ it('diplays details', () => {
+ expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456');
+ });
+
+ it('displays the metric name', () => {
+ expect(vm.$el.innerText).toContain('gitaly');
+ });
+ });
+});
diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js
index faaf710cf6f..b0d714cbefb 100644
--- a/spec/javascripts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/shortcuts_issuable_spec.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import initCopyAsGFM from '~/behaviors/copy_as_gfm';
+import initCopyAsGFM from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/shortcuts_issuable';
initCopyAsGFM();
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js
index 4c67504b642..25684861724 100644
--- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js
@@ -1,16 +1,17 @@
import Vue from 'vue';
-import shaMismatchComponent from '~/vue_merge_request_widget/components/states/mr_widget_sha_mismatch';
+import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue';
-describe('MRWidgetSHAMismatch', () => {
+describe('ShaMismatch', () => {
describe('template', () => {
- const Component = Vue.extend(shaMismatchComponent);
+ const Component = Vue.extend(ShaMismatch);
const vm = new Component({
el: document.createElement('div'),
});
it('should have correct elements', () => {
expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy();
- expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed. Please reload the page and review the changes before merging');
+ expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed.');
+ expect(vm.$el.innerText).toContain('Please reload the page and review the changes before merging.');
});
});
});
diff --git a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js
index 818ef0af3c2..3e708f865c8 100644
--- a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js
+++ b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import toolbar from '~/vue_shared/components/markdown/toolbar.vue';
-import mountComponent from '../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('toolbar', () => {
let vm;
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js
index 8daaf018396..6fe95153204 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js
@@ -3,9 +3,9 @@ import Vue from 'vue';
import LabelsSelect from '~/labels_select';
import baseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue';
-import { mockConfig, mockLabels } from './mock_data';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { mockConfig, mockLabels } from './mock_data';
const createComponent = (config = mockConfig) => {
const Component = Vue.extend(baseComponent);
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
index ec63ac306d0..f25c70db125 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js
@@ -2,9 +2,9 @@ import Vue from 'vue';
import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue';
-import { mockConfig, mockLabels } from './mock_data';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { mockConfig, mockLabels } from './mock_data';
const componentConfig = Object.assign({}, mockConfig, {
fieldName: 'label_id[]',
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
index 5cb4bb6fea6..ce559fe0335 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js
@@ -2,9 +2,9 @@ import Vue from 'vue';
import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue';
-import { mockSuggestedColors } from './mock_data';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { mockSuggestedColors } from './mock_data';
const createComponent = (headerTitle) => {
const Component = Vue.extend(dropdownCreateLabelComponent);
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
index 0f4fa716f8a..debeab25bd6 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js
@@ -2,9 +2,9 @@ import Vue from 'vue';
import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue';
-import { mockConfig } from './mock_data';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { mockConfig } from './mock_data';
const createComponent = (
labelsWebUrl = mockConfig.labelsWebUrl,
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js
index 325fa47c957..cdf234bb0c4 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(dropdownHeaderComponent);
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js
index 703b87498c7..88733922a59 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js
@@ -2,9 +2,9 @@ import Vue from 'vue';
import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue';
-import { mockLabels } from './mock_data';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { mockLabels } from './mock_data';
const createComponent = (name = 'label_id[]', label = mockLabels[0]) => {
const Component = Vue.extend(dropdownHiddenInputComponent);
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js
index 69e11d966c2..57608d957e7 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(dropdownSearchInputComponent);
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js
index c3580933072..7c3d2711f65 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = (canEdit = true) => {
const Component = Vue.extend(dropdownTitleComponent);
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
index 93b42795bea..39040670a87 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js
@@ -2,9 +2,9 @@ import Vue from 'vue';
import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
-import { mockLabels } from './mock_data';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { mockLabels } from './mock_data';
const createComponent = (labels = mockLabels) => {
const Component = Vue.extend(dropdownValueCollapsedComponent);
diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
index 66e0957b431..4397b00acfa 100644
--- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
+++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js
@@ -2,9 +2,9 @@ import Vue from 'vue';
import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue';
-import { mockConfig, mockLabels } from './mock_data';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { mockConfig, mockLabels } from './mock_data';
const createComponent = (
labels = mockLabels,
diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb
index 448c6fb57dd..3a9371ed2e8 100644
--- a/spec/lib/gitlab/ci/trace_spec.rb
+++ b/spec/lib/gitlab/ci/trace_spec.rb
@@ -510,6 +510,28 @@ describe Gitlab::Ci::Trace do
it_behaves_like 'source trace in database stays intact', error: ActiveRecord::RecordInvalid
end
+
+ context 'when there is a validation error on Ci::Build' do
+ before do
+ allow_any_instance_of(Ci::Build).to receive(:save).and_return(false)
+ allow_any_instance_of(Ci::Build).to receive_message_chain(:errors, :full_messages)
+ .and_return(%w[Error Error])
+ end
+
+ context "when erase old trace with 'save'" do
+ before do
+ build.send(:write_attribute, :trace, nil)
+ build.save
+ end
+
+ it 'old trace is not deleted' do
+ build.reload
+ expect(build.trace.raw).to eq(trace_content)
+ end
+ end
+
+ it_behaves_like 'archive trace in database'
+ end
end
end
diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb
index 57905a74e92..8351b967133 100644
--- a/spec/lib/gitlab/project_search_results_spec.rb
+++ b/spec/lib/gitlab/project_search_results_spec.rb
@@ -83,19 +83,19 @@ describe Gitlab::ProjectSearchResults do
end
context 'when the matching filename contains a colon' do
- let(:search_result) { "\nmaster:testdata/project::function1.yaml\x001\x00---\n" }
+ let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" }
it 'returns a valid FoundBlob' do
expect(subject.filename).to eq('testdata/project::function1.yaml')
expect(subject.basename).to eq('testdata/project::function1')
expect(subject.ref).to eq('master')
expect(subject.startline).to eq(1)
- expect(subject.data).to eq('---')
+ expect(subject.data).to eq("---\n")
end
end
context 'when the matching content contains a number surrounded by colons' do
- let(:search_result) { "\nmaster:testdata/foo.txt\x001\x00blah:9:blah" }
+ let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" }
it 'returns a valid FoundBlob' do
expect(subject.filename).to eq('testdata/foo.txt')
@@ -106,6 +106,18 @@ describe Gitlab::ProjectSearchResults do
end
end
+ context 'when the search result ends with an empty line' do
+ let(:results) { project.repository.search_files_by_content('Role models', 'master') }
+
+ it 'returns a valid FoundBlob that ends with an empty line' do
+ expect(subject.filename).to eq('files/markdown/ruby-style-guide.md')
+ expect(subject.basename).to eq('files/markdown/ruby-style-guide')
+ expect(subject.ref).to eq('master')
+ expect(subject.startline).to eq(1)
+ expect(subject.data).to eq("# Prelude\n\n> Role models are important. <br/>\n> -- Officer Alex J. Murphy / RoboCop\n\n")
+ end
+ end
+
context 'when the search returns non-ASCII data' do
context 'with UTF-8' do
let(:results) { project.repository.search_files_by_content('файл', 'master') }
@@ -115,7 +127,7 @@ describe Gitlab::ProjectSearchResults do
expect(subject.basename).to eq('encoding/russian')
expect(subject.ref).to eq('master')
expect(subject.startline).to eq(1)
- expect(subject.data).to eq('Хороший файл')
+ expect(subject.data).to eq("Хороший файл\n")
end
end
@@ -139,7 +151,7 @@ describe Gitlab::ProjectSearchResults do
expect(subject.basename).to eq('encoding/iso8859')
expect(subject.ref).to eq('master')
expect(subject.startline).to eq(1)
- expect(subject.data).to eq("Äü\n\nfoo")
+ expect(subject.data).to eq("Äü\n\nfoo\n")
end
end
end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 9da3de7a828..30a352fd090 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -2101,6 +2101,35 @@ describe Ci::Build do
subject.drop!
end
+
+ context 'when retry service raises Gitlab::Access::AccessDeniedError exception' do
+ let(:retry_service) { Ci::RetryBuildService.new(subject.project, subject.user) }
+
+ before do
+ allow_any_instance_of(Ci::RetryBuildService)
+ .to receive(:execute)
+ .with(subject)
+ .and_raise(Gitlab::Access::AccessDeniedError)
+ allow(Rails.logger).to receive(:error)
+ end
+
+ it 'handles raised exception' do
+ expect { subject.drop! }.not_to raise_exception(Gitlab::Access::AccessDeniedError)
+ end
+
+ it 'logs the error' do
+ subject.drop!
+
+ expect(Rails.logger)
+ .to have_received(:error)
+ .with(a_string_matching("Unable to auto-retry job #{subject.id}"))
+ end
+
+ it 'fails the job' do
+ subject.drop!
+ expect(subject.failed?).to be_truthy
+ end
+ end
end
context 'when build is not configured to be retried' do
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index 9052a18c60b..f8d5258a8d9 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -99,10 +99,10 @@ describe API::Search do
end
end
- describe "GET /groups/:id/-/search" do
+ describe "GET /groups/:id/search" do
context 'when user is not authenticated' do
it 'returns 401 error' do
- get api("/groups/#{group.id}/-/search"), scope: 'projects', search: 'awesome'
+ get api("/groups/#{group.id}/search"), scope: 'projects', search: 'awesome'
expect(response).to have_gitlab_http_status(401)
end
@@ -110,7 +110,7 @@ describe API::Search do
context 'when scope is not supported' do
it 'returns 400 error' do
- get api("/groups/#{group.id}/-/search", user), scope: 'unsupported', search: 'awesome'
+ get api("/groups/#{group.id}/search", user), scope: 'unsupported', search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
@@ -118,7 +118,7 @@ describe API::Search do
context 'when scope is missing' do
it 'returns 400 error' do
- get api("/groups/#{group.id}/-/search", user), search: 'awesome'
+ get api("/groups/#{group.id}/search", user), search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
@@ -126,7 +126,7 @@ describe API::Search do
context 'when group does not exist' do
it 'returns 404 error' do
- get api('/groups/9999/-/search', user), scope: 'issues', search: 'awesome'
+ get api('/groups/9999/search', user), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(404)
end
@@ -136,7 +136,7 @@ describe API::Search do
it 'returns 404 error' do
private_group = create(:group, :private)
- get api("/groups/#{private_group.id}/-/search", user), scope: 'issues', search: 'awesome'
+ get api("/groups/#{private_group.id}/search", user), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(404)
end
@@ -145,7 +145,7 @@ describe API::Search do
context 'with correct params' do
context 'for projects scope' do
before do
- get api("/groups/#{group.id}/-/search", user), scope: 'projects', search: 'awesome'
+ get api("/groups/#{group.id}/search", user), scope: 'projects', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/projects'
@@ -155,7 +155,7 @@ describe API::Search do
before do
create(:issue, project: project, title: 'awesome issue')
- get api("/groups/#{group.id}/-/search", user), scope: 'issues', search: 'awesome'
+ get api("/groups/#{group.id}/search", user), scope: 'issues', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
@@ -165,7 +165,7 @@ describe API::Search do
before do
create(:merge_request, source_project: repo_project, title: 'awesome mr')
- get api("/groups/#{group.id}/-/search", user), scope: 'merge_requests', search: 'awesome'
+ get api("/groups/#{group.id}/search", user), scope: 'merge_requests', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
@@ -175,7 +175,7 @@ describe API::Search do
before do
create(:milestone, project: project, title: 'awesome milestone')
- get api("/groups/#{group.id}/-/search", user), scope: 'milestones', search: 'awesome'
+ get api("/groups/#{group.id}/search", user), scope: 'milestones', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
@@ -187,7 +187,7 @@ describe API::Search do
create(:milestone, project: project, title: 'awesome milestone')
create(:milestone, project: another_project, title: 'awesome milestone other project')
- get api("/groups/#{CGI.escape(group.full_path)}/-/search", user), scope: 'milestones', search: 'awesome'
+ get api("/groups/#{CGI.escape(group.full_path)}/search", user), scope: 'milestones', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
@@ -198,7 +198,7 @@ describe API::Search do
describe "GET /projects/:id/search" do
context 'when user is not authenticated' do
it 'returns 401 error' do
- get api("/projects/#{project.id}/-/search"), scope: 'issues', search: 'awesome'
+ get api("/projects/#{project.id}/search"), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(401)
end
@@ -206,7 +206,7 @@ describe API::Search do
context 'when scope is not supported' do
it 'returns 400 error' do
- get api("/projects/#{project.id}/-/search", user), scope: 'unsupported', search: 'awesome'
+ get api("/projects/#{project.id}/search", user), scope: 'unsupported', search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
@@ -214,7 +214,7 @@ describe API::Search do
context 'when scope is missing' do
it 'returns 400 error' do
- get api("/projects/#{project.id}/-/search", user), search: 'awesome'
+ get api("/projects/#{project.id}/search", user), search: 'awesome'
expect(response).to have_gitlab_http_status(400)
end
@@ -222,7 +222,7 @@ describe API::Search do
context 'when project does not exist' do
it 'returns 404 error' do
- get api('/projects/9999/-/search', user), scope: 'issues', search: 'awesome'
+ get api('/projects/9999/search', user), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(404)
end
@@ -232,7 +232,7 @@ describe API::Search do
it 'returns 404 error' do
project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome'
+ get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome'
expect(response).to have_gitlab_http_status(404)
end
@@ -243,7 +243,7 @@ describe API::Search do
before do
create(:issue, project: project, title: 'awesome issue')
- get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome'
+ get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/issues'
@@ -253,7 +253,7 @@ describe API::Search do
before do
create(:merge_request, source_project: repo_project, title: 'awesome mr')
- get api("/projects/#{repo_project.id}/-/search", user), scope: 'merge_requests', search: 'awesome'
+ get api("/projects/#{repo_project.id}/search", user), scope: 'merge_requests', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests'
@@ -263,7 +263,7 @@ describe API::Search do
before do
create(:milestone, project: project, title: 'awesome milestone')
- get api("/projects/#{project.id}/-/search", user), scope: 'milestones', search: 'awesome'
+ get api("/projects/#{project.id}/search", user), scope: 'milestones', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
@@ -273,7 +273,7 @@ describe API::Search do
before do
create(:note_on_merge_request, project: project, note: 'awesome note')
- get api("/projects/#{project.id}/-/search", user), scope: 'notes', search: 'awesome'
+ get api("/projects/#{project.id}/search", user), scope: 'notes', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/notes'
@@ -284,7 +284,7 @@ describe API::Search do
wiki = create(:project_wiki, project: project)
create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: "Awesome page" })
- get api("/projects/#{project.id}/-/search", user), scope: 'wiki_blobs', search: 'awesome'
+ get api("/projects/#{project.id}/search", user), scope: 'wiki_blobs', search: 'awesome'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/blobs'
@@ -292,7 +292,7 @@ describe API::Search do
context 'for commits scope' do
before do
- get api("/projects/#{repo_project.id}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6'
+ get api("/projects/#{repo_project.id}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details'
@@ -300,7 +300,7 @@ describe API::Search do
context 'for commits scope with project path as id' do
before do
- get api("/projects/#{CGI.escape(repo_project.full_path)}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6'
+ get api("/projects/#{CGI.escape(repo_project.full_path)}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details'
@@ -308,7 +308,7 @@ describe API::Search do
context 'for blobs scope' do
before do
- get api("/projects/#{repo_project.id}/-/search", user), scope: 'blobs', search: 'monitors'
+ get api("/projects/#{repo_project.id}/search", user), scope: 'blobs', search: 'monitors'
end
it_behaves_like 'response is correct', schema: 'public_api/v4/blobs', size: 2
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 62fdf870090..3943148f0db 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -34,6 +34,12 @@ describe NotificationService, :mailer do
should_not_email_anyone
end
+ it 'emails new mentions despite being unsubscribed' do
+ send_notifications(@unsubscribed_mentioned)
+
+ should_only_email(@unsubscribed_mentioned)
+ end
+
it 'sends the proper notification reason header' do
send_notifications(@u_watcher)
should_only_email(@u_watcher)
@@ -122,7 +128,7 @@ describe NotificationService, :mailer do
let(:project) { create(:project, :private) }
let(:issue) { create(:issue, project: project, assignees: [assignee]) }
let(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
- let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') }
+ let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') }
before do
build_team(note.project)
@@ -150,7 +156,7 @@ describe NotificationService, :mailer do
add_users_with_subscription(note.project, issue)
reset_delivered_emails!
- expect(SentNotification).to receive(:record).with(issue, any_args).exactly(9).times
+ expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times
notification.new_note(note)
@@ -163,6 +169,7 @@ describe NotificationService, :mailer do
should_email(@watcher_and_subscriber)
should_email(@subscribed_participant)
should_email(@u_custom_off)
+ should_email(@unsubscribed_mentioned)
should_not_email(@u_guest_custom)
should_not_email(@u_guest_watcher)
should_not_email(note.author)
@@ -279,6 +286,7 @@ describe NotificationService, :mailer do
before do
build_team(note.project)
note.project.add_master(note.author)
+ add_users_with_subscription(note.project, issue)
reset_delivered_emails!
end
@@ -286,6 +294,9 @@ describe NotificationService, :mailer do
it 'notifies the team members' do
notification.new_note(note)
+ # Make sure @unsubscribed_mentioned is part of the team
+ expect(note.project.team.members).to include(@unsubscribed_mentioned)
+
# Notify all team members
note.project.team.members.each do |member|
# User with disabled notification should not be notified
@@ -486,7 +497,7 @@ describe NotificationService, :mailer do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
let(:another_project) { create(:project, :public, namespace: group) }
- let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' }
+ let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' }
before do
build_team(issue.project)
@@ -510,6 +521,7 @@ describe NotificationService, :mailer do
should_email(@u_participant_mentioned)
should_email(@g_global_watcher)
should_email(@g_watcher)
+ should_email(@unsubscribed_mentioned)
should_not_email(@u_mentioned)
should_not_email(@u_participating)
should_not_email(@u_disabled)
@@ -1823,6 +1835,7 @@ describe NotificationService, :mailer do
def add_users_with_subscription(project, issuable)
@subscriber = create :user
@unsubscriber = create :user
+ @unsubscribed_mentioned = create :user, username: 'unsubscribed_mentioned'
@subscribed_participant = create_global_setting_for(create(:user, username: 'subscribed_participant'), :participating)
@watcher_and_subscriber = create_global_setting_for(create(:user), :watch)
@@ -1830,7 +1843,9 @@ describe NotificationService, :mailer do
project.add_master(@subscriber)
project.add_master(@unsubscriber)
project.add_master(@watcher_and_subscriber)
+ project.add_master(@unsubscribed_mentioned)
+ issuable.subscriptions.create(user: @unsubscribed_mentioned, project: project, subscribed: false)
issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true)
issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true)
issuable.subscriptions.create(user: @unsubscriber, project: project, subscribed: false)
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index a3893188c6e..e28b0ea5cf2 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -743,7 +743,7 @@ describe SystemNoteService do
expect(cross_reference(type)).to eq("Events for #{type.pluralize.humanize.downcase} are disabled.")
end
- it "blocks cross reference when #{type.underscore}_events is true" do
+ it "creates cross reference when #{type.underscore}_events is true" do
jira_tracker.update("#{type}_events" => true)
expect(cross_reference(type)).to eq(success_message)
diff --git a/spec/views/projects/services/_form.haml_spec.rb b/spec/views/projects/services/_form.haml_spec.rb
new file mode 100644
index 00000000000..85167bca115
--- /dev/null
+++ b/spec/views/projects/services/_form.haml_spec.rb
@@ -0,0 +1,46 @@
+require 'spec_helper'
+
+describe 'projects/services/_form' do
+ let(:project) { create(:redmine_project) }
+ let(:user) { create(:admin) }
+
+ before do
+ assign(:project, project)
+
+ allow(controller).to receive(:current_user).and_return(user)
+
+ allow(view).to receive_messages(current_user: user,
+ can?: true,
+ current_application_settings: Gitlab::CurrentSettings.current_application_settings)
+ end
+
+ context 'commit_events and merge_request_events' do
+ before do
+ assign(:service, project.redmine_service)
+ end
+
+ it 'display merge_request_events and commit_events descriptions' do
+ allow(RedmineService).to receive(:supported_events).and_return(%w(commit merge_request))
+
+ render
+
+ expect(rendered).to have_content('Event will be triggered when a commit is created/updated')
+ expect(rendered).to have_content('Event will be triggered when a merge request is created/updated/merged')
+ end
+
+ context 'when service is JIRA' do
+ let(:project) { create(:jira_project) }
+
+ before do
+ assign(:service, project.jira_service)
+ end
+
+ it 'display merge_request_events and commit_events descriptions' do
+ render
+
+ expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a commit.')
+ expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a merge request.')
+ end
+ end
+ end
+end
diff --git a/vendor/assets/javascripts/peek.js b/vendor/assets/javascripts/peek.js
deleted file mode 100644
index 7c6d226fa6a..00000000000
--- a/vendor/assets/javascripts/peek.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * this is a modified version of https://github.com/peek/peek/blob/master/app/assets/javascripts/peek.js
- *
- * - Removed the dependency on jquery.tipsy
- * - Removed the initializeTipsy and toggleBar functions
- * - Customized updatePerformanceBar to handle SQL query and Gitaly call lists
- * - Changed /peek/results to /-/peek/results
- * - Removed the keypress, pjax:end, page:change, and turbolinks:load handlers
- */
-(function($) {
- var fetchRequestResults, getRequestId, peekEnabled, updatePerformanceBar, createTable, createTableRow;
- getRequestId = function() {
- return $('#peek').data('requestId');
- };
- peekEnabled = function() {
- return $('#peek').length;
- };
- updatePerformanceBar = function(results) {
- Object.keys(results.data).forEach(function(key) {
- Object.keys(results.data[key]).forEach(function(label) {
- var data = results.data[key][label];
- var table = createTable(key, label, data);
- var target = $('[data-defer-to="' + key + '-' + label + '"]');
-
- if (table) {
- target.html(table);
- } else {
- target.text(data);
- }
- });
- });
- return $(document).trigger('peek:render', [getRequestId(), results]);
- };
- createTable = function(key, label, data) {
- if (label !== 'queries' && label !== 'details') {
- return;
- }
-
- var table = document.createElement('table');
-
- for (var i = 0; i < data.length; i += 1) {
- table.appendChild(createTableRow(data[i]));
- }
-
- table.className = 'table';
-
- return table;
- };
- createTableRow = function(row) {
- var tr = document.createElement('tr');
- var durationTd = document.createElement('td');
- var strong = document.createElement('strong');
-
- strong.append(row['duration'] + 'ms');
- durationTd.appendChild(strong);
- tr.appendChild(durationTd);
-
- ['sql', 'feature', 'enabled', 'request'].forEach(function(key) {
- if (!row[key]) { return; }
-
- var td = document.createElement('td');
-
- td.appendChild(document.createTextNode(row[key]));
- tr.appendChild(td);
- });
-
- return tr;
- };
- fetchRequestResults = function() {
- return $.ajax('/-/peek/results', {
- data: {
- request_id: getRequestId()
- },
- success: function(data, textStatus, xhr) {
- return updatePerformanceBar(data);
- },
- error: function(xhr, textStatus, error) {}
- });
- };
- $(document).on('peek:update', fetchRequestResults);
- return $(function() {
- if (peekEnabled()) {
- return $(this).trigger('peek:update');
- }
- });
-})(jQuery);