From e391fe1d6d39837d6aebb14f90e200fc6ff42d81 Mon Sep 17 00:00:00 2001 From: Pawel Chojnacki Date: Tue, 12 Dec 2017 15:11:34 +0100 Subject: Reduce cardinality of gitlab_cache_operation_duration_seconds histogram --- lib/gitlab/metrics/subscribers/rails_cache.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gitlab/metrics/subscribers/rails_cache.rb b/lib/gitlab/metrics/subscribers/rails_cache.rb index efd3c9daf79..250897a79c2 100644 --- a/lib/gitlab/metrics/subscribers/rails_cache.rb +++ b/lib/gitlab/metrics/subscribers/rails_cache.rb @@ -66,7 +66,7 @@ module Gitlab :gitlab_cache_operation_duration_seconds, 'Cache access time', Transaction::BASE_LABELS.merge({ action: nil }), - [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.500, 2.0, 10.0] + [0.001, 0.01, 0.1, 1, 10] ) end -- cgit v1.2.1 From b02db1f4931060d9e28bd0d651dbea34c79340e2 Mon Sep 17 00:00:00 2001 From: Pawel Chojnacki Date: Tue, 12 Dec 2017 16:54:11 +0100 Subject: Fix gitaly_call_histogram to observe times in seconds correctly --- lib/gitlab/gitaly_client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index b753ac46291..7aa2eafbb73 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -132,7 +132,7 @@ module Gitlab self.query_time += duration gitaly_call_histogram.observe( current_transaction_labels.merge(gitaly_service: service.to_s, rpc: rpc.to_s), - duration) + duration / 1000.0) end def self.current_transaction_labels -- cgit v1.2.1 From a8ebed6016726722a2283458e4176fc9177558af Mon Sep 17 00:00:00 2001 From: Pawel Chojnacki Date: Tue, 12 Dec 2017 18:12:49 +0100 Subject: Make `System.monotonic_time` retun seconds represented by float with microsecond precision --- lib/gitlab/gitaly_client.rb | 2 +- lib/gitlab/metrics/method_call.rb | 18 +++++++++++------- lib/gitlab/metrics/samplers/ruby_sampler.rb | 2 +- lib/gitlab/metrics/system.rb | 9 +++++---- lib/gitlab/metrics/transaction.rb | 8 ++++++-- spec/lib/gitlab/metrics/system_spec.rb | 4 ++-- 6 files changed, 26 insertions(+), 17 deletions(-) diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 7aa2eafbb73..b753ac46291 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -132,7 +132,7 @@ module Gitlab self.query_time += duration gitaly_call_histogram.observe( current_transaction_labels.merge(gitaly_service: service.to_s, rpc: rpc.to_s), - duration / 1000.0) + duration) end def self.current_transaction_labels diff --git a/lib/gitlab/metrics/method_call.rb b/lib/gitlab/metrics/method_call.rb index 65d55576ac2..6fb8f564237 100644 --- a/lib/gitlab/metrics/method_call.rb +++ b/lib/gitlab/metrics/method_call.rb @@ -4,7 +4,7 @@ module Gitlab class MethodCall MUTEX = Mutex.new BASE_LABELS = { module: nil, method: nil }.freeze - attr_reader :real_time, :cpu_time, :call_count, :labels + attr_reader :real_time_seconds, :cpu_time, :call_count, :labels def self.call_duration_histogram return @call_duration_histogram if @call_duration_histogram @@ -27,37 +27,41 @@ module Gitlab @transaction = transaction @name = name @labels = { module: @module_name, method: @method_name } - @real_time = 0 + @real_time_seconds = 0 @cpu_time = 0 @call_count = 0 end # Measures the real and CPU execution time of the supplied block. def measure - start_real = System.monotonic_time + start_real_seconds = System.monotonic_time start_cpu = System.cpu_time retval = yield - real_time = System.monotonic_time - start_real + real_time_seconds = System.monotonic_time - start_real_seconds cpu_time = System.cpu_time - start_cpu - @real_time += real_time + @real_time_seconds += real_time_seconds @cpu_time += cpu_time @call_count += 1 if call_measurement_enabled? && above_threshold? - self.class.call_duration_histogram.observe(@transaction.labels.merge(labels), real_time / 1000.0) + self.class.call_duration_histogram.observe(@transaction.labels.merge(labels), real_time_seconds) end retval end + def real_time_milliseconds + (real_time_seconds * 1000.0).to_i + end + # Returns a Metric instance of the current method call. def to_metric Metric.new( Instrumentation.series, { - duration: real_time, + duration: real_time_milliseconds, cpu_duration: cpu_time, call_count: call_count }, diff --git a/lib/gitlab/metrics/samplers/ruby_sampler.rb b/lib/gitlab/metrics/samplers/ruby_sampler.rb index b68800417a2..4e1ea62351f 100644 --- a/lib/gitlab/metrics/samplers/ruby_sampler.rb +++ b/lib/gitlab/metrics/samplers/ruby_sampler.rb @@ -52,7 +52,7 @@ module Gitlab metrics[:memory_usage].set(labels, System.memory_usage) metrics[:file_descriptors].set(labels, System.file_descriptor_count) - metrics[:sampler_duration].observe(labels.merge(worker_label), (System.monotonic_time - start_time) / 1000.0) + metrics[:sampler_duration].observe(labels.merge(worker_label), System.monotonic_time - start_time) ensure GC::Profiler.clear end diff --git a/lib/gitlab/metrics/system.rb b/lib/gitlab/metrics/system.rb index c2cbd3c16a1..b31cc6236d1 100644 --- a/lib/gitlab/metrics/system.rb +++ b/lib/gitlab/metrics/system.rb @@ -51,11 +51,12 @@ module Gitlab Process.clock_gettime(Process::CLOCK_REALTIME, precision) end - # Returns the current monotonic clock time in a given precision. + # Returns the current monotonic clock time as seconds with microseconds precision. # - # Returns the time as a Fixnum. - def self.monotonic_time(precision = :millisecond) - Process.clock_gettime(Process::CLOCK_MONOTONIC, precision) + # Returns the time as a Float. + def self.monotonic_time + + Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) end end end diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb index ee3afc5ffdb..16f0969ab74 100644 --- a/lib/gitlab/metrics/transaction.rb +++ b/lib/gitlab/metrics/transaction.rb @@ -35,6 +35,10 @@ module Gitlab @finished_at ? (@finished_at - @started_at) : 0.0 end + def duration_milliseconds + (duration * 1000).to_i + end + def allocated_memory @memory_after - @memory_before end @@ -50,7 +54,7 @@ module Gitlab @memory_after = System.memory_usage @finished_at = System.monotonic_time - self.class.metric_transaction_duration_seconds.observe(labels, duration * 1000) + self.class.metric_transaction_duration_seconds.observe(labels, duration) self.class.metric_transaction_allocated_memory_bytes.observe(labels, allocated_memory * 1024.0) Thread.current[THREAD_KEY] = nil @@ -97,7 +101,7 @@ module Gitlab end def track_self - values = { duration: duration, allocated_memory: allocated_memory } + values = { duration: duration_milliseconds, allocated_memory: allocated_memory } @values.each do |name, value| values[name] = value diff --git a/spec/lib/gitlab/metrics/system_spec.rb b/spec/lib/gitlab/metrics/system_spec.rb index 4d94d8705fb..ea3bd00970e 100644 --- a/spec/lib/gitlab/metrics/system_spec.rb +++ b/spec/lib/gitlab/metrics/system_spec.rb @@ -40,8 +40,8 @@ describe Gitlab::Metrics::System do end describe '.monotonic_time' do - it 'returns a Fixnum' do - expect(described_class.monotonic_time).to be_an(Integer) + it 'returns a Float' do + expect(described_class.monotonic_time).to be_an(Float) end end end -- cgit v1.2.1 From 8e7f19c60bea4eec86844be1e0db12ebf30f105e Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sat, 2 Dec 2017 22:55:49 -0800 Subject: Add button to run scheduled pipeline immediately Closes #38741 --- .../projects/pipeline_schedules_controller.rb | 16 +++++++++- app/helpers/gitlab_routing_helper.rb | 5 +++ .../_pipeline_schedule.html.haml | 4 ++- app/workers/run_pipeline_schedule_worker.rb | 20 ++++++++++++ .../sh-add-schedule-pipeline-run-now.yml | 5 +++ config/routes/project.rb | 1 + .../projects/pipeline_schedules_controller_spec.rb | 26 +++++++++++++++- spec/workers/run_pipeline_schedule_worker_spec.rb | 36 ++++++++++++++++++++++ 8 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 app/workers/run_pipeline_schedule_worker.rb create mode 100644 changelogs/unreleased/sh-add-schedule-pipeline-run-now.yml create mode 100644 spec/workers/run_pipeline_schedule_worker_spec.rb diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index ec7c645df5a..9fc7935950d 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -1,9 +1,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :schedule, except: [:index, :new, :create] + before_action :authorize_create_pipeline!, only: [:run] before_action :authorize_read_pipeline_schedule! before_action :authorize_create_pipeline_schedule!, only: [:new, :create] - before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create] + before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :run] before_action :authorize_admin_pipeline_schedule!, only: [:destroy] def index @@ -40,6 +41,19 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end end + def run + job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) + + flash[:notice] = + if job_id + 'Successfully scheduled pipeline to run immediately' + else + 'Unable to schedule a pipeline to run immediately' + end + + redirect_to pipeline_schedules_path(@project) + end + def take_ownership if schedule.update(owner: current_user) redirect_to pipeline_schedules_path(@project) diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index a77aa0ad2cc..5f72c6e64b6 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -182,6 +182,11 @@ module GitlabRoutingHelper edit_project_pipeline_schedule_path(project, schedule) end + def run_pipeline_schedule_path(schedule, *args) + project = schedule.project + run_project_pipeline_schedule_path(project, schedule, *args) + end + def take_ownership_pipeline_schedule_path(schedule, *args) project = schedule.project take_ownership_project_pipeline_schedule_path(project, schedule, *args) diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index bd8c38292d6..57f0ffba9ff 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -26,10 +26,12 @@ = pipeline_schedule.owner&.name %td .pull-right.btn-group + - if can?(current_user, :create_pipeline, @project) + = link_to run_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do + = icon('play') - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do = s_('PipelineSchedules|Take ownership') - - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn' do = icon('pencil') - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb new file mode 100644 index 00000000000..ac6371f0871 --- /dev/null +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -0,0 +1,20 @@ +class RunPipelineScheduleWorker + include Sidekiq::Worker + include PipelineQueue + + enqueue_in group: :creation + + def perform(schedule_id, user_id) + schedule = Ci::PipelineSchedule.find(schedule_id) + user = User.find(user_id) + + run_pipeline_schedule(schedule, user) + end + + def run_pipeline_schedule(schedule, user) + Ci::CreatePipelineService.new(schedule.project, + user, + ref: schedule.ref) + .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) + end +end diff --git a/changelogs/unreleased/sh-add-schedule-pipeline-run-now.yml b/changelogs/unreleased/sh-add-schedule-pipeline-run-now.yml new file mode 100644 index 00000000000..6d06f695f10 --- /dev/null +++ b/changelogs/unreleased/sh-add-schedule-pipeline-run-now.yml @@ -0,0 +1,5 @@ +--- +title: Add button to run scheduled pipeline immediately +merge_request: +author: +type: added diff --git a/config/routes/project.rb b/config/routes/project.rb index 093da10f57f..3dcb21e45c5 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -179,6 +179,7 @@ constraints(ProjectUrlConstrainer.new) do resources :pipeline_schedules, except: [:show] do member do + post :run post :take_ownership end end diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 4e52e261920..384a407a100 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -96,7 +96,7 @@ describe Projects::PipelineSchedulesController do end end - context 'when variables_attributes has two variables and duplicted' do + context 'when variables_attributes has two variables and duplicated' do let(:schedule) do basic_param.merge({ variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }] @@ -364,6 +364,30 @@ describe Projects::PipelineSchedulesController do end end + describe 'POST #run' do + set(:user) { create(:user) } + + context 'when a developer makes the request' do + before do + project.add_developer(user) + sign_in(user) + end + + it 'executes a new pipeline' do + expect(RunPipelineScheduleWorker).to receive(:perform_async).with(pipeline_schedule.id, user.id).and_return('job-123') + + go + + expect(flash[:notice]).to eq 'Successfully scheduled pipeline to run immediately' + expect(response).to have_gitlab_http_status(302) + end + end + + def go + post :run, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + end + end + describe 'DELETE #destroy' do set(:user) { create(:user) } diff --git a/spec/workers/run_pipeline_schedule_worker_spec.rb b/spec/workers/run_pipeline_schedule_worker_spec.rb new file mode 100644 index 00000000000..4a88ac51f62 --- /dev/null +++ b/spec/workers/run_pipeline_schedule_worker_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe RunPipelineScheduleWorker do + describe '#perform' do + let(:project) { create(:project) } + let(:worker) { described_class.new } + let(:user) { create(:user) } + let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project ) } + + context 'when a project not found' do + it 'does not call the Service' do + expect(Ci::CreatePipelineService).not_to receive(:new) + expect { worker.perform(100000, user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when a user not found' do + it 'does not call the Service' do + expect(Ci::CreatePipelineService).not_to receive(:new) + expect { worker.perform(pipeline_schedule.id, 10000) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when everything is ok' do + let(:project) { create(:project) } + let(:create_pipeline_service) { instance_double(Ci::CreatePipelineService) } + + it 'calls the Service' do + expect(Ci::CreatePipelineService).to receive(:new).with(project, user, ref: pipeline_schedule.ref).and_return(create_pipeline_service) + expect(create_pipeline_service).to receive(:execute).with(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: pipeline_schedule) + + worker.perform(pipeline_schedule.id, user.id) + end + end + end +end -- cgit v1.2.1 From f6966cfa63fab7e3c8847d69101c6c6a444fb85f Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 5 Dec 2017 22:24:20 -0800 Subject: Address some comments with running a pipeline schedule --- app/controllers/projects/pipeline_schedules_controller.rb | 6 +++--- app/helpers/gitlab_routing_helper.rb | 4 ++-- app/workers/run_pipeline_schedule_worker.rb | 6 ++++-- config/routes/project.rb | 2 +- .../projects/pipeline_schedules_controller_spec.rb | 4 ++-- spec/workers/run_pipeline_schedule_worker_spec.rb | 15 +++++++++------ 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 9fc7935950d..38edb38f9fc 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -1,10 +1,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :schedule, except: [:index, :new, :create] - before_action :authorize_create_pipeline!, only: [:run] + before_action :authorize_create_pipeline!, only: [:play] before_action :authorize_read_pipeline_schedule! before_action :authorize_create_pipeline_schedule!, only: [:new, :create] - before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :run] + before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play] before_action :authorize_admin_pipeline_schedule!, only: [:destroy] def index @@ -41,7 +41,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end end - def run + def play job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) flash[:notice] = diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 5f72c6e64b6..7f3c118c7ab 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -182,9 +182,9 @@ module GitlabRoutingHelper edit_project_pipeline_schedule_path(project, schedule) end - def run_pipeline_schedule_path(schedule, *args) + def play_pipeline_schedule_path(schedule, *args) project = schedule.project - run_project_pipeline_schedule_path(project, schedule, *args) + play_project_pipeline_schedule_path(project, schedule, *args) end def take_ownership_pipeline_schedule_path(schedule, *args) diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index ac6371f0871..3e0d3fed20a 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -5,8 +5,10 @@ class RunPipelineScheduleWorker enqueue_in group: :creation def perform(schedule_id, user_id) - schedule = Ci::PipelineSchedule.find(schedule_id) - user = User.find(user_id) + schedule = Ci::PipelineSchedule.find_by(id: schedule_id) + user = User.find_by(id: user_id) + + return unless schedule && user run_pipeline_schedule(schedule, user) end diff --git a/config/routes/project.rb b/config/routes/project.rb index 3dcb21e45c5..239b5480321 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -179,7 +179,7 @@ constraints(ProjectUrlConstrainer.new) do resources :pipeline_schedules, except: [:show] do member do - post :run + post :play post :take_ownership end end diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 384a407a100..e875f5bce08 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -364,7 +364,7 @@ describe Projects::PipelineSchedulesController do end end - describe 'POST #run' do + describe 'POST #play' do set(:user) { create(:user) } context 'when a developer makes the request' do @@ -384,7 +384,7 @@ describe Projects::PipelineSchedulesController do end def go - post :run, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + post :play, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id end end diff --git a/spec/workers/run_pipeline_schedule_worker_spec.rb b/spec/workers/run_pipeline_schedule_worker_spec.rb index 4a88ac51f62..481a84837f9 100644 --- a/spec/workers/run_pipeline_schedule_worker_spec.rb +++ b/spec/workers/run_pipeline_schedule_worker_spec.rb @@ -2,27 +2,30 @@ require 'spec_helper' describe RunPipelineScheduleWorker do describe '#perform' do - let(:project) { create(:project) } + set(:project) { create(:project) } + set(:user) { create(:user) } + set(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project ) } let(:worker) { described_class.new } - let(:user) { create(:user) } - let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project ) } context 'when a project not found' do it 'does not call the Service' do expect(Ci::CreatePipelineService).not_to receive(:new) - expect { worker.perform(100000, user.id) }.to raise_error(ActiveRecord::RecordNotFound) + expect(worker).not_to receive(:run_pipeline_schedule) + + worker.perform(100000, user.id) end end context 'when a user not found' do it 'does not call the Service' do expect(Ci::CreatePipelineService).not_to receive(:new) - expect { worker.perform(pipeline_schedule.id, 10000) }.to raise_error(ActiveRecord::RecordNotFound) + expect(worker).not_to receive(:run_pipeline_schedule) + + worker.perform(pipeline_schedule.id, 10000) end end context 'when everything is ok' do - let(:project) { create(:project) } let(:create_pipeline_service) { instance_double(Ci::CreatePipelineService) } it 'calls the Service' do -- cgit v1.2.1 From bc2d32aca0be46250bd02c9312d1064df024b621 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 5 Dec 2017 23:23:59 -0800 Subject: Create a play_pipeline_schedule policy and use it --- .../projects/pipeline_schedules_controller.rb | 6 +++++- app/policies/ci/pipeline_schedule_policy.rb | 18 ++++++++++++++++++ .../pipeline_schedules/_pipeline_schedule.html.haml | 2 +- .../projects/pipeline_schedules_controller_spec.rb | 21 ++++++++++++++++----- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 38edb38f9fc..a4e865cb9da 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -1,7 +1,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :schedule, except: [:index, :new, :create] - before_action :authorize_create_pipeline!, only: [:play] + before_action :authorize_play_pipeline_schedule!, only: [:play] before_action :authorize_read_pipeline_schedule! before_action :authorize_create_pipeline_schedule!, only: [:new, :create] before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play] @@ -84,6 +84,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController variables_attributes: [:id, :key, :value, :_destroy] ) end + def authorize_play_pipeline_schedule! + return access_denied! unless can?(current_user, :play_pipeline_schedule, schedule) + end + def authorize_update_pipeline_schedule! return access_denied! unless can?(current_user, :update_pipeline_schedule, schedule) end diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb index 6b7598e1821..8e7e129f135 100644 --- a/app/policies/ci/pipeline_schedule_policy.rb +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -2,13 +2,31 @@ module Ci class PipelineSchedulePolicy < PipelinePolicy alias_method :pipeline_schedule, :subject + condition(:protected_ref) do + access = ::Gitlab::UserAccess.new(@user, project: @subject.project) + + if @subject.project.repository.branch_exists?(@subject.ref) + access.can_update_branch?(@subject.ref) + elsif @subject.project.repository.tag_exists?(@subject.ref) + access.can_create_tag?(@subject.ref) + else + true + end + end + condition(:owner_of_schedule) do can?(:developer_access) && pipeline_schedule.owned_by?(@user) end + rule { can?(:developer_access) }.policy do + enable :play_pipeline_schedule + end + rule { can?(:master_access) | owner_of_schedule }.policy do enable :update_pipeline_schedule enable :admin_pipeline_schedule end + + rule { protected_ref }.prevent :play_pipeline_schedule end end diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 57f0ffba9ff..12d7e81c39e 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -26,7 +26,7 @@ = pipeline_schedule.owner&.name %td .pull-right.btn-group - - if can?(current_user, :create_pipeline, @project) + - if can?(current_user, :play_pipeline_schedule, pipeline_schedule) = link_to run_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do = icon('play') - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index e875f5bce08..3878b0a5e4f 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe Projects::PipelineSchedulesController do include AccessMatchersForController - set(:project) { create(:project, :public) } - let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } + set(:project) { create(:project, :public, :repository) } + set(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } describe 'GET #index' do let(:scope) { nil } @@ -366,25 +366,36 @@ describe Projects::PipelineSchedulesController do describe 'POST #play' do set(:user) { create(:user) } + let(:ref) { 'master' } context 'when a developer makes the request' do before do project.add_developer(user) + sign_in(user) end it 'executes a new pipeline' do expect(RunPipelineScheduleWorker).to receive(:perform_async).with(pipeline_schedule.id, user.id).and_return('job-123') - go + post :play, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id expect(flash[:notice]).to eq 'Successfully scheduled pipeline to run immediately' expect(response).to have_gitlab_http_status(302) end end - def go - post :play, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + context 'when a developer attempts to schedule a protected ref' do + it 'does not allow pipeline to be executed' do + create(:protected_branch, project: project, name: ref) + protected_schedule = create(:ci_pipeline_schedule, project: project, ref: ref) + + expect(RunPipelineScheduleWorker).not_to receive(:perform_async) + + post :play, namespace_id: project.namespace.to_param, project_id: project, id: protected_schedule.id + + expect(response).to have_gitlab_http_status(404) + end end end -- cgit v1.2.1 From 87118872c94ab465d400090e247b1e4ae90c2d82 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 5 Dec 2017 23:36:49 -0800 Subject: Fix conditions for checking pipeline schedule rules --- app/policies/ci/pipeline_schedule_policy.rb | 6 +++--- app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb index 8e7e129f135..c0a2bb963e9 100644 --- a/app/policies/ci/pipeline_schedule_policy.rb +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -6,11 +6,11 @@ module Ci access = ::Gitlab::UserAccess.new(@user, project: @subject.project) if @subject.project.repository.branch_exists?(@subject.ref) - access.can_update_branch?(@subject.ref) + !access.can_update_branch?(@subject.ref) elsif @subject.project.repository.tag_exists?(@subject.ref) - access.can_create_tag?(@subject.ref) + !access.can_create_tag?(@subject.ref) else - true + false end end diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 12d7e81c39e..f8c4005a9e0 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -27,7 +27,7 @@ %td .pull-right.btn-group - if can?(current_user, :play_pipeline_schedule, pipeline_schedule) - = link_to run_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do + = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn' do = icon('play') - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do -- cgit v1.2.1 From ad3732955389024b8a209455f9a889d5ebf411b9 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 7 Dec 2017 23:49:55 -0800 Subject: Refactor common protected ref check --- app/policies/ci/pipeline_policy.rb | 16 ++-- app/policies/ci/pipeline_schedule_policy.rb | 10 +-- spec/policies/ci/pipeline_schedule_policy_spec.rb | 92 +++++++++++++++++++++++ 3 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 spec/policies/ci/pipeline_schedule_policy_spec.rb diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 4e689a9efd5..6363c382ff8 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -2,16 +2,18 @@ module Ci class PipelinePolicy < BasePolicy delegate { @subject.project } - condition(:protected_ref) do - access = ::Gitlab::UserAccess.new(@user, project: @subject.project) + condition(:protected_ref) { ref_protected?(@user, @subject.project, @subject.tag?, @subject.ref) } - if @subject.tag? - !access.can_create_tag?(@subject.ref) + rule { protected_ref }.prevent :update_pipeline + + def ref_protected?(user, project, tag, ref) + access = ::Gitlab::UserAccess.new(user, project: project) + + if tag + !access.can_create_tag?(ref) else - !access.can_update_branch?(@subject.ref) + !access.can_update_branch?(ref) end end - - rule { protected_ref }.prevent :update_pipeline end end diff --git a/app/policies/ci/pipeline_schedule_policy.rb b/app/policies/ci/pipeline_schedule_policy.rb index c0a2bb963e9..abcf536b2f7 100644 --- a/app/policies/ci/pipeline_schedule_policy.rb +++ b/app/policies/ci/pipeline_schedule_policy.rb @@ -3,15 +3,7 @@ module Ci alias_method :pipeline_schedule, :subject condition(:protected_ref) do - access = ::Gitlab::UserAccess.new(@user, project: @subject.project) - - if @subject.project.repository.branch_exists?(@subject.ref) - !access.can_update_branch?(@subject.ref) - elsif @subject.project.repository.tag_exists?(@subject.ref) - !access.can_create_tag?(@subject.ref) - else - false - end + ref_protected?(@user, @subject.project, @subject.project.repository.tag_exists?(@subject.ref), @subject.ref) end condition(:owner_of_schedule) do diff --git a/spec/policies/ci/pipeline_schedule_policy_spec.rb b/spec/policies/ci/pipeline_schedule_policy_spec.rb new file mode 100644 index 00000000000..1b0e9fac355 --- /dev/null +++ b/spec/policies/ci/pipeline_schedule_policy_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +describe Ci::PipelineSchedulePolicy, :models do + set(:user) { create(:user) } + set(:project) { create(:project, :repository) } + set(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project) } + + let(:policy) do + described_class.new(user, pipeline_schedule) + end + + describe 'rules' do + describe 'rules for protected ref' do + before do + project.add_developer(user) + end + + context 'when no one can push or merge to the branch' do + before do + create(:protected_branch, :no_one_can_push, + name: pipeline_schedule.ref, project: project) + end + + it 'does not include ability to play pipeline schedule' do + expect(policy).to be_disallowed :play_pipeline_schedule + end + end + + context 'when developers can push to the branch' do + before do + create(:protected_branch, :developers_can_merge, + name: pipeline_schedule.ref, project: project) + end + + it 'includes ability to update pipeline' do + expect(policy).to be_allowed :play_pipeline_schedule + end + end + + context 'when no one can create the tag' do + let(:tag) { 'v1.0.0' } + + before do + pipeline_schedule.update(ref: tag) + + create(:protected_tag, :no_one_can_create, + name: pipeline_schedule.ref, project: project) + end + + it 'does not include ability to play pipeline schedule' do + expect(policy).to be_disallowed :play_pipeline_schedule + end + end + + context 'when no one can create the tag but it is not a tag' do + before do + create(:protected_tag, :no_one_can_create, + name: pipeline_schedule.ref, project: project) + end + + it 'includes ability to play pipeline schedule' do + expect(policy).to be_allowed :play_pipeline_schedule + end + end + end + + describe 'rules for owner of schedule' do + before do + project.add_developer(user) + pipeline_schedule.update(owner: user) + end + + it 'includes abilities to do do all operations on pipeline schedule' do + expect(policy).to be_allowed :play_pipeline_schedule + expect(policy).to be_allowed :update_pipeline_schedule + expect(policy).to be_allowed :admin_pipeline_schedule + end + end + + describe 'rules for a master' do + before do + project.add_master(user) + end + + it 'includes abilities to do do all operations on pipeline schedule' do + expect(policy).to be_allowed :play_pipeline_schedule + expect(policy).to be_allowed :update_pipeline_schedule + expect(policy).to be_allowed :admin_pipeline_schedule + end + end + end +end -- cgit v1.2.1 From 0ea70802e19cbe11c6af0f6750200bb137225940 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 7 Dec 2017 23:58:05 -0800 Subject: Fix Sidekiq worker and make flash message return a link to the pipelines page --- app/controllers/projects/pipeline_schedules_controller.rb | 2 +- app/workers/run_pipeline_schedule_worker.rb | 2 +- spec/controllers/projects/pipeline_schedules_controller_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index a4e865cb9da..fe77d8eabeb 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -46,7 +46,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController flash[:notice] = if job_id - 'Successfully scheduled pipeline to run immediately' + "Successfully scheduled a pipeline to run. Go to the Pipelines page for details".html_safe else 'Unable to schedule a pipeline to run immediately' end diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 3e0d3fed20a..7725ad319a3 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -1,5 +1,5 @@ class RunPipelineScheduleWorker - include Sidekiq::Worker + include ApplicationWorker include PipelineQueue enqueue_in group: :creation diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 3878b0a5e4f..debc20ee467 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -380,7 +380,7 @@ describe Projects::PipelineSchedulesController do post :play, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id - expect(flash[:notice]).to eq 'Successfully scheduled pipeline to run immediately' + expect(flash[:notice]).to start_with 'Successfully scheduled a pipeline to run' expect(response).to have_gitlab_http_status(302) end end -- cgit v1.2.1 From f8c3a58a54d622193a0cf15777a0d0631289278c Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 8 Dec 2017 22:20:28 -0800 Subject: Avoid Gitaly N+1 calls by caching tag_names --- app/models/repository.rb | 6 ++++++ spec/controllers/projects/pipeline_schedules_controller_spec.rb | 2 ++ spec/models/repository_spec.rb | 9 +++++++++ 3 files changed, 17 insertions(+) diff --git a/app/models/repository.rb b/app/models/repository.rb index c0e31eca8da..2413e60bc76 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -221,6 +221,12 @@ class Repository branch_names.include?(branch_name) end + def tag_exists?(tag_name) + return false unless raw_repository + + tag_names.include?(tag_name) + end + def ref_exists?(ref) !!raw_repository&.ref_exists?(ref) rescue ArgumentError diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index debc20ee467..8888573e882 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -7,6 +7,8 @@ describe Projects::PipelineSchedulesController do set(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } describe 'GET #index' do + render_views + let(:scope) { nil } let!(:inactive_pipeline_schedule) do create(:ci_pipeline_schedule, :inactive, project: project) diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 129fce74f45..08eb563082f 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1157,6 +1157,15 @@ describe Repository do end end + describe '#tag_exists?' do + it 'uses tag_names' do + allow(repository).to receive(:tag_names).and_return(['foobar']) + + expect(repository.tag_exists?('foobar')).to eq(true) + expect(repository.tag_exists?('master')).to eq(false) + end + end + describe '#branch_names', :use_clean_rails_memory_store_caching do let(:fake_branch_names) { ['foobar'] } -- cgit v1.2.1 From 54f13b1ec8542dc5085e0367734e8344c2c3d01e Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sat, 9 Dec 2017 01:01:42 -0800 Subject: Add rate limiting to guard against excessive scheduling of pipelines --- .../projects/pipeline_schedules_controller.rb | 11 ++++++++ lib/gitlab/action_rate_limiter.rb | 31 ++++++++++++++++++++++ .../projects/pipeline_schedules_controller_spec.rb | 2 +- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 lib/gitlab/action_rate_limiter.rb diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index fe77d8eabeb..b7a0a3591cd 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -42,6 +42,13 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end def play + limiter = ::Gitlab::ActionRateLimiter.new(action: 'play_pipeline_schedule') + + if limiter.throttled?(throttle_key, 1) + flash[:notice] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' + return redirect_to pipeline_schedules_path(@project) + end + job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) flash[:notice] = @@ -74,6 +81,10 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController private + def throttle_key + "user:#{current_user.id}:schedule:#{schedule.id}" + end + def schedule @schedule ||= project.pipeline_schedules.find(params[:id]) end diff --git a/lib/gitlab/action_rate_limiter.rb b/lib/gitlab/action_rate_limiter.rb new file mode 100644 index 00000000000..c3af583a3ed --- /dev/null +++ b/lib/gitlab/action_rate_limiter.rb @@ -0,0 +1,31 @@ +module Gitlab + # This class implements a simple rate limiter that can be used to throttle + # certain actions. Unlike Rack Attack and Rack::Throttle, which operate at + # the middleware level, this can be used at the controller level. + class ActionRateLimiter + TIME_TO_EXPIRE = 60 # 1 min + + attr_accessor :action, :expiry_time + + def initialize(action:, expiry_time: TIME_TO_EXPIRE) + @action = action + @expiry_time = expiry_time + end + + def increment(key) + value = 0 + + Gitlab::Redis::Cache.with do |redis| + cache_key = "action_rate_limiter:#{action}:#{key}" + value = redis.incr(cache_key) + redis.expire(cache_key, expiry_time) if value == 1 + end + + value.to_i + end + + def throttled?(key, threshold_value) + self.increment(key) > threshold_value + end + end +end diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 8888573e882..844c62ef005 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -366,7 +366,7 @@ describe Projects::PipelineSchedulesController do end end - describe 'POST #play' do + describe 'POST #play', :clean_gitlab_redis_cache do set(:user) { create(:user) } let(:ref) { 'master' } -- cgit v1.2.1 From ef78f67f4a2e19d204f5f4d4770649be1fe7bee9 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sat, 9 Dec 2017 15:08:27 -0800 Subject: Add a spec for rate limiting pipeline schedules --- app/controllers/projects/pipeline_schedules_controller.rb | 4 ++-- .../controllers/projects/pipeline_schedules_controller_spec.rb | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index b7a0a3591cd..87878667e9b 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -45,7 +45,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController limiter = ::Gitlab::ActionRateLimiter.new(action: 'play_pipeline_schedule') if limiter.throttled?(throttle_key, 1) - flash[:notice] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' + flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' return redirect_to pipeline_schedules_path(@project) end @@ -53,7 +53,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController flash[:notice] = if job_id - "Successfully scheduled a pipeline to run. Go to the Pipelines page for details".html_safe + "Successfully scheduled a pipeline to run. Go to the Pipelines page for details.".html_safe else 'Unable to schedule a pipeline to run immediately' end diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 844c62ef005..ffc1259eb8f 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -385,6 +385,16 @@ describe Projects::PipelineSchedulesController do expect(flash[:notice]).to start_with 'Successfully scheduled a pipeline to run' expect(response).to have_gitlab_http_status(302) end + + it 'prevents users from scheduling the same pipeline repeatedly' do + 2.times do + post :play, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + end + + expect(flash.to_a.size).to eq(2) + expect(flash[:alert]).to eq 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' + expect(response).to have_gitlab_http_status(302) + end end context 'when a developer attempts to schedule a protected ref' do -- cgit v1.2.1 From 8b939bfab769810ba56f5f4ca18632fd7ae435db Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sat, 9 Dec 2017 15:45:01 -0800 Subject: Add spec for ActionRateLimiter --- lib/gitlab/action_rate_limiter.rb | 4 ++-- spec/lib/gitlab/action_rate_limiter_spec.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 spec/lib/gitlab/action_rate_limiter_spec.rb diff --git a/lib/gitlab/action_rate_limiter.rb b/lib/gitlab/action_rate_limiter.rb index c3af583a3ed..7b231ac14ca 100644 --- a/lib/gitlab/action_rate_limiter.rb +++ b/lib/gitlab/action_rate_limiter.rb @@ -16,12 +16,12 @@ module Gitlab value = 0 Gitlab::Redis::Cache.with do |redis| - cache_key = "action_rate_limiter:#{action}:#{key}" + cache_key = "action_rate_limiter:#{action.to_s}:#{key}" value = redis.incr(cache_key) redis.expire(cache_key, expiry_time) if value == 1 end - value.to_i + value end def throttled?(key, threshold_value) diff --git a/spec/lib/gitlab/action_rate_limiter_spec.rb b/spec/lib/gitlab/action_rate_limiter_spec.rb new file mode 100644 index 00000000000..84661605623 --- /dev/null +++ b/spec/lib/gitlab/action_rate_limiter_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Gitlab::ActionRateLimiter do + let(:redis) { double('redis') } + let(:key) { 'user:1' } + let(:cache_key) { "action_rate_limiter:test_action:#{key}" } + + subject { described_class.new(action: :test_action, expiry_time: 100) } + + before do + allow(Gitlab::Redis::Cache).to receive(:with).and_yield(redis) + end + + it 'increases the throttle count and sets the expire time' do + expect(redis).to receive(:incr).with(cache_key).and_return(1) + expect(redis).to receive(:expire).with(cache_key, 100) + + expect(subject.throttled?(key, 1)).to be false + end + + it 'returns true if the key is throttled' do + expect(redis).to receive(:incr).with(cache_key).and_return(2) + expect(redis).not_to receive(:expire) + + expect(subject.throttled?(key, 1)).to be true + end +end -- cgit v1.2.1 From 02e7e499d4011733c1cf0f6fb675dd2d309f0d52 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sat, 9 Dec 2017 21:00:34 -0800 Subject: Fix Rubocop offense and use a symbol instead of a string --- app/controllers/projects/pipeline_schedules_controller.rb | 2 +- lib/gitlab/action_rate_limiter.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index 87878667e9b..dd6593650e7 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -42,7 +42,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end def play - limiter = ::Gitlab::ActionRateLimiter.new(action: 'play_pipeline_schedule') + limiter = ::Gitlab::ActionRateLimiter.new(action: :play_pipeline_schedule) if limiter.throttled?(throttle_key, 1) flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' diff --git a/lib/gitlab/action_rate_limiter.rb b/lib/gitlab/action_rate_limiter.rb index 7b231ac14ca..53add503c60 100644 --- a/lib/gitlab/action_rate_limiter.rb +++ b/lib/gitlab/action_rate_limiter.rb @@ -16,7 +16,7 @@ module Gitlab value = 0 Gitlab::Redis::Cache.with do |redis| - cache_key = "action_rate_limiter:#{action.to_s}:#{key}" + cache_key = "action_rate_limiter:#{action}:#{key}" value = redis.incr(cache_key) redis.expire(cache_key, expiry_time) if value == 1 end -- cgit v1.2.1 From 4b0465f20de1bf58326c7daf6876b63438f00d84 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 12 Dec 2017 15:46:05 -0800 Subject: Address review comments with playing pipeline scheduler --- .../projects/pipeline_schedules_controller.rb | 30 +++++++++++----------- lib/gitlab/action_rate_limiter.rb | 18 ++++++++++++- .../projects/pipeline_schedules_controller_spec.rb | 20 ++++++++++++--- spec/lib/gitlab/action_rate_limiter_spec.rb | 6 +++-- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index dd6593650e7..b478e7b5e05 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -1,6 +1,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :schedule, except: [:index, :new, :create] + before_action :play_rate_limit, only: [:play] before_action :authorize_play_pipeline_schedule!, only: [:play] before_action :authorize_read_pipeline_schedule! before_action :authorize_create_pipeline_schedule!, only: [:new, :create] @@ -42,21 +43,13 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController end def play - limiter = ::Gitlab::ActionRateLimiter.new(action: :play_pipeline_schedule) - - if limiter.throttled?(throttle_key, 1) - flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' - return redirect_to pipeline_schedules_path(@project) - end - job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) - flash[:notice] = - if job_id - "Successfully scheduled a pipeline to run. Go to the Pipelines page for details.".html_safe - else - 'Unable to schedule a pipeline to run immediately' - end + if job_id + flash[:notice] = "Successfully scheduled a pipeline to run. Go to the Pipelines page for details.".html_safe + else + flash[:alert] = 'Unable to schedule a pipeline to run immediately' + end redirect_to pipeline_schedules_path(@project) end @@ -81,8 +74,15 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController private - def throttle_key - "user:#{current_user.id}:schedule:#{schedule.id}" + def play_rate_limit + return unless current_user + + limiter = ::Gitlab::ActionRateLimiter.new(action: :play_pipeline_schedule) + + return unless limiter.throttled?([current_user, schedule], 1) + + flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.' + redirect_to pipeline_schedules_path(@project) end def schedule diff --git a/lib/gitlab/action_rate_limiter.rb b/lib/gitlab/action_rate_limiter.rb index 53add503c60..4cd3bdefda3 100644 --- a/lib/gitlab/action_rate_limiter.rb +++ b/lib/gitlab/action_rate_limiter.rb @@ -12,11 +12,15 @@ module Gitlab @expiry_time = expiry_time end + # Increments the given cache key and increments the value by 1 with the + # given expiration time. Returns the incremented value. + # + # key - An array of ActiveRecord instances def increment(key) value = 0 Gitlab::Redis::Cache.with do |redis| - cache_key = "action_rate_limiter:#{action}:#{key}" + cache_key = action_key(key) value = redis.incr(cache_key) redis.expire(cache_key, expiry_time) if value == 1 end @@ -24,8 +28,20 @@ module Gitlab value end + # Increments the given key and returns true if the action should + # be throttled. + # + # key - An array of ActiveRecord instances + # threshold_value - The maximum number of times this action should occur in the given time interval def throttled?(key, threshold_value) self.increment(key) > threshold_value end + + private + + def action_key(key) + serialized = key.map { |obj| "#{obj.class.model_name.to_s.underscore}:#{obj.id}" }.join(":") + "action_rate_limiter:#{action}:#{serialized}" + end end end diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index ffc1259eb8f..966ffdf6996 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -370,13 +370,27 @@ describe Projects::PipelineSchedulesController do set(:user) { create(:user) } let(:ref) { 'master' } - context 'when a developer makes the request' do + before do + project.add_developer(user) + + sign_in(user) + end + + context 'when an anonymous user makes the request' do before do - project.add_developer(user) + sign_out(user) + end - sign_in(user) + it 'does not allow pipeline to be executed' do + expect(RunPipelineScheduleWorker).not_to receive(:perform_async) + + post :play, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + + expect(response).to have_gitlab_http_status(404) end + end + context 'when a developer makes the request' do it 'executes a new pipeline' do expect(RunPipelineScheduleWorker).to receive(:perform_async).with(pipeline_schedule.id, user.id).and_return('job-123') diff --git a/spec/lib/gitlab/action_rate_limiter_spec.rb b/spec/lib/gitlab/action_rate_limiter_spec.rb index 84661605623..542fc03e555 100644 --- a/spec/lib/gitlab/action_rate_limiter_spec.rb +++ b/spec/lib/gitlab/action_rate_limiter_spec.rb @@ -2,8 +2,10 @@ require 'spec_helper' describe Gitlab::ActionRateLimiter do let(:redis) { double('redis') } - let(:key) { 'user:1' } - let(:cache_key) { "action_rate_limiter:test_action:#{key}" } + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:key) { [user, project] } + let(:cache_key) { "action_rate_limiter:test_action:user:#{user.id}:project:#{project.id}" } subject { described_class.new(action: :test_action, expiry_time: 100) } -- cgit v1.2.1 From d7209062f58c326a9ff812a5cf651489e11458cd Mon Sep 17 00:00:00 2001 From: Paolo Falomo Date: Wed, 13 Dec 2017 13:24:37 +0000 Subject: I'm currently the proofreader of Italians Translations, i've started to translate GitLab even before Crowdin. Can i be mentioned? --- doc/development/i18n/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/development/i18n/index.md b/doc/development/i18n/index.md index 4cb2624c098..8aa0462d213 100644 --- a/doc/development/i18n/index.md +++ b/doc/development/i18n/index.md @@ -59,6 +59,7 @@ Requests to become a proof reader will be considered on the merits of previous t - French - German - Italian + - [Paolo Falomo](https://crowdin.com/profile/paolo.falomo) - Japanese - Korean - [Huang Tao](https://crowdin.com/profile/htve) -- cgit v1.2.1 From 48e629e65464ececf891ca528061cad5cb4493d5 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 15 Dec 2017 05:46:59 +0000 Subject: With laravel's package auto-discovery, the composer install fails when it tries to install Dusk as it thinks it's running in production --- doc/articles/laravel_with_gitlab_and_envoy/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/articles/laravel_with_gitlab_and_envoy/index.md b/doc/articles/laravel_with_gitlab_and_envoy/index.md index e0d8fb8d081..b20bd8c247a 100644 --- a/doc/articles/laravel_with_gitlab_and_envoy/index.md +++ b/doc/articles/laravel_with_gitlab_and_envoy/index.md @@ -502,8 +502,8 @@ stages: unit_test: stage: test script: - - composer install - cp .env.example .env + - composer install - php artisan key:generate - php artisan migrate - vendor/bin/phpunit -- cgit v1.2.1 From 558c971e3198c3127320402c8d060243c7b28daa Mon Sep 17 00:00:00 2001 From: Tiago Botelho Date: Mon, 11 Dec 2017 11:35:03 +0000 Subject: Fork and Import jobs only get marked as failed when the number of Sidekiq retries were exhausted --- app/workers/concerns/project_import_options.rb | 23 +++++++++++++ app/workers/concerns/project_start_import.rb | 1 + app/workers/repository_fork_worker.rb | 22 ++---------- app/workers/repository_import_worker.rb | 19 ++-------- ...ed-when-the-number-of-retries-was-exhausted.yml | 5 +++ .../concerns/project_import_options_spec.rb | 40 ++++++++++++++++++++++ spec/workers/repository_fork_worker_spec.rb | 29 +++++++--------- spec/workers/repository_import_worker_spec.rb | 23 +++++-------- 8 files changed, 95 insertions(+), 67 deletions(-) create mode 100644 app/workers/concerns/project_import_options.rb create mode 100644 changelogs/unreleased/39246-fork-and-import-jobs-should-only-be-marked-as-failed-when-the-number-of-retries-was-exhausted.yml create mode 100644 spec/workers/concerns/project_import_options_spec.rb diff --git a/app/workers/concerns/project_import_options.rb b/app/workers/concerns/project_import_options.rb new file mode 100644 index 00000000000..10b971344f7 --- /dev/null +++ b/app/workers/concerns/project_import_options.rb @@ -0,0 +1,23 @@ +module ProjectImportOptions + extend ActiveSupport::Concern + + included do + IMPORT_RETRY_COUNT = 5 + + sidekiq_options retry: IMPORT_RETRY_COUNT, status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + + # We only want to mark the project as failed once we exhausted all retries + sidekiq_retries_exhausted do |job| + project = Project.find(job['args'].first) + + action = if project.forked? + "fork" + else + "import" + end + + project.mark_import_as_failed("Every #{action} attempt has failed: #{job['error_message']}. Please try again.") + Sidekiq.logger.warn "Failed #{job['class']} with #{job['args']}: #{job['error_message']}" + end + end +end diff --git a/app/workers/concerns/project_start_import.rb b/app/workers/concerns/project_start_import.rb index 0704ebbb0fd..4e55a1ee3d6 100644 --- a/app/workers/concerns/project_start_import.rb +++ b/app/workers/concerns/project_start_import.rb @@ -1,3 +1,4 @@ +# Used in EE by mirroring module ProjectStartImport def start(project) if project.import_started? && project.import_jid == self.jid diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index a07ef1705a1..d1c57b82681 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -1,11 +1,8 @@ class RepositoryForkWorker - ForkError = Class.new(StandardError) - include ApplicationWorker include Gitlab::ShellAdapter include ProjectStartImport - - sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + include ProjectImportOptions def perform(project_id, forked_from_repository_storage_path, source_disk_path) project = Project.find(project_id) @@ -18,20 +15,12 @@ class RepositoryForkWorker result = gitlab_shell.fork_repository(forked_from_repository_storage_path, source_disk_path, project.repository_storage_path, project.disk_path) - raise ForkError, "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result + raise "Unable to fork project #{project_id} for repository #{source_disk_path} -> #{project.disk_path}" unless result project.repository.after_import - raise ForkError, "Project #{project_id} had an invalid repository after fork" unless project.valid_repo? + raise "Project #{project_id} had an invalid repository after fork" unless project.valid_repo? project.import_finish - rescue ForkError => ex - fail_fork(project, ex.message) - raise - rescue => ex - return unless project - - fail_fork(project, ex.message) - raise ForkError, "#{ex.class} #{ex.message}" end private @@ -42,9 +31,4 @@ class RepositoryForkWorker Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.") false end - - def fail_fork(project, message) - Rails.logger.error(message) - project.mark_import_as_failed(message) - end end diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 55715c83cb1..31e2798c36b 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -1,11 +1,8 @@ class RepositoryImportWorker - ImportError = Class.new(StandardError) - include ApplicationWorker include ExceptionBacktrace include ProjectStartImport - - sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION + include ProjectImportOptions def perform(project_id) project = Project.find(project_id) @@ -23,17 +20,9 @@ class RepositoryImportWorker # to those importers to mark the import process as complete. return if service.async? - raise ImportError, result[:message] if result[:status] == :error + raise result[:message] if result[:status] == :error project.after_import - rescue ImportError => ex - fail_import(project, ex.message) - raise - rescue => ex - return unless project - - fail_import(project, ex.message) - raise ImportError, "#{ex.class} #{ex.message}" end private @@ -44,8 +33,4 @@ class RepositoryImportWorker Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while importing.") false end - - def fail_import(project, message) - project.mark_import_as_failed(message) - end end diff --git a/changelogs/unreleased/39246-fork-and-import-jobs-should-only-be-marked-as-failed-when-the-number-of-retries-was-exhausted.yml b/changelogs/unreleased/39246-fork-and-import-jobs-should-only-be-marked-as-failed-when-the-number-of-retries-was-exhausted.yml new file mode 100644 index 00000000000..ce238a2c79f --- /dev/null +++ b/changelogs/unreleased/39246-fork-and-import-jobs-should-only-be-marked-as-failed-when-the-number-of-retries-was-exhausted.yml @@ -0,0 +1,5 @@ +--- +title: Only mark import and fork jobs as failed once all Sidekiq retries get exhausted +merge_request: 15844 +author: +type: changed diff --git a/spec/workers/concerns/project_import_options_spec.rb b/spec/workers/concerns/project_import_options_spec.rb new file mode 100644 index 00000000000..b6c111df8b9 --- /dev/null +++ b/spec/workers/concerns/project_import_options_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe ProjectImportOptions do + let(:project) { create(:project, :import_started) } + let(:job) { { 'args' => [project.id, nil, nil], 'jid' => '123' } } + let(:worker_class) do + Class.new do + include Sidekiq::Worker + include ProjectImportOptions + end + end + + it 'sets default retry limit' do + expect(worker_class.sidekiq_options['retry']).to eq(ProjectImportOptions::IMPORT_RETRY_COUNT) + end + + it 'sets default status expiration' do + expect(worker_class.sidekiq_options['status_expiration']).to eq(StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION) + end + + describe '.sidekiq_retries_exhausted' do + it 'marks fork as failed' do + expect { worker_class.sidekiq_retries_exhausted_block.call(job) }.to change { project.reload.import_status }.from("started").to("failed") + end + + it 'logs the appropriate error message for forked projects' do + allow_any_instance_of(Project).to receive(:forked?).and_return(true) + + worker_class.sidekiq_retries_exhausted_block.call(job) + + expect(project.reload.import_error).to include("fork") + end + + it 'logs the appropriate error message for forked projects' do + worker_class.sidekiq_retries_exhausted_block.call(job) + + expect(project.reload.import_error).to include("import") + end + end +end diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index 74c85848b7e..31598586f59 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -1,17 +1,21 @@ require 'spec_helper' describe RepositoryForkWorker do - let(:project) { create(:project, :repository) } - let(:fork_project) { create(:project, :repository, :import_scheduled, forked_from_project: project) } - let(:shell) { Gitlab::Shell.new } - - subject { described_class.new } - - before do - allow(subject).to receive(:gitlab_shell).and_return(shell) + describe 'modules' do + it 'includes ProjectImportOptions' do + expect(described_class).to include_module(ProjectImportOptions) + end end describe "#perform" do + let(:project) { create(:project, :repository) } + let(:fork_project) { create(:project, :repository, :import_scheduled, forked_from_project: project) } + let(:shell) { Gitlab::Shell.new } + + before do + allow(subject).to receive(:gitlab_shell).and_return(shell) + end + def perform! subject.perform(fork_project.id, '/test/path', project.disk_path) end @@ -60,14 +64,7 @@ describe RepositoryForkWorker do expect_fork_repository.and_return(false) - expect { perform! }.to raise_error(RepositoryForkWorker::ForkError, error_message) - end - - it 'handles unexpected error' do - expect_fork_repository.and_raise(RuntimeError) - - expect { perform! }.to raise_error(RepositoryForkWorker::ForkError) - expect(fork_project.reload.import_status).to eq('failed') + expect { perform! }.to raise_error(StandardError, error_message) end end end diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb index 0af537647ad..85ac14eb347 100644 --- a/spec/workers/repository_import_worker_spec.rb +++ b/spec/workers/repository_import_worker_spec.rb @@ -1,11 +1,15 @@ require 'spec_helper' describe RepositoryImportWorker do - let(:project) { create(:project, :import_scheduled) } - - subject { described_class.new } + describe 'modules' do + it 'includes ProjectImportOptions' do + expect(described_class).to include_module(ProjectImportOptions) + end + end describe '#perform' do + let(:project) { create(:project, :import_scheduled) } + context 'when worker was reset without cleanup' do let(:jid) { '12345678' } let(:started_project) { create(:project, :import_started, import_jid: jid) } @@ -44,22 +48,11 @@ describe RepositoryImportWorker do expect do subject.perform(project.id) - end.to raise_error(RepositoryImportWorker::ImportError, error) + end.to raise_error(StandardError, error) expect(project.reload.import_jid).not_to be_nil end end - context 'with unexpected error' do - it 'marks import as failed' do - allow_any_instance_of(Projects::ImportService).to receive(:execute).and_raise(RuntimeError) - - expect do - subject.perform(project.id) - end.to raise_error(RepositoryImportWorker::ImportError) - expect(project.reload.import_status).to eq('failed') - end - end - context 'when using an asynchronous importer' do it 'does not mark the import process as finished' do service = double(:service) -- cgit v1.2.1 From eaf2f48dc7db706fa1dea050543667f8cdebd4c4 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 15 Dec 2017 10:21:49 +0000 Subject: Export and use Notes ES module --- .../diff_notes/components/diff_note_avatars.js | 4 ++-- app/assets/javascripts/init_notes.js | 6 ++++-- app/assets/javascripts/main.js | 2 -- app/assets/javascripts/merge_request_tabs.js | 4 ++-- app/assets/javascripts/notes.js | 6 ++++++ spec/javascripts/merge_request_notes_spec.js | 4 +--- spec/javascripts/merge_request_tabs_spec.js | 19 +++++++++---------- spec/javascripts/notes_spec.js | 4 +--- 8 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js index 06ce84d7599..300b02da663 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -1,8 +1,8 @@ /* global CommentsStore */ -/* global notes */ import Vue from 'vue'; import collapseIcon from '../icons/collapse_icon.svg'; +import Notes from '../../notes'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; const DiffNoteAvatars = Vue.extend({ @@ -129,7 +129,7 @@ const DiffNoteAvatars = Vue.extend({ }, methods: { clickedAvatar(e) { - notes.onAddDiffNote(e); + Notes.instance.onAddDiffNote(e); // Toggle the active state of the toggle all button this.toggleDiscussionsToggleState(); diff --git a/app/assets/javascripts/init_notes.js b/app/assets/javascripts/init_notes.js index 3a8b4360cb6..882aedfcc76 100644 --- a/app/assets/javascripts/init_notes.js +++ b/app/assets/javascripts/init_notes.js @@ -1,4 +1,4 @@ -/* global Notes */ +import Notes from './notes'; export default () => { const dataEl = document.querySelector('.js-notes-data'); @@ -10,5 +10,7 @@ export default () => { autocomplete, } = JSON.parse(dataEl.innerHTML); - window.notes = new Notes(notesUrl, notesIds, now, diffView, autocomplete); + // Create a singleton so that we don't need to assign + // into the window object, we can just access the current isntance with Notes.instance + Notes.initialize(notesUrl, notesIds, now, diffView, autocomplete); }; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index ae3f76873cf..63f2a65700c 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -46,9 +46,7 @@ import LazyLoader from './lazy_loader'; import './line_highlighter'; import initLogoAnimation from './logo'; import './merge_request'; -import './merge_request_tabs'; import './milestone_select'; -import './notes'; import './preview_markdown'; import './projects_dropdown'; import './render_gfm'; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index cacca35ca98..acfc62fe5cb 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -1,5 +1,4 @@ /* eslint-disable no-new, class-methods-use-this */ -/* global notes */ import Cookies from 'js-cookie'; import Flash from './flash'; @@ -16,6 +15,7 @@ import initDiscussionTab from './image_diff/init_discussion_tab'; import Diff from './diff'; import { localTimeAgo } from './lib/utils/datetime_utility'; import syntaxHighlight from './syntax_highlight'; +import Notes from './notes'; /* eslint-disable max-len */ // MergeRequestTabs @@ -324,7 +324,7 @@ export default class MergeRequestTabs { if (anchor && anchor.length > 0) { const notesContent = anchor.closest('.notes_content'); const lineType = notesContent.hasClass('new') ? 'new' : 'old'; - notes.toggleDiffNote({ + Notes.instance.toggleDiffNote({ target: anchor, lineType, forceShow: true, diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 042fe44e1c6..a2b8e6f6495 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -37,6 +37,12 @@ const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; export default class Notes { + static initialize(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { + if (!this.instance) { + this.instance = new Notes(notes_url, note_ids, last_fetched_at, view, enableGFM); + } + } + constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateComment = this.updateComment.bind(this); diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js index 6054b75d0b8..e983e4de3fc 100644 --- a/spec/javascripts/merge_request_notes_spec.js +++ b/spec/javascripts/merge_request_notes_spec.js @@ -1,11 +1,9 @@ -/* global Notes */ - import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; import '~/render_gfm'; import '~/render_math'; -import '~/notes'; +import Notes from '~/notes'; const upArrowKeyCode = 38; diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index 31426ceb110..050f0ea9ebd 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -1,5 +1,4 @@ /* eslint-disable no-var, comma-dangle, object-shorthand */ -/* global Notes */ import * as urlUtils from '~/lib/utils/url_utility'; import MergeRequestTabs from '~/merge_request_tabs'; @@ -7,7 +6,7 @@ import '~/commit/pipelines/pipelines_bundle'; import '~/breakpoints'; import '~/lib/utils/common_utils'; import Diff from '~/diff'; -import '~/notes'; +import Notes from '~/notes'; import 'vendor/jquery.scrollTo'; (function () { @@ -279,8 +278,8 @@ import 'vendor/jquery.scrollTo'; loadFixtures('merge_requests/diff_comment.html.raw'); $('body').attr('data-page', 'projects:merge_requests:show'); window.gl.ImageFile = () => {}; - window.notes = new Notes('', []); - spyOn(window.notes, 'toggleDiffNote').and.callThrough(); + Notes.initialize('', []); + spyOn(Notes.instance, 'toggleDiffNote').and.callThrough(); }); afterEach(() => { @@ -338,7 +337,7 @@ import 'vendor/jquery.scrollTo'; this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); expect(noteId.length).toBeGreaterThan(0); - expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({ + expect(Notes.instance.toggleDiffNote).toHaveBeenCalledWith({ target: jasmine.any(Object), lineType: 'old', forceShow: true, @@ -349,7 +348,7 @@ import 'vendor/jquery.scrollTo'; spyOn(urlUtils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist'); this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); - expect(window.notes.toggleDiffNote).not.toHaveBeenCalled(); + expect(Notes.instance.toggleDiffNote).not.toHaveBeenCalled(); }); }); @@ -359,7 +358,7 @@ import 'vendor/jquery.scrollTo'; this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); expect(noteLineNumId.length).toBeGreaterThan(0); - expect(window.notes.toggleDiffNote).not.toHaveBeenCalled(); + expect(Notes.instance.toggleDiffNote).not.toHaveBeenCalled(); }); }); }); @@ -393,7 +392,7 @@ import 'vendor/jquery.scrollTo'; this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); expect(noteId.length).toBeGreaterThan(0); - expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({ + expect(Notes.instance.toggleDiffNote).toHaveBeenCalledWith({ target: jasmine.any(Object), lineType: 'new', forceShow: true, @@ -404,7 +403,7 @@ import 'vendor/jquery.scrollTo'; spyOn(urlUtils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist'); this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); - expect(window.notes.toggleDiffNote).not.toHaveBeenCalled(); + expect(Notes.instance.toggleDiffNote).not.toHaveBeenCalled(); }); }); @@ -414,7 +413,7 @@ import 'vendor/jquery.scrollTo'; this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); expect(noteLineNumId.length).toBeGreaterThan(0); - expect(window.notes.toggleDiffNote).not.toHaveBeenCalled(); + expect(Notes.instance.toggleDiffNote).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index e09b8dc7fc5..167f074fb9b 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -1,12 +1,10 @@ /* eslint-disable space-before-function-paren, no-unused-expressions, no-var, object-shorthand, comma-dangle, max-len */ -/* global Notes */ - import * as urlUtils from '~/lib/utils/url_utility'; import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; import '~/render_gfm'; -import '~/notes'; +import Notes from '~/notes'; (function() { window.gon || (window.gon = {}); -- cgit v1.2.1 From 6a33587182ef13179d2dc00a6698cb42f2e7138e Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 15 Dec 2017 12:57:08 +0000 Subject: Exported JS classes as modules --- .../javascripts/boards/components/board_sidebar.js | 2 +- app/assets/javascripts/dispatcher.js | 6 +- app/assets/javascripts/init_issuable_sidebar.js | 4 +- app/assets/javascripts/line_highlighter.js | 2 +- app/assets/javascripts/main.js | 2 - app/assets/javascripts/merge_request.js | 261 ++++++------ .../javascripts/repo/components/repo_preview.vue | 2 +- app/assets/javascripts/right_sidebar.js | 438 +++++++++++---------- app/assets/javascripts/shortcuts_issuable.js | 4 +- spec/javascripts/collapsed_sidebar_todo_spec.js | 3 +- spec/javascripts/line_highlighter_spec.js | 3 +- spec/javascripts/merge_request_spec.js | 3 +- spec/javascripts/right_sidebar_spec.js | 3 +- 13 files changed, 362 insertions(+), 371 deletions(-) diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index faa76da964f..616de2347e1 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -1,9 +1,9 @@ /* eslint-disable comma-dangle, space-before-function-paren, no-new */ /* global MilestoneSelect */ -/* global Sidebar */ import Vue from 'vue'; import Flash from '../../flash'; +import Sidebar from '../../right_sidebar'; import eventHub from '../../sidebar/event_hub'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title'; import assignees from '../../sidebar/components/assignees/assignees'; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 5721375f4c6..6d00211cd0f 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -11,7 +11,7 @@ import NotificationsForm from './notifications_form'; import notificationsDropdown from './notifications_dropdown'; import groupAvatar from './group_avatar'; import GroupLabelSubscription from './group_label_subscription'; -/* global LineHighlighter */ +import LineHighlighter from './line_highlighter'; import BuildArtifacts from './build_artifacts'; import CILintEditor from './ci_lint_editor'; import groupsSelect from './groups_select'; @@ -21,7 +21,7 @@ import NamespaceSelect from './namespace_select'; import NewCommitForm from './new_commit_form'; import Project from './project'; import projectAvatar from './project_avatar'; -/* global MergeRequest */ +import MergeRequest from './merge_request'; import Compare from './compare'; import initCompareAutocomplete from './compare_autocomplete'; import ProjectFindFile from './project_find_file'; @@ -29,7 +29,7 @@ import ProjectNew from './project_new'; import projectImport from './project_import'; import Labels from './labels'; import LabelManager from './label_manager'; -/* global Sidebar */ +import Sidebar from './right_sidebar'; import IssuableTemplateSelectors from './templates/issuable_template_selectors'; import Flash from './flash'; import CommitsList from './commits'; diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js index ada693afc46..5d4c1851fe5 100644 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ b/app/assets/javascripts/init_issuable_sidebar.js @@ -2,7 +2,7 @@ /* global MilestoneSelect */ import LabelsSelect from './labels_select'; import IssuableContext from './issuable_context'; -/* global Sidebar */ +import Sidebar from './right_sidebar'; import DueDateSelectors from './due_date_select'; @@ -15,5 +15,5 @@ export default () => { new LabelsSelect(); new IssuableContext(sidebarOptions.currentUser); new DueDateSelectors(); - window.sidebar = new Sidebar(); + Sidebar.initialize(); }; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index a75d1a4b8d0..fbd381d8ff7 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -175,4 +175,4 @@ LineHighlighter.prototype.__setLocationHash__ = function(value) { }, document.title, value); }; -window.LineHighlighter = LineHighlighter; +export default LineHighlighter; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index ae3f76873cf..b984914ad68 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -45,14 +45,12 @@ import './layout_nav'; import LazyLoader from './lazy_loader'; import './line_highlighter'; import initLogoAnimation from './logo'; -import './merge_request'; import './merge_request_tabs'; import './milestone_select'; import './notes'; import './preview_markdown'; import './projects_dropdown'; import './render_gfm'; -import './right_sidebar'; import initBreadcrumbs from './breadcrumb'; import './dispatcher'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 6946c0b30f0..cb3cdea8111 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,5 +1,4 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, max-len, prefer-arrow-callback */ -/* global MergeRequestTabs */ import 'vendor/jquery.waitforimages'; import TaskList from './task_list'; @@ -7,142 +6,138 @@ import MergeRequestTabs from './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; import { addDelimiter } from './lib/utils/text_utility'; -(function() { - this.MergeRequest = (function() { - function MergeRequest(opts) { - // Initialize MergeRequest behavior - // - // Options: - // action - String, current controller action - // - this.opts = opts != null ? opts : {}; - this.submitNoteForm = this.submitNoteForm.bind(this); - this.$el = $('.merge-request'); - this.$('.show-all-commits').on('click', (function(_this) { - return function() { - return _this.showAllCommits(); - }; - })(this)); - - this.initTabs(); - this.initMRBtnListeners(); - this.initCommitMessageListeners(); - this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); - - if ($("a.btn-close").length) { - this.taskList = new TaskList({ - dataType: 'merge_request', - fieldName: 'description', - selector: '.detail-page-description', - onSuccess: (result) => { - document.querySelector('#task_status').innerText = result.task_status; - document.querySelector('#task_status_short').innerText = result.task_status_short; - } - }); - } - } - - // Local jQuery finder - MergeRequest.prototype.$ = function(selector) { - return this.$el.find(selector); +function MergeRequest(opts) { + // Initialize MergeRequest behavior + // + // Options: + // action - String, current controller action + // + this.opts = opts != null ? opts : {}; + this.submitNoteForm = this.submitNoteForm.bind(this); + this.$el = $('.merge-request'); + this.$('.show-all-commits').on('click', (function(_this) { + return function() { + return _this.showAllCommits(); }; - - MergeRequest.prototype.initTabs = function() { - if (window.mrTabs) { - window.mrTabs.unbindEvents(); + })(this)); + + this.initTabs(); + this.initMRBtnListeners(); + this.initCommitMessageListeners(); + this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); + + if ($("a.btn-close").length) { + this.taskList = new TaskList({ + dataType: 'merge_request', + fieldName: 'description', + selector: '.detail-page-description', + onSuccess: (result) => { + document.querySelector('#task_status').innerText = result.task_status; + document.querySelector('#task_status_short').innerText = result.task_status_short; } - window.mrTabs = new MergeRequestTabs(this.opts); - }; - - MergeRequest.prototype.showAllCommits = function() { - this.$('.first-commits').remove(); - return this.$('.all-commits').removeClass('hide'); - }; - - MergeRequest.prototype.initMRBtnListeners = function() { - var _this; - _this = this; - return $('a.btn-close, a.btn-reopen').on('click', function(e) { - var $this, shouldSubmit; - $this = $(this); - shouldSubmit = $this.hasClass('btn-comment'); - if (shouldSubmit && $this.data('submitted')) { - return; - } - - if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable(); - - if (shouldSubmit) { - if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) { - e.preventDefault(); - e.stopImmediatePropagation(); - - _this.submitNoteForm($this.closest('form'), $this); - } - } - }); - }; - - MergeRequest.prototype.submitNoteForm = function(form, $button) { - var noteText; - noteText = form.find("textarea.js-note-text").val(); - if (noteText.trim().length > 0) { - form.submit(); - $button.data('submitted', true); - return $button.trigger('click'); - } - }; - - MergeRequest.prototype.initCommitMessageListeners = function() { - $(document).on('click', 'a.js-with-description-link', function(e) { - var textarea = $('textarea.js-commit-message'); - e.preventDefault(); + }); + } +} + +// Local jQuery finder +MergeRequest.prototype.$ = function(selector) { + return this.$el.find(selector); +}; + +MergeRequest.prototype.initTabs = function() { + if (window.mrTabs) { + window.mrTabs.unbindEvents(); + } + window.mrTabs = new MergeRequestTabs(this.opts); +}; + +MergeRequest.prototype.showAllCommits = function() { + this.$('.first-commits').remove(); + return this.$('.all-commits').removeClass('hide'); +}; + +MergeRequest.prototype.initMRBtnListeners = function() { + var _this; + _this = this; + return $('a.btn-close, a.btn-reopen').on('click', function(e) { + var $this, shouldSubmit; + $this = $(this); + shouldSubmit = $this.hasClass('btn-comment'); + if (shouldSubmit && $this.data('submitted')) { + return; + } - textarea.val(textarea.data('messageWithDescription')); - $('.js-with-description-hint').hide(); - $('.js-without-description-hint').show(); - }); + if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable(); - $(document).on('click', 'a.js-without-description-link', function(e) { - var textarea = $('textarea.js-commit-message'); + if (shouldSubmit) { + if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) { e.preventDefault(); + e.stopImmediatePropagation(); - textarea.val(textarea.data('messageWithoutDescription')); - $('.js-with-description-hint').show(); - $('.js-without-description-hint').hide(); - }); - }; - - MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) { - $('.detail-page-header .status-box') - .removeClass(classToRemove) - .addClass(classToAdd) - .find('span') - .text(newStatusText); - }; - - MergeRequest.prototype.decreaseCounter = function(by = 1) { - const $el = $('.nav-links .js-merge-counter'); - const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); - - $el.text(addDelimiter(count)); - }; - - MergeRequest.prototype.hideCloseButton = function() { - const el = document.querySelector('.merge-request .js-issuable-actions'); - const closeDropdownItem = el.querySelector('li.close-item'); - if (closeDropdownItem) { - closeDropdownItem.classList.add('hidden'); - // Selects the next dropdown item - el.querySelector('li.report-item').click(); - } else { - // No dropdown just hide the Close button - el.querySelector('.btn-close').classList.add('hidden'); + _this.submitNoteForm($this.closest('form'), $this); } - // Dropdown for mobile screen - el.querySelector('li.js-close-item').classList.add('hidden'); - }; - - return MergeRequest; - })(); -}).call(window); + } + }); +}; + +MergeRequest.prototype.submitNoteForm = function(form, $button) { + var noteText; + noteText = form.find("textarea.js-note-text").val(); + if (noteText.trim().length > 0) { + form.submit(); + $button.data('submitted', true); + return $button.trigger('click'); + } +}; + +MergeRequest.prototype.initCommitMessageListeners = function() { + $(document).on('click', 'a.js-with-description-link', function(e) { + var textarea = $('textarea.js-commit-message'); + e.preventDefault(); + + textarea.val(textarea.data('messageWithDescription')); + $('.js-with-description-hint').hide(); + $('.js-without-description-hint').show(); + }); + + $(document).on('click', 'a.js-without-description-link', function(e) { + var textarea = $('textarea.js-commit-message'); + e.preventDefault(); + + textarea.val(textarea.data('messageWithoutDescription')); + $('.js-with-description-hint').show(); + $('.js-without-description-hint').hide(); + }); +}; + +MergeRequest.prototype.updateStatusText = function(classToRemove, classToAdd, newStatusText) { + $('.detail-page-header .status-box') + .removeClass(classToRemove) + .addClass(classToAdd) + .find('span') + .text(newStatusText); +}; + +MergeRequest.prototype.decreaseCounter = function(by = 1) { + const $el = $('.nav-links .js-merge-counter'); + const count = Math.max((parseInt($el.text().replace(/[^\d]/, ''), 10) - by), 0); + + $el.text(addDelimiter(count)); +}; + +MergeRequest.prototype.hideCloseButton = function() { + const el = document.querySelector('.merge-request .js-issuable-actions'); + const closeDropdownItem = el.querySelector('li.close-item'); + if (closeDropdownItem) { + closeDropdownItem.classList.add('hidden'); + // Selects the next dropdown item + el.querySelector('li.report-item').click(); + } else { + // No dropdown just hide the Close button + el.querySelector('.btn-close').classList.add('hidden'); + } + // Dropdown for mobile screen + el.querySelector('li.js-close-item').classList.add('hidden'); +}; + +export default MergeRequest; diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index 425c55fafb5..3d1e0297bd5 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -1,6 +1,6 @@ + + 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..6a0262f271b --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -0,0 +1,35 @@ + + + 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..742f746e02f --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue new file mode 100644 index 00000000000..7f29a355eca --- /dev/null +++ b/app/assets/javascripts/ide/components/ide.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue new file mode 100644 index 00000000000..5a08718e386 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_context_bar.vue @@ -0,0 +1,75 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue new file mode 100644 index 00000000000..bd3a521ff43 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue new file mode 100644 index 00000000000..61daba6d176 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_project_tree.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue new file mode 100644 index 00000000000..b6b089e6b25 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_repo_tree.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue new file mode 100644 index 00000000000..535398d98c2 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -0,0 +1,62 @@ + + + diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue new file mode 100644 index 00000000000..a24abadd936 --- /dev/null +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -0,0 +1,71 @@ + + + diff --git a/app/assets/javascripts/ide/components/new_branch_form.vue b/app/assets/javascripts/ide/components/new_branch_form.vue new file mode 100644 index 00000000000..2119d373d31 --- /dev/null +++ b/app/assets/javascripts/ide/components/new_branch_form.vue @@ -0,0 +1,108 @@ + + + diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue new file mode 100644 index 00000000000..6e67e99a70f --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -0,0 +1,101 @@ + + + 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..a0650d37690 --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -0,0 +1,112 @@ + + + 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..2a2f2a241fc --- /dev/null +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -0,0 +1,87 @@ + + + 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..470db2c9650 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -0,0 +1,171 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_edit_button.vue b/app/assets/javascripts/ide/components/repo_edit_button.vue new file mode 100644 index 00000000000..37bd9003e96 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_edit_button.vue @@ -0,0 +1,57 @@ + + + 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..221be4b9074 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -0,0 +1,127 @@ + + + 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..09ca11531b1 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file.vue @@ -0,0 +1,165 @@ + + + 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..34f0d51819a --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_file_buttons.vue @@ -0,0 +1,56 @@ + + + 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..7eb840c7608 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_loading_file.vue @@ -0,0 +1,44 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_prev_directory.vue b/app/assets/javascripts/ide/components/repo_prev_directory.vue new file mode 100644 index 00000000000..7cd359ea4ed --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_prev_directory.vue @@ -0,0 +1,32 @@ + + + diff --git a/app/assets/javascripts/ide/components/repo_preview.vue b/app/assets/javascripts/ide/components/repo_preview.vue new file mode 100644 index 00000000000..3d1e0297bd5 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_preview.vue @@ -0,0 +1,65 @@ + + + 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..5bd63ac9ec5 --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -0,0 +1,69 @@ + + + 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..ab0bef4f0ac --- /dev/null +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -0,0 +1,27 @@ + + + diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js new file mode 100644 index 00000000000..a9cbf8e370f --- /dev/null +++ b/app/assets/javascripts/ide/ide_router.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import store from './stores'; +import flash from '../flash'; +import { + getTreeEntry, +} from './stores/utils'; + +Vue.use(VueRouter); + +/** + * Routes below /-/ide/: + +/project/h5bp/html5-boilerplate/blob/master +/project/h5bp/html5-boilerplate/blob/master/app/js/test.js + +/project/h5bp/html5-boilerplate/mr/123 +/project/h5bp/html5-boilerplate/mr/123/app/js/test.js + +/workspace/123 +/workspace/project/h5bp/html5-boilerplate/blob/my-special-branch +/workspace/project/h5bp/html5-boilerplate/mr/123 + +/ = /workspace + +/settings +*/ + +// Unfortunately Vue Router doesn't work without at least a fake component +// If you do only data handling +const EmptyRouterComponent = { + render(createElement) { + return createElement('div'); + }, +}; + +const router = new VueRouter({ + mode: 'history', + base: `${gon.relative_url_root}/-/ide/`, + routes: [ + { + path: '/project/:namespace/:project', + component: EmptyRouterComponent, + children: [ + { + path: ':targetmode/:branch/*', + component: EmptyRouterComponent, + }, + { + path: 'mr/:mrid', + component: EmptyRouterComponent, + }, + ], + }, + ], +}); + +router.beforeEach((to, from, next) => { + if (to.params.namespace && to.params.project) { + store.dispatch('getProjectData', { + namespace: to.params.namespace, + projectId: to.params.project, + }) + .then(() => { + const fullProjectId = `${to.params.namespace}/${to.params.project}`; + + if (to.params.branch) { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: to.params.branch, + }); + + store.dispatch('getTreeData', { + projectId: fullProjectId, + branch: to.params.branch, + endpoint: `/tree/${to.params.branch}`, + }) + .then(() => { + if (to.params[0]) { + const treeEntry = getTreeEntry(store, `${to.params.namespace}/${to.params.project}/${to.params.branch}`, to.params[0]); + if (treeEntry) { + store.dispatch('handleTreeEntryAction', treeEntry); + } + } + }) + .catch((e) => { + flash('Error while loading the branch files. Please try again.'); + throw e; + }); + } + }) + .catch((e) => { + flash('Error while loading the project data. Please try again.'); + throw e; + }); + } + + next(); +}); + +export default router; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js new file mode 100644 index 00000000000..a96bd339f51 --- /dev/null +++ b/app/assets/javascripts/ide/index.js @@ -0,0 +1,55 @@ +import Vue from 'vue'; +import { mapActions } from 'vuex'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; +import ide from './components/ide.vue'; + +import store from './stores'; +import router from './ide_router'; +import Translate from '../vue_shared/translate'; +import ContextualSidebar from '../contextual_sidebar'; + +function initIde(el) { + if (!el) return null; + + return new Vue({ + el, + store, + router, + components: { + ide, + }, + methods: { + ...mapActions([ + 'setInitialData', + ]), + }, + created() { + const data = el.dataset; + + this.setInitialData({ + endpoints: { + rootEndpoint: data.url, + newMergeRequestUrl: data.newMergeRequestUrl, + rootUrl: data.rootUrl, + }, + canCommit: convertPermissionToBoolean(data.canCommit), + onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch), + path: data.currentPath, + isRoot: convertPermissionToBoolean(data.root), + isInitialRoot: convertPermissionToBoolean(data.root), + }); + }, + render(createElement) { + return createElement('ide'); + }, + }); +} + +const ideElement = document.getElementById('ide'); + +Vue.use(Translate); + +initIde(ideElement); + +const contextualSidebar = new ContextualSidebar(); +contextualSidebar.bindEvents(); diff --git a/app/assets/javascripts/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..14d9fe4771e --- /dev/null +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -0,0 +1,64 @@ +/* global monaco */ +import Disposable from './disposable'; + +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(); + } + + 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; + } + + onChange(cb) { + this.events.set( + this.path, + this.disposable.add( + this.model.onDidChangeContent(e => cb(this.model, e)), + ), + ); + } + + dispose() { + this.disposable.dispose(); + this.events.clear(); + } +} 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..fd462252795 --- /dev/null +++ b/app/assets/javascripts/ide/lib/common/model_manager.js @@ -0,0 +1,32 @@ +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); + } + + addModel(file) { + if (this.hasCachedModel(file.path)) { + return this.models.get(file.path); + } + + const model = new Model(this.monaco, file); + this.models.set(model.path, model); + this.disposable.add(model); + + return model; + } + + 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..0954b7973c4 --- /dev/null +++ b/app/assets/javascripts/ide/lib/decorations/controller.js @@ -0,0 +1,43 @@ +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) { + 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..dc0b1c95e59 --- /dev/null +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -0,0 +1,71 @@ +/* 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)); + this.decorationsController.addDecorations(data.path, '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..51e202b9348 --- /dev/null +++ b/app/assets/javascripts/ide/lib/editor.js @@ -0,0 +1,109 @@ +import DecorationsController from './decorations/controller'; +import DirtyDiffController from './diff/controller'; +import Disposable from './common/disposable'; +import ModelManager from './common/model_manager'; +import editorOptions from './editor_options'; + +export default class Editor { + static create(monaco) { + 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.disposable.add( + this.modelManager = new ModelManager(this.monaco), + this.decorationsController = new DecorationsController(this), + ); + + this.debouncedUpdate = _.debounce(() => { + this.updateDimensions(); + }, 200); + window.addEventListener('resize', this.debouncedUpdate, false); + } + + createInstance(domElement) { + if (!this.instance) { + this.disposable.add( + this.instance = this.monaco.editor.create(domElement, { + model: null, + readOnly: false, + contextmenu: true, + scrollBeyondLastLine: false, + minimap: { + enabled: false, + }, + }), + this.dirtyDiffController = new DirtyDiffController( + this.modelManager, this.decorationsController, + ), + ); + } + } + + createModel(file) { + return this.modelManager.addModel(file); + } + + attachModel(model) { + this.instance.setModel(model.getModel()); + 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; + }, {})); + + this.dirtyDiffController.reDecorate(model); + } + + clearEditor() { + if (this.instance) { + this.instance.setModel(null); + } + } + + dispose() { + this.disposable.dispose(); + window.removeEventListener('resize', this.debouncedUpdate); + + // dispose main monaco instance + if (this.instance) { + this.instance = null; + } + } + + updateDimensions() { + this.instance.layout(); + } + + setPosition({ lineNumber, column }) { + this.instance.revealPositionInCenter({ + lineNumber, + column, + }); + this.instance.setPosition({ + lineNumber, + column, + }); + } + + onPositionChange(cb) { + this.disposable.add( + this.instance.onDidChangeCursorPosition(e => cb(this.instance, e)), + ); + } +} diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js new file mode 100644 index 00000000000..701affc466e --- /dev/null +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -0,0 +1,2 @@ +export default [{ +}]; diff --git a/app/assets/javascripts/ide/monaco_loader.js b/app/assets/javascripts/ide/monaco_loader.js new file mode 100644 index 00000000000..af83a1ec0b4 --- /dev/null +++ b/app/assets/javascripts/ide/monaco_loader.js @@ -0,0 +1,11 @@ +import monacoContext from 'monaco-editor/dev/vs/loader'; + +monacoContext.require.config({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, +}); + +// 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..1fb24e93f2e --- /dev/null +++ b/app/assets/javascripts/ide/services/index.js @@ -0,0 +1,47 @@ +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', + }, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js new file mode 100644 index 00000000000..c01046c8c76 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions.js @@ -0,0 +1,179 @@ +import Vue from 'vue'; +import { visitUrl } from '../../lib/utils/url_utility'; +import flash from '../../flash'; +import service from '../services'; +import * as types from './mutation_types'; + +export const redirectToUrl = (_, url) => visitUrl(url); + +export const setInitialData = ({ commit }, data) => + commit(types.SET_INITIAL_DATA, data); + +export const closeDiscardPopup = ({ commit }) => + commit(types.TOGGLE_DISCARD_POPUP, false); + +export const discardAllChanges = ({ commit, getters, dispatch }) => { + const changedFiles = getters.changedFiles; + + changedFiles.forEach((file) => { + commit(types.DISCARD_FILE_CHANGES, file); + + if (file.tempFile) { + dispatch('closeFile', { file, force: true }); + } + }); +}; + +export const closeAllFiles = ({ state, dispatch }) => { + state.openFiles.forEach(file => dispatch('closeFile', { file })); +}; + +export const toggleEditMode = ( + { state, commit, getters, dispatch }, + force = false, +) => { + const changedFiles = getters.changedFiles; + + if (changedFiles.length && !force) { + commit(types.TOGGLE_DISCARD_POPUP, true); + } else { + commit(types.TOGGLE_EDIT_MODE); + commit(types.TOGGLE_DISCARD_POPUP, false); + dispatch('toggleBlobView'); + + if (!state.editMode) { + dispatch('discardAllChanges'); + } + } +}; + +export const toggleBlobView = ({ commit, state }) => { + if (state.editMode) { + commit(types.SET_EDIT_MODE); + } else { + commit(types.SET_PREVIEW_MODE); + } +}; + +export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { + if (side === 'left') { + commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed); + } else { + commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed); + } +}; + +export const checkCommitStatus = ({ state }) => + service + .getBranchData(state.currentProjectId, state.currentBranchId) + .then((data) => { + const { id } = data.commit; + const selectedBranch = + state.projects[state.currentProjectId].branches[state.currentBranchId]; + + if (selectedBranch.workingReference !== id) { + return true; + } + + return false; + }) + .catch(() => flash('Error checking branch data. Please try again.')); + +export const commitChanges = ( + { commit, state, dispatch, getters }, + { payload, newMr }, +) => + service + .commit(state.currentProjectId, payload) + .then((data) => { + const { branch } = payload; + if (!data.short_id) { + flash(data.message); + return; + } + + const selectedProject = state.projects[state.currentProjectId]; + const lastCommit = { + commit_path: `${selectedProject.web_url}/commit/${data.id}`, + commit: { + message: data.message, + authored_date: data.committed_date, + }, + }; + + flash( + `Your changes have been committed. Commit ${data.short_id} with ${ + data.stats.additions + } additions, ${data.stats.deletions} deletions.`, + 'notice', + ); + + if (newMr) { + dispatch( + 'redirectToUrl', + `${ + selectedProject.web_url + }/merge_requests/new?merge_request%5Bsource_branch%5D=${branch}`, + ); + } else { + commit(types.SET_BRANCH_WORKING_REFERENCE, { + projectId: state.currentProjectId, + branchId: state.currentBranchId, + reference: data.id, + }); + + getters.changedFiles.forEach((entry) => { + commit(types.SET_LAST_COMMIT_DATA, { + entry, + lastCommit, + }); + }); + + dispatch('discardAllChanges'); + dispatch('closeAllFiles'); + + window.scrollTo(0, 0); + } + }) + .catch(() => flash('Error committing changes. Please try again.')); + +export const createTempEntry = ( + { state, dispatch }, + { projectId, branchId, parent, name, type, content = '', base64 = false }, +) => { + const selectedParent = parent || state.trees[`${projectId}/${branchId}`]; + if (type === 'tree') { + dispatch('createTempTree', { + projectId, + branchId, + parent: selectedParent, + name, + }); + } else if (type === 'blob') { + dispatch('createTempFile', { + projectId, + branchId, + parent: selectedParent, + name, + base64, + content, + }); + } +}; + +export const scrollToTab = () => { + Vue.nextTick(() => { + const tabs = document.getElementById('tabs'); + + if (tabs) { + const tabEl = tabs.querySelector('.active .repo-tab'); + + tabEl.focus(); + } + }); +}; + +export * from './actions/tree'; +export * from './actions/file'; +export * from './actions/project'; +export * from './actions/branch'; diff --git a/app/assets/javascripts/ide/stores/actions/branch.js b/app/assets/javascripts/ide/stores/actions/branch.js new file mode 100644 index 00000000000..32bdf7fec22 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/branch.js @@ -0,0 +1,43 @@ +import service from '../../services'; +import flash from '../../../flash'; +import * as types from '../mutation_types'; + +export const getBranchData = ( + { commit, state, dispatch }, + { projectId, branchId, force = false } = {}, +) => new Promise((resolve, reject) => { + if ((typeof state.projects[`${projectId}`] === 'undefined' || + !state.projects[`${projectId}`].branches[branchId]) + || force) { + service.getBranchData(`${projectId}`, branchId) + .then((data) => { + const { id } = data.commit; + commit(types.SET_BRANCH, { projectPath: `${projectId}`, branchName: branchId, branch: data }); + commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id }); + resolve(data); + }) + .catch(() => { + flash('Error loading branch data. Please try again.'); + reject(new Error(`Branch not loaded - ${projectId}/${branchId}`)); + }); + } else { + resolve(state.projects[`${projectId}`].branches[branchId]); + } +}); + +export const createNewBranch = ({ state, commit }, branch) => service.createBranch( + state.currentProjectId, + { + branch, + ref: state.currentBranchId, + }, +) +.then(res => res.json()) +.then((data) => { + const branchName = data.name; + const url = location.href.replace(state.currentBranchId, branchName); + + if (this.$router) this.$router.push(url); + + commit(types.SET_CURRENT_BRANCH, branchName); +}); diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js new file mode 100644 index 00000000000..0f27d5bf1c3 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -0,0 +1,131 @@ +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import router from '../../ide_router'; +import { + findEntry, + setPageTitle, + createTemp, + findIndexOfFile, +} from '../utils'; + +export const closeFile = ({ commit, state, dispatch }, { file, force = false }) => { + if ((file.changed || file.tempFile) && !force) return; + + const indexOfClosedFile = findIndexOfFile(state.openFiles, file); + const fileWasActive = file.active; + + commit(types.TOGGLE_FILE_OPEN, file); + commit(types.SET_FILE_ACTIVE, { file, active: false }); + + if (state.openFiles.length > 0 && fileWasActive) { + const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; + const nextFileToOpen = state.openFiles[nextIndexToOpen]; + + dispatch('setFileActive', nextFileToOpen); + } else if (!state.openFiles.length) { + router.push(`/project/${file.projectId}/tree/${file.branchId}/`); + } + + dispatch('getLastCommitData'); +}; + +export const setFileActive = ({ commit, state, getters, dispatch }, file) => { + const currentActiveFile = getters.activeFile; + + if (file.active) return; + + if (currentActiveFile) { + commit(types.SET_FILE_ACTIVE, { file: currentActiveFile, active: false }); + } + + commit(types.SET_FILE_ACTIVE, { file, active: true }); + dispatch('scrollToTab'); + + // reset hash for line highlighting + location.hash = ''; + + commit(types.SET_CURRENT_PROJECT, file.projectId); + commit(types.SET_CURRENT_BRANCH, file.branchId); +}; + +export const getFileData = ({ state, commit, dispatch }, file) => { + commit(types.TOGGLE_LOADING, file); + + 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); + dispatch('setFileActive', file); + commit(types.TOGGLE_LOADING, file); + }) + .catch(() => { + commit(types.TOGGLE_LOADING, file); + flash('Error loading file data. Please try again.'); + }); +}; + +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.')); + +export const changeFileContent = ({ commit }, { file, content }) => { + commit(types.UPDATE_FILE_CONTENT, { file, content }); +}; + +export const setFileLanguage = ({ state, commit }, { fileLanguage }) => { + commit(types.SET_FILE_LANGUAGE, { file: state.selectedFile, fileLanguage }); +}; + +export const setFileEOL = ({ state, commit }, { eol }) => { + commit(types.SET_FILE_EOL, { file: state.selectedFile, eol }); +}; + +export const setEditorPosition = ({ state, commit }, { editorRow, editorColumn }) => { + commit(types.SET_FILE_POSITION, { file: state.selectedFile, editorRow, editorColumn }); +}; + +export const createTempFile = ({ state, commit, dispatch }, { projectId, branchId, parent, name, content = '', base64 = '' }) => { + const path = parent.path !== undefined ? parent.path : ''; + // We need to do the replacement otherwise the web_url + file.url duplicate + const newUrl = `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${name}`; + const file = createTemp({ + projectId, + branchId, + name: name.replace(`${path}/`, ''), + path, + type: 'blob', + level: parent.level !== undefined ? parent.level + 1 : 0, + changed: true, + content, + base64, + url: newUrl, + }); + + if (findEntry(parent.tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); + + commit(types.CREATE_TMP_FILE, { + parent, + file, + }); + commit(types.TOGGLE_FILE_OPEN, file); + dispatch('setFileActive', file); + + if (!state.editMode && !file.base64) { + dispatch('toggleEditMode', true); + } + + router.push(`/project${file.url}`); + + return Promise.resolve(file); +}; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js new file mode 100644 index 00000000000..75e332090cb --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -0,0 +1,25 @@ +import service from '../../services'; +import flash from '../../../flash'; +import * as types from '../mutation_types'; + +// eslint-disable-next-line import/prefer-default-export +export const getProjectData = ( + { commit, state, dispatch }, + { namespace, projectId, force = false } = {}, +) => new Promise((resolve, reject) => { + if (!state.projects[`${namespace}/${projectId}`] || force) { + service.getProjectData(namespace, projectId) + .then(res => res.data) + .then((data) => { + commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); + if (!state.currentProjectId) commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); + resolve(data); + }) + .catch(() => { + flash('Error loading project data. Please try again.'); + reject(new Error(`Project not loaded ${namespace}/${projectId}`)); + }); + } else { + resolve(state.projects[`${namespace}/${projectId}`]); + } +}); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js new file mode 100644 index 00000000000..25909400a75 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -0,0 +1,188 @@ +import { visitUrl } from '../../../lib/utils/url_utility'; +import { normalizeHeaders } from '../../../lib/utils/common_utils'; +import flash from '../../../flash'; +import service from '../../services'; +import * as types from '../mutation_types'; +import router from '../../ide_router'; +import { + setPageTitle, + findEntry, + createTemp, + createOrMergeEntry, +} from '../utils'; + +export const getTreeData = ( + { commit, state, dispatch }, + { endpoint, tree = null, projectId, branch, force = false } = {}, +) => new Promise((resolve, reject) => { + // We already have the base tree so we resolve immediately + if (!tree && state.trees[`${projectId}/${branch}`] && !force) { + resolve(); + } else { + if (tree) commit(types.TOGGLE_LOADING, tree); + const selectedProject = state.projects[projectId]; + // We are merging the web_url that we got on the project info with the endpoint + // we got on the tree entry, as both contain the projectId, we replace it in the tree endpoint + const completeEndpoint = selectedProject.web_url + (endpoint).replace(projectId, ''); + if (completeEndpoint && (!tree || !tree.tempFile)) { + service.getTreeData(completeEndpoint) + .then((res) => { + const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then((data) => { + if (!state.isInitialRoot) { + commit(types.SET_ROOT, data.path === '/'); + } + + dispatch('updateDirectoryData', { data, tree, projectId, branch }); + const selectedTree = tree || state.trees[`${projectId}/${branch}`]; + + commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); + commit(types.SET_LAST_COMMIT_URL, { tree: selectedTree, url: data.last_commit_path }); + if (tree) commit(types.TOGGLE_LOADING, selectedTree); + + const prevLastCommitPath = selectedTree.lastCommitPath; + if (prevLastCommitPath !== null) { + dispatch('getLastCommitData', selectedTree); + } + resolve(data); + }) + .catch((e) => { + flash('Error loading tree data. Please try again.'); + if (tree) commit(types.TOGGLE_LOADING, tree); + reject(e); + }); + } else { + resolve(); + } + } +}); + +export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { + if (tree.opened) { + // send empty data to clear the tree + const data = { trees: [], blobs: [], submodules: [] }; + + dispatch('updateDirectoryData', { data, tree, projectId: tree.projectId, branchId: tree.branchId }); + } else { + dispatch('getTreeData', { endpoint, tree, projectId: tree.projectId, branch: tree.branchId }); + } + + commit(types.TOGGLE_TREE_OPEN, tree); +}; + +export const handleTreeEntryAction = ({ commit, dispatch }, row) => { + if (row.type === 'tree') { + dispatch('toggleTreeOpen', { + endpoint: row.url, + tree: row, + }); + } else if (row.type === 'submodule') { + commit(types.TOGGLE_LOADING, row); + visitUrl(row.url); + } else if (row.type === 'blob' && row.opened) { + dispatch('setFileActive', row); + } else { + dispatch('getFileData', row); + } +}; + +export const createTempTree = ( + { state, commit, dispatch }, + { projectId, branchId, parent, name }, +) => { + let selectedTree = parent; + const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/'); + + dirNames.forEach((dirName) => { + const foundEntry = findEntry(selectedTree.tree, 'tree', dirName); + + if (!foundEntry) { + const path = selectedTree.path !== undefined ? selectedTree.path : ''; + const tmpEntry = createTemp({ + projectId, + branchId, + name: dirName, + path, + type: 'tree', + level: selectedTree.level !== undefined ? selectedTree.level + 1 : 0, + tree: [], + url: `/${projectId}/blob/${branchId}/${path}${path ? '/' : ''}${dirName}`, + }); + + commit(types.CREATE_TMP_TREE, { + parent: selectedTree, + tmpEntry, + }); + commit(types.TOGGLE_TREE_OPEN, tmpEntry); + + router.push(`/project${tmpEntry.url}`); + + selectedTree = tmpEntry; + } else { + selectedTree = foundEntry; + } + }); +}; + +export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { + if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; + + service.getTreeLastCommit(tree.lastCommitPath) + .then((res) => { + const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; + + commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); + + return res.json(); + }) + .then((data) => { + data.forEach((lastCommit) => { + const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); + + if (entry) { + commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); + } + }); + + dispatch('getLastCommitData', tree); + }) + .catch(() => flash('Error fetching log data.')); +}; + +export const updateDirectoryData = ( + { commit, state }, + { data, tree, projectId, branch }, +) => { + if (!tree) { + const existingTree = state.trees[`${projectId}/${branch}`]; + if (!existingTree) { + commit(types.CREATE_TREE, { treePath: `${projectId}/${branch}` }); + } + } + + const selectedTree = tree || state.trees[`${projectId}/${branch}`]; + const level = selectedTree.level !== undefined ? selectedTree.level + 1 : 0; + const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; + const createEntry = (entry, type) => createOrMergeEntry({ + tree: selectedTree, + projectId: `${projectId}`, + branchId: branch, + entry, + level, + type, + parentTreeUrl, + }); + + const formattedData = [ + ...data.trees.map(t => createEntry(t, 'tree')), + ...data.submodules.map(m => createEntry(m, 'submodule')), + ...data.blobs.map(b => createEntry(b, 'blob')), + ]; + + commit(types.SET_DIRECTORY_DATA, { tree: selectedTree, data: formattedData }); +}; diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js new file mode 100644 index 00000000000..6b51ccff817 --- /dev/null +++ b/app/assets/javascripts/ide/stores/getters.js @@ -0,0 +1,19 @@ +export const changedFiles = state => state.openFiles.filter(file => file.changed); + +export const activeFile = state => state.openFiles.find(file => file.active) || null; + +export const activeFileExtension = (state) => { + const file = activeFile(state); + return file ? `.${file.path.split('.').pop()}` : ''; +}; + +export const canEditFile = (state) => { + const currentActiveFile = activeFile(state); + + return state.canCommit && + (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); +}; + +export const addedFiles = state => changedFiles(state).filter(f => f.tempFile); + +export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile); diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js new file mode 100644 index 00000000000..6ac9bfd8189 --- /dev/null +++ b/app/assets/javascripts/ide/stores/index.js @@ -0,0 +1,15 @@ +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'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: state(), + actions, + mutations, + getters, +}); 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..4e3c10972ba --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -0,0 +1,45 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; +export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL'; +export const SET_ROOT = 'SET_ROOT'; +export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; +export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; +export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED'; + +// Project Mutation Types +export const SET_PROJECT = 'SET_PROJECT'; +export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; +export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; + +// Branch Mutation Types +export const SET_BRANCH = 'SET_BRANCH'; +export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; +export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; + +// Tree mutation types +export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; +export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; +export const CREATE_TMP_TREE = 'CREATE_TMP_TREE'; +export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; +export const CREATE_TREE = 'CREATE_TREE'; + +// File mutation types +export const SET_FILE_DATA = 'SET_FILE_DATA'; +export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; +export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; +export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; +export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; +export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; +export const SET_FILE_POSITION = 'SET_FILE_POSITION'; +export const SET_FILE_EOL = 'SET_FILE_EOL'; +export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; +export const CREATE_TMP_FILE = 'CREATE_TMP_FILE'; + +// Viewer mutation types +export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE'; +export const SET_EDIT_MODE = 'SET_EDIT_MODE'; +export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE'; +export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP'; + +export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; + diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js new file mode 100644 index 00000000000..2fed9019cb6 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -0,0 +1,65 @@ +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.SET_PREVIEW_MODE](state) { + Object.assign(state, { + currentBlobView: 'repo-preview', + }); + }, + [types.SET_EDIT_MODE](state) { + Object.assign(state, { + currentBlobView: 'repo-editor', + }); + }, + [types.TOGGLE_LOADING](state, entry) { + Object.assign(entry, { + loading: !entry.loading, + }); + }, + [types.TOGGLE_EDIT_MODE](state) { + Object.assign(state, { + editMode: !state.editMode, + }); + }, + [types.TOGGLE_DISCARD_POPUP](state, discardPopupOpen) { + Object.assign(state, { + discardPopupOpen, + }); + }, + [types.SET_ROOT](state, isRoot) { + Object.assign(state, { + isRoot, + isInitialRoot: isRoot, + }); + }, + [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_LAST_COMMIT_DATA](state, { entry, lastCommit }) { + Object.assign(entry.lastCommit, { + id: lastCommit.commit.id, + url: lastCommit.commit_path, + message: lastCommit.commit.message, + author: lastCommit.commit.author_name, + updatedAt: lastCommit.commit.authored_date, + }); + }, + ...projectMutations, + ...fileMutations, + ...treeMutations, + ...branchMutations, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js new file mode 100644 index 00000000000..04b9582c5bb --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/branch.js @@ -0,0 +1,28 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_BRANCH](state, currentBranchId) { + Object.assign(state, { + currentBranchId, + }); + }, + [types.SET_BRANCH](state, { projectPath, branchName, branch }) { + // Add client side properties + Object.assign(branch, { + treeId: `${projectPath}/${branchName}`, + active: true, + workingReference: '', + }); + + Object.assign(state.projects[projectPath], { + branches: { + [branchName]: branch, + }, + }); + }, + [types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) { + Object.assign(state.projects[projectId].branches[branchId], { + workingReference: reference, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js new file mode 100644 index 00000000000..5f3655b0092 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -0,0 +1,74 @@ +import * as types from '../mutation_types'; +import { findIndexOfFile } from '../utils'; + +export default { + [types.SET_FILE_ACTIVE](state, { file, active }) { + Object.assign(file, { + active, + }); + + Object.assign(state, { + selectedFile: file, + }); + }, + [types.TOGGLE_FILE_OPEN](state, file) { + Object.assign(file, { + opened: !file.opened, + }); + + if (file.opened) { + state.openFiles.push(file); + } else { + state.openFiles.splice(findIndexOfFile(state.openFiles, file), 1); + } + }, + [types.SET_FILE_DATA](state, { data, file }) { + Object.assign(file, { + blamePath: data.blame_path, + commitsPath: data.commits_path, + permalink: data.permalink, + rawPath: data.raw_path, + binary: data.binary, + html: data.html, + renderError: data.render_error, + }); + }, + [types.SET_FILE_RAW_DATA](state, { file, raw }) { + Object.assign(file, { + raw, + }); + }, + [types.UPDATE_FILE_CONTENT](state, { file, content }) { + const changed = content !== file.raw; + + Object.assign(file, { + content, + changed, + }); + }, + [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) { + Object.assign(file, { + fileLanguage, + }); + }, + [types.SET_FILE_EOL](state, { file, eol }) { + Object.assign(file, { + eol, + }); + }, + [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) { + Object.assign(file, { + editorRow, + editorColumn, + }); + }, + [types.DISCARD_FILE_CHANGES](state, file) { + Object.assign(file, { + content: '', + changed: false, + }); + }, + [types.CREATE_TMP_FILE](state, { file, parent }) { + parent.tree.push(file); + }, +}; 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..4fe438ab465 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/tree.js @@ -0,0 +1,36 @@ +import * as types from '../mutation_types'; + +export default { + [types.TOGGLE_TREE_OPEN](state, tree) { + Object.assign(tree, { + opened: !tree.opened, + }); + }, + [types.CREATE_TREE](state, { treePath }) { + Object.assign(state, { + trees: Object.assign({}, state.trees, { + [treePath]: { + tree: [], + }, + }), + }); + }, + [types.SET_DIRECTORY_DATA](state, { data, tree }) { + Object.assign(tree, { + tree: data, + }); + }, + [types.SET_PARENT_TREE_URL](state, url) { + Object.assign(state, { + parentTreeUrl: url, + }); + }, + [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { + Object.assign(tree, { + lastCommitPath: url, + }); + }, + [types.CREATE_TMP_TREE](state, { parent, tmpEntry }) { + parent.tree.push(tmpEntry); + }, +}; diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js new file mode 100644 index 00000000000..539e382830f --- /dev/null +++ b/app/assets/javascripts/ide/stores/state.js @@ -0,0 +1,22 @@ +export default () => ({ + canCommit: false, + currentProjectId: '', + currentBranchId: '', + currentBlobView: 'repo-editor', + discardPopupOpen: false, + editMode: true, + endpoints: {}, + isRoot: false, + isInitialRoot: false, + lastCommitPath: '', + loading: false, + onTopOfBranch: false, + openFiles: [], + selectedFile: null, + path: '', + parentTreeUrl: '', + trees: {}, + projects: {}, + leftPanelCollapsed: false, + rightPanelCollapsed: true, +}); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js new file mode 100644 index 00000000000..29e3ab5d040 --- /dev/null +++ b/app/assets/javascripts/ide/stores/utils.js @@ -0,0 +1,175 @@ +export const dataStructure = () => ({ + id: '', + key: '', + type: '', + projectId: '', + branchId: '', + name: '', + url: '', + path: '', + level: 0, + tempFile: false, + icon: '', + tree: [], + loading: false, + opened: false, + active: false, + changed: false, + lastCommitPath: '', + lastCommit: { + id: '', + url: '', + message: '', + updatedAt: '', + author: '', + }, + tree_url: '', + 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, + icon, + tree_url, + path, + renderError, + content = '', + tempFile = false, + active = false, + opened = false, + changed = false, + parentTreeUrl = '', + level = 0, + base64 = false, + } = entity; + + return { + ...dataStructure(), + id, + projectId, + branchId, + key: `${name}-${type}-${id}`, + type, + name, + url, + tree_url, + path, + level, + tempFile, + icon: `fa-${icon}`, + opened, + active, + parentTreeUrl, + changed, + renderError, + content, + base64, + }; +}; + +/* + Takes the multi-dimensional tree and returns a flattened array. + This allows for the table to recursively render the table rows but keeps the data + structure nested to make it easier to add new files/directories. +*/ +export const treeList = (state, treeId) => { + const baseTree = state.trees[treeId]; + if (baseTree) { + const mapTree = arr => (!arr.tree || !arr.tree.length ? + [] : _.map(arr.tree, a => [a, mapTree(a)])); + + return _.chain(baseTree.tree) + .map(arr => [arr, mapTree(arr)]) + .flatten() + .value(); + } + return []; +}; + +export const getTree = state => (namespace, projectId, branch) => state.trees[`${namespace}/${projectId}/${branch}`]; + +export const getTreeEntry = (store, treeId, path) => { + const fileList = treeList(store.state, treeId); + return fileList ? fileList.find(file => file.path === path) : null; +}; + +export const findEntry = (tree, type, name) => tree.find( + f => f.type === type && f.name === name, +); + +export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); + +export const setPageTitle = (title) => { + document.title = title; +}; + +export const createTemp = ({ + projectId, branchId, name, path, type, level, changed, content, base64, url, +}) => { + const treePath = path ? `${path}/${name}` : name; + + return decorateData({ + id: new Date().getTime().toString(), + projectId, + branchId, + name, + type, + tempFile: true, + path: treePath, + icon: type === 'tree' ? 'folder' : 'file-text-o', + changed, + content, + parentTreeUrl: '', + level, + base64, + renderError: base64, + url, + }); +}; + +export const createOrMergeEntry = ({ tree, + projectId, + branchId, + entry, + type, + parentTreeUrl, + level }) => { + const found = findEntry(tree.tree || tree, type, entry.name); + + if (found) { + return Object.assign({}, found, { + id: entry.id, + url: entry.url, + tempFile: false, + }); + } + + return decorateData({ + ...entry, + projectId, + branchId, + type, + parentTreeUrl, + level, + }); +}; diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index 6e152497d20..a2f0a44863f 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -6,11 +6,12 @@ export default class NewCommitForm { this.branchName = form.find('.js-branch-name'); this.originalBranch = form.find('.js-original-branch'); this.createMergeRequest = form.find('.js-create-merge-request'); - this.createMergeRequestContainer = form.find('.js-create-merge-request-container'); + this.createMergeRequestContainer = form.find( + '.js-create-merge-request-container', + ); this.branchName.keyup(this.renderDestination); this.renderDestination(); } - renderDestination() { var different; different = this.branchName.val() !== this.originalBranch.val(); @@ -23,6 +24,6 @@ export default class NewCommitForm { this.createMergeRequestContainer.hide(); this.createMergeRequest.prop('checked', false); } - return this.wasDifferent = different; + return (this.wasDifferent = different); } } diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list.vue b/app/assets/javascripts/repo/components/commit_sidebar/list.vue deleted file mode 100644 index fb862e7bf01..00000000000 --- a/app/assets/javascripts/repo/components/commit_sidebar/list.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue deleted file mode 100644 index 6a0262f271b..00000000000 --- a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue b/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue deleted file mode 100644 index 742f746e02f..00000000000 --- a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/new_branch_form.vue b/app/assets/javascripts/repo/components/new_branch_form.vue deleted file mode 100644 index ba7090e4a9d..00000000000 --- a/app/assets/javascripts/repo/components/new_branch_form.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/new_dropdown/index.vue b/app/assets/javascripts/repo/components/new_dropdown/index.vue deleted file mode 100644 index 781404cf8ca..00000000000 --- a/app/assets/javascripts/repo/components/new_dropdown/index.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/new_dropdown/modal.vue b/app/assets/javascripts/repo/components/new_dropdown/modal.vue deleted file mode 100644 index c191af7dec3..00000000000 --- a/app/assets/javascripts/repo/components/new_dropdown/modal.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/new_dropdown/upload.vue b/app/assets/javascripts/repo/components/new_dropdown/upload.vue deleted file mode 100644 index 14ad32f4ae0..00000000000 --- a/app/assets/javascripts/repo/components/new_dropdown/upload.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue deleted file mode 100644 index a00e1e9d809..00000000000 --- a/app/assets/javascripts/repo/components/repo.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue deleted file mode 100644 index 4e0178072cb..00000000000 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ /dev/null @@ -1,178 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue deleted file mode 100644 index 37bd9003e96..00000000000 --- a/app/assets/javascripts/repo/components/repo_edit_button.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue deleted file mode 100644 index f37cbd1e961..00000000000 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue deleted file mode 100644 index 75787ad6103..00000000000 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue deleted file mode 100644 index 34f0d51819a..00000000000 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue deleted file mode 100644 index 8fa637d771f..00000000000 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue deleted file mode 100644 index a2b305bbd05..00000000000 --- a/app/assets/javascripts/repo/components/repo_prev_directory.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue deleted file mode 100644 index 3d1e0297bd5..00000000000 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue deleted file mode 100644 index 4ea21913129..00000000000 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue deleted file mode 100644 index fb29a60df66..00000000000 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ /dev/null @@ -1,67 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue deleted file mode 100644 index ab0bef4f0ac..00000000000 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js deleted file mode 100644 index b6801af7fcb..00000000000 --- a/app/assets/javascripts/repo/index.js +++ /dev/null @@ -1,106 +0,0 @@ -import Vue from 'vue'; -import { mapActions } from 'vuex'; -import { convertPermissionToBoolean } from '../lib/utils/common_utils'; -import Repo from './components/repo.vue'; -import RepoEditButton from './components/repo_edit_button.vue'; -import newBranchForm from './components/new_branch_form.vue'; -import newDropdown from './components/new_dropdown/index.vue'; -import store from './stores'; -import Translate from '../vue_shared/translate'; - -function initRepo(el) { - if (!el) return null; - - return new Vue({ - el, - store, - components: { - repo: Repo, - }, - methods: { - ...mapActions([ - 'setInitialData', - ]), - }, - created() { - const data = el.dataset; - - this.setInitialData({ - project: { - id: data.projectId, - name: data.projectName, - url: data.projectUrl, - }, - endpoints: { - rootEndpoint: data.url, - newMergeRequestUrl: data.newMergeRequestUrl, - rootUrl: data.rootUrl, - }, - canCommit: convertPermissionToBoolean(data.canCommit), - onTopOfBranch: convertPermissionToBoolean(data.onTopOfBranch), - currentRef: data.ref, - path: data.currentPath, - currentBranch: data.currentBranch, - isRoot: convertPermissionToBoolean(data.root), - isInitialRoot: convertPermissionToBoolean(data.root), - }); - }, - render(createElement) { - return createElement('repo'); - }, - }); -} - -function initRepoEditButton(el) { - return new Vue({ - el, - store, - components: { - repoEditButton: RepoEditButton, - }, - render(createElement) { - return createElement('repo-edit-button'); - }, - }); -} - -function initNewDropdown(el) { - return new Vue({ - el, - store, - components: { - newDropdown, - }, - render(createElement) { - return createElement('new-dropdown'); - }, - }); -} - -function initNewBranchForm() { - const el = document.querySelector('.js-new-branch-dropdown'); - - if (!el) return null; - - return new Vue({ - el, - components: { - newBranchForm, - }, - store, - render(createElement) { - return createElement('new-branch-form'); - }, - }); -} - -const repo = document.getElementById('repo'); -const editButton = document.querySelector('.editable-mode'); -const newDropdownHolder = document.querySelector('.js-new-dropdown'); - -Vue.use(Translate); - -initRepo(repo); -initRepoEditButton(editButton); -initNewBranchForm(); -initNewDropdown(newDropdownHolder); diff --git a/app/assets/javascripts/repo/lib/common/disposable.js b/app/assets/javascripts/repo/lib/common/disposable.js deleted file mode 100644 index 84b29bdb600..00000000000 --- a/app/assets/javascripts/repo/lib/common/disposable.js +++ /dev/null @@ -1,14 +0,0 @@ -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/repo/lib/common/model.js b/app/assets/javascripts/repo/lib/common/model.js deleted file mode 100644 index 23c4811e6c0..00000000000 --- a/app/assets/javascripts/repo/lib/common/model.js +++ /dev/null @@ -1,56 +0,0 @@ -/* global monaco */ -import Disposable from './disposable'; - -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(); - } - - get url() { - return this.model.uri.toString(); - } - - get path() { - return this.file.path; - } - - getModel() { - return this.model; - } - - getOriginalModel() { - return this.originalModel; - } - - onChange(cb) { - this.events.set( - this.path, - this.disposable.add( - this.model.onDidChangeContent(e => cb(this.model, e)), - ), - ); - } - - dispose() { - this.disposable.dispose(); - this.events.clear(); - } -} diff --git a/app/assets/javascripts/repo/lib/common/model_manager.js b/app/assets/javascripts/repo/lib/common/model_manager.js deleted file mode 100644 index fd462252795..00000000000 --- a/app/assets/javascripts/repo/lib/common/model_manager.js +++ /dev/null @@ -1,32 +0,0 @@ -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); - } - - addModel(file) { - if (this.hasCachedModel(file.path)) { - return this.models.get(file.path); - } - - const model = new Model(this.monaco, file); - this.models.set(model.path, model); - this.disposable.add(model); - - return model; - } - - dispose() { - // dispose of all the models - this.disposable.dispose(); - this.models.clear(); - } -} diff --git a/app/assets/javascripts/repo/lib/decorations/controller.js b/app/assets/javascripts/repo/lib/decorations/controller.js deleted file mode 100644 index 0954b7973c4..00000000000 --- a/app/assets/javascripts/repo/lib/decorations/controller.js +++ /dev/null @@ -1,43 +0,0 @@ -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) { - 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/repo/lib/diff/controller.js b/app/assets/javascripts/repo/lib/diff/controller.js deleted file mode 100644 index dc0b1c95e59..00000000000 --- a/app/assets/javascripts/repo/lib/diff/controller.js +++ /dev/null @@ -1,71 +0,0 @@ -/* 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)); - this.decorationsController.addDecorations(data.path, 'dirtyDiff', decorations); - } - - dispose() { - this.disposable.dispose(); - - this.dirtyDiffWorker.removeEventListener('message', this.decorate); - this.dirtyDiffWorker.terminate(); - } -} diff --git a/app/assets/javascripts/repo/lib/diff/diff.js b/app/assets/javascripts/repo/lib/diff/diff.js deleted file mode 100644 index 0e37f5c4704..00000000000 --- a/app/assets/javascripts/repo/lib/diff/diff.js +++ /dev/null @@ -1,30 +0,0 @@ -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/repo/lib/diff/diff_worker.js b/app/assets/javascripts/repo/lib/diff/diff_worker.js deleted file mode 100644 index e74c4046330..00000000000 --- a/app/assets/javascripts/repo/lib/diff/diff_worker.js +++ /dev/null @@ -1,10 +0,0 @@ -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/repo/lib/editor.js b/app/assets/javascripts/repo/lib/editor.js deleted file mode 100644 index db499444402..00000000000 --- a/app/assets/javascripts/repo/lib/editor.js +++ /dev/null @@ -1,79 +0,0 @@ -import DecorationsController from './decorations/controller'; -import DirtyDiffController from './diff/controller'; -import Disposable from './common/disposable'; -import ModelManager from './common/model_manager'; -import editorOptions from './editor_options'; - -export default class Editor { - static create(monaco) { - 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.disposable.add( - this.modelManager = new ModelManager(this.monaco), - this.decorationsController = new DecorationsController(this), - ); - } - - createInstance(domElement) { - if (!this.instance) { - this.disposable.add( - this.instance = this.monaco.editor.create(domElement, { - model: null, - readOnly: false, - contextmenu: true, - scrollBeyondLastLine: false, - }), - this.dirtyDiffController = new DirtyDiffController( - this.modelManager, this.decorationsController, - ), - ); - } - } - - createModel(file) { - return this.modelManager.addModel(file); - } - - attachModel(model) { - this.instance.setModel(model.getModel()); - 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; - }, {})); - - this.dirtyDiffController.reDecorate(model); - } - - clearEditor() { - if (this.instance) { - this.instance.setModel(null); - } - } - - dispose() { - this.disposable.dispose(); - - // dispose main monaco instance - if (this.instance) { - this.instance = null; - } - } -} diff --git a/app/assets/javascripts/repo/lib/editor_options.js b/app/assets/javascripts/repo/lib/editor_options.js deleted file mode 100644 index 701affc466e..00000000000 --- a/app/assets/javascripts/repo/lib/editor_options.js +++ /dev/null @@ -1,2 +0,0 @@ -export default [{ -}]; diff --git a/app/assets/javascripts/repo/monaco_loader.js b/app/assets/javascripts/repo/monaco_loader.js deleted file mode 100644 index af83a1ec0b4..00000000000 --- a/app/assets/javascripts/repo/monaco_loader.js +++ /dev/null @@ -1,11 +0,0 @@ -import monacoContext from 'monaco-editor/dev/vs/loader'; - -monacoContext.require.config({ - paths: { - vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase - }, -}); - -// eslint-disable-next-line no-underscore-dangle -window.__monaco_context__ = monacoContext; -export default monacoContext.require; diff --git a/app/assets/javascripts/repo/services/index.js b/app/assets/javascripts/repo/services/index.js deleted file mode 100644 index 994d325e991..00000000000 --- a/app/assets/javascripts/repo/services/index.js +++ /dev/null @@ -1,44 +0,0 @@ -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()); - }, - getBranchData(projectId, currentBranch) { - return Api.branchSingle(projectId, currentBranch); - }, - 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', - }, - }); - }, -}; diff --git a/app/assets/javascripts/repo/stores/actions.js b/app/assets/javascripts/repo/stores/actions.js deleted file mode 100644 index af5dcf054ef..00000000000 --- a/app/assets/javascripts/repo/stores/actions.js +++ /dev/null @@ -1,146 +0,0 @@ -import Vue from 'vue'; -import { visitUrl } from '../../lib/utils/url_utility'; -import flash from '../../flash'; -import service from '../services'; -import * as types from './mutation_types'; - -export const redirectToUrl = (_, url) => visitUrl(url); - -export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); - -export const closeDiscardPopup = ({ commit }) => commit(types.TOGGLE_DISCARD_POPUP, false); - -export const discardAllChanges = ({ commit, getters, dispatch }) => { - const changedFiles = getters.changedFiles; - - changedFiles.forEach((file) => { - commit(types.DISCARD_FILE_CHANGES, file); - - if (file.tempFile) { - dispatch('closeFile', { file, force: true }); - } - }); -}; - -export const closeAllFiles = ({ state, dispatch }) => { - state.openFiles.forEach(file => dispatch('closeFile', { file })); -}; - -export const toggleEditMode = ({ state, commit, getters, dispatch }, force = false) => { - const changedFiles = getters.changedFiles; - - if (changedFiles.length && !force) { - commit(types.TOGGLE_DISCARD_POPUP, true); - } else { - commit(types.TOGGLE_EDIT_MODE); - commit(types.TOGGLE_DISCARD_POPUP, false); - dispatch('toggleBlobView'); - - if (!state.editMode) { - dispatch('discardAllChanges'); - } - } -}; - -export const toggleBlobView = ({ commit, state }) => { - if (state.editMode) { - commit(types.SET_EDIT_MODE); - } else { - commit(types.SET_PREVIEW_MODE); - } -}; - -export const checkCommitStatus = ({ state }) => service.getBranchData( - state.project.id, - state.currentBranch, -) - .then((data) => { - const { id } = data.commit; - - if (state.currentRef !== id) { - return true; - } - - return false; - }) - .catch(() => flash('Error checking branch data. Please try again.')); - -export const commitChanges = ({ commit, state, dispatch, getters }, { payload, newMr }) => - service.commit(state.project.id, payload) - .then((data) => { - const { branch } = payload; - if (!data.short_id) { - flash(data.message); - return; - } - - const lastCommit = { - commit_path: `${state.project.url}/commit/${data.id}`, - commit: { - message: data.message, - authored_date: data.committed_date, - }, - }; - - flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); - - if (newMr) { - dispatch('redirectToUrl', `${state.endpoints.newMergeRequestUrl}${branch}`); - } else { - commit(types.SET_COMMIT_REF, data.id); - - getters.changedFiles.forEach((entry) => { - commit(types.SET_LAST_COMMIT_DATA, { - entry, - lastCommit, - }); - }); - - dispatch('discardAllChanges'); - dispatch('closeAllFiles'); - dispatch('toggleEditMode'); - - window.scrollTo(0, 0); - } - }) - .catch(() => flash('Error committing changes. Please try again.')); - -export const createTempEntry = ({ state, dispatch }, { name, type, content = '', base64 = false }) => { - if (type === 'tree') { - dispatch('createTempTree', name); - } else if (type === 'blob') { - dispatch('createTempFile', { - tree: state, - name, - base64, - content, - }); - } -}; - -export const popHistoryState = ({ state, dispatch, getters }) => { - const treeList = getters.treeList; - const tree = treeList.find(file => file.url === state.previousUrl); - - if (!tree) return; - - if (tree.type === 'tree') { - dispatch('toggleTreeOpen', { endpoint: tree.url, tree }); - } -}; - -export const scrollToTab = () => { - Vue.nextTick(() => { - const tabs = document.getElementById('tabs'); - - if (tabs) { - const tabEl = tabs.querySelector('.active .repo-tab'); - - tabEl.focus(); - } - }); -}; - -export * from './actions/tree'; -export * from './actions/file'; -export * from './actions/branch'; diff --git a/app/assets/javascripts/repo/stores/actions/branch.js b/app/assets/javascripts/repo/stores/actions/branch.js deleted file mode 100644 index 61d9a5af3e3..00000000000 --- a/app/assets/javascripts/repo/stores/actions/branch.js +++ /dev/null @@ -1,20 +0,0 @@ -import service from '../../services'; -import * as types from '../mutation_types'; -import { pushState } from '../utils'; - -// eslint-disable-next-line import/prefer-default-export -export const createNewBranch = ({ state, commit }, branch) => service.createBranch( - state.project.id, - { - branch, - ref: state.currentBranch, - }, -).then(res => res.json()) -.then((data) => { - const branchName = data.name; - const url = location.href.replace(state.currentBranch, branchName); - - pushState(url); - - commit(types.SET_CURRENT_BRANCH, branchName); -}); diff --git a/app/assets/javascripts/repo/stores/actions/file.js b/app/assets/javascripts/repo/stores/actions/file.js deleted file mode 100644 index 5bae4fa826a..00000000000 --- a/app/assets/javascripts/repo/stores/actions/file.js +++ /dev/null @@ -1,110 +0,0 @@ -import { normalizeHeaders } from '../../../lib/utils/common_utils'; -import flash from '../../../flash'; -import service from '../../services'; -import * as types from '../mutation_types'; -import { - findEntry, - pushState, - setPageTitle, - createTemp, - findIndexOfFile, -} from '../utils'; - -export const closeFile = ({ commit, state, dispatch }, { file, force = false }) => { - if ((file.changed || file.tempFile) && !force) return; - - const indexOfClosedFile = findIndexOfFile(state.openFiles, file); - const fileWasActive = file.active; - - commit(types.TOGGLE_FILE_OPEN, file); - commit(types.SET_FILE_ACTIVE, { file, active: false }); - - if (state.openFiles.length > 0 && fileWasActive) { - const nextIndexToOpen = indexOfClosedFile === 0 ? 0 : indexOfClosedFile - 1; - const nextFileToOpen = state.openFiles[nextIndexToOpen]; - - dispatch('setFileActive', nextFileToOpen); - } else if (!state.openFiles.length) { - pushState(file.parentTreeUrl); - } - - dispatch('getLastCommitData'); -}; - -export const setFileActive = ({ commit, state, getters, dispatch }, file) => { - const currentActiveFile = getters.activeFile; - - if (file.active) return; - - if (currentActiveFile) { - commit(types.SET_FILE_ACTIVE, { file: currentActiveFile, active: false }); - } - - commit(types.SET_FILE_ACTIVE, { file, active: true }); - dispatch('scrollToTab'); - - // reset hash for line highlighting - location.hash = ''; -}; - -export const getFileData = ({ state, commit, dispatch }, file) => { - commit(types.TOGGLE_LOADING, file); - - 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); - dispatch('setFileActive', file); - commit(types.TOGGLE_LOADING, file); - - pushState(file.url); - }) - .catch(() => { - commit(types.TOGGLE_LOADING, file); - flash('Error loading file data. Please try again.'); - }); -}; - -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.')); - -export const changeFileContent = ({ commit }, { file, content }) => { - commit(types.UPDATE_FILE_CONTENT, { file, content }); -}; - -export const createTempFile = ({ state, commit, dispatch }, { tree, name, content = '', base64 = '' }) => { - const file = createTemp({ - name: name.replace(`${state.path}/`, ''), - path: tree.path, - type: 'blob', - level: tree.level !== undefined ? tree.level + 1 : 0, - changed: true, - content, - base64, - }); - - if (findEntry(tree, 'blob', file.name)) return flash(`The name "${file.name}" is already taken in this directory.`); - - commit(types.CREATE_TMP_FILE, { - parent: tree, - file, - }); - commit(types.TOGGLE_FILE_OPEN, file); - dispatch('setFileActive', file); - - if (!state.editMode && !file.base64) { - dispatch('toggleEditMode', true); - } - - return Promise.resolve(file); -}; diff --git a/app/assets/javascripts/repo/stores/actions/tree.js b/app/assets/javascripts/repo/stores/actions/tree.js deleted file mode 100644 index 7c251e26bed..00000000000 --- a/app/assets/javascripts/repo/stores/actions/tree.js +++ /dev/null @@ -1,163 +0,0 @@ -import { visitUrl } from '../../../lib/utils/url_utility'; -import { normalizeHeaders } from '../../../lib/utils/common_utils'; -import flash from '../../../flash'; -import service from '../../services'; -import * as types from '../mutation_types'; -import { - pushState, - setPageTitle, - findEntry, - createTemp, - createOrMergeEntry, -} from '../utils'; - -export const getTreeData = ( - { commit, state, dispatch }, - { endpoint = state.endpoints.rootEndpoint, tree = state } = {}, -) => { - commit(types.TOGGLE_LOADING, tree); - - service.getTreeData(endpoint) - .then((res) => { - const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - - setPageTitle(pageTitle); - - return res.json(); - }) - .then((data) => { - const prevLastCommitPath = tree.lastCommitPath; - if (!state.isInitialRoot) { - commit(types.SET_ROOT, data.path === '/'); - } - - dispatch('updateDirectoryData', { data, tree }); - commit(types.SET_PARENT_TREE_URL, data.parent_tree_url); - commit(types.SET_LAST_COMMIT_URL, { tree, url: data.last_commit_path }); - commit(types.TOGGLE_LOADING, tree); - - if (prevLastCommitPath !== null) { - dispatch('getLastCommitData', tree); - } - - pushState(endpoint); - }) - .catch(() => { - flash('Error loading tree data. Please try again.'); - commit(types.TOGGLE_LOADING, tree); - }); -}; - -export const toggleTreeOpen = ({ commit, dispatch }, { endpoint, tree }) => { - if (tree.opened) { - // send empty data to clear the tree - const data = { trees: [], blobs: [], submodules: [] }; - - pushState(tree.parentTreeUrl); - - commit(types.SET_PREVIOUS_URL, tree.parentTreeUrl); - dispatch('updateDirectoryData', { data, tree }); - } else { - commit(types.SET_PREVIOUS_URL, endpoint); - dispatch('getTreeData', { endpoint, tree }); - } - - commit(types.TOGGLE_TREE_OPEN, tree); -}; - -export const clickedTreeRow = ({ commit, dispatch }, row) => { - if (row.type === 'tree') { - dispatch('toggleTreeOpen', { - endpoint: row.url, - tree: row, - }); - } else if (row.type === 'submodule') { - commit(types.TOGGLE_LOADING, row); - - visitUrl(row.url); - } else if (row.type === 'blob' && row.opened) { - dispatch('setFileActive', row); - } else { - dispatch('getFileData', row); - } -}; - -export const createTempTree = ({ state, commit, dispatch }, name) => { - let tree = state; - const dirNames = name.replace(new RegExp(`^${state.path}/`), '').split('/'); - - dirNames.forEach((dirName) => { - const foundEntry = findEntry(tree, 'tree', dirName); - - if (!foundEntry) { - const tmpEntry = createTemp({ - name: dirName, - path: tree.path, - type: 'tree', - level: tree.level !== undefined ? tree.level + 1 : 0, - }); - - commit(types.CREATE_TMP_TREE, { - parent: tree, - tmpEntry, - }); - commit(types.TOGGLE_TREE_OPEN, tmpEntry); - - tree = tmpEntry; - } else { - tree = foundEntry; - } - }); - - if (tree.tempFile) { - dispatch('createTempFile', { - tree, - name: '.gitkeep', - }); - } -}; - -export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { - if (tree.lastCommitPath === null || getters.isCollapsed) return; - - service.getTreeLastCommit(tree.lastCommitPath) - .then((res) => { - const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; - - commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); - - return res.json(); - }) - .then((data) => { - data.forEach((lastCommit) => { - const entry = findEntry(tree, lastCommit.type, lastCommit.file_name); - - if (entry) { - commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); - } - }); - - dispatch('getLastCommitData', tree); - }) - .catch(() => flash('Error fetching log data.')); -}; - -export const updateDirectoryData = ({ commit, state }, { data, tree }) => { - const level = tree.level !== undefined ? tree.level + 1 : 0; - const parentTreeUrl = data.parent_tree_url ? `${data.parent_tree_url}${data.path}` : state.endpoints.rootUrl; - const createEntry = (entry, type) => createOrMergeEntry({ - tree, - entry, - level, - type, - parentTreeUrl, - }); - - const formattedData = [ - ...data.trees.map(t => createEntry(t, 'tree')), - ...data.submodules.map(m => createEntry(m, 'submodule')), - ...data.blobs.map(b => createEntry(b, 'blob')), - ]; - - commit(types.SET_DIRECTORY_DATA, { tree, data: formattedData }); -}; diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js deleted file mode 100644 index 5ce9f449905..00000000000 --- a/app/assets/javascripts/repo/stores/getters.js +++ /dev/null @@ -1,40 +0,0 @@ -import _ from 'underscore'; - -/* - Takes the multi-dimensional tree and returns a flattened array. - This allows for the table to recursively render the table rows but keeps the data - structure nested to make it easier to add new files/directories. -*/ -export const treeList = (state) => { - const mapTree = arr => (!arr.tree.length ? [] : _.map(arr.tree, a => [a, mapTree(a)])); - - return _.chain(state.tree) - .map(arr => [arr, mapTree(arr)]) - .flatten() - .value(); -}; - -export const changedFiles = state => state.openFiles.filter(file => file.changed); - -export const activeFile = state => state.openFiles.find(file => file.active); - -export const activeFileExtension = (state) => { - const file = activeFile(state); - return file ? `.${file.path.split('.').pop()}` : ''; -}; - -export const isCollapsed = state => !!state.openFiles.length; - -export const canEditFile = (state) => { - const currentActiveFile = activeFile(state); - const openedFiles = state.openFiles; - - return state.canCommit && - state.onTopOfBranch && - openedFiles.length && - (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); -}; - -export const addedFiles = state => changedFiles(state).filter(f => f.tempFile); - -export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile); diff --git a/app/assets/javascripts/repo/stores/index.js b/app/assets/javascripts/repo/stores/index.js deleted file mode 100644 index 6ac9bfd8189..00000000000 --- a/app/assets/javascripts/repo/stores/index.js +++ /dev/null @@ -1,15 +0,0 @@ -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'; - -Vue.use(Vuex); - -export default new Vuex.Store({ - state: state(), - actions, - mutations, - getters, -}); diff --git a/app/assets/javascripts/repo/stores/mutation_types.js b/app/assets/javascripts/repo/stores/mutation_types.js deleted file mode 100644 index bc3390f1506..00000000000 --- a/app/assets/javascripts/repo/stores/mutation_types.js +++ /dev/null @@ -1,30 +0,0 @@ -export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; -export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_COMMIT_REF = 'SET_COMMIT_REF'; -export const SET_PARENT_TREE_URL = 'SET_PARENT_TREE_URL'; -export const SET_ROOT = 'SET_ROOT'; -export const SET_PREVIOUS_URL = 'SET_PREVIOUS_URL'; -export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA'; - -// Tree mutation types -export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; -export const TOGGLE_TREE_OPEN = 'TOGGLE_TREE_OPEN'; -export const CREATE_TMP_TREE = 'CREATE_TMP_TREE'; -export const SET_LAST_COMMIT_URL = 'SET_LAST_COMMIT_URL'; - -// 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 DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; -export const CREATE_TMP_FILE = 'CREATE_TMP_FILE'; - -// Viewer mutation types -export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE'; -export const SET_EDIT_MODE = 'SET_EDIT_MODE'; -export const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE'; -export const TOGGLE_DISCARD_POPUP = 'TOGGLE_DISCARD_POPUP'; - -export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; diff --git a/app/assets/javascripts/repo/stores/mutations.js b/app/assets/javascripts/repo/stores/mutations.js deleted file mode 100644 index ae2ba5bedf7..00000000000 --- a/app/assets/javascripts/repo/stores/mutations.js +++ /dev/null @@ -1,61 +0,0 @@ -import * as types from './mutation_types'; -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.SET_PREVIEW_MODE](state) { - Object.assign(state, { - currentBlobView: 'repo-preview', - }); - }, - [types.SET_EDIT_MODE](state) { - Object.assign(state, { - currentBlobView: 'repo-editor', - }); - }, - [types.TOGGLE_LOADING](state, entry) { - Object.assign(entry, { - loading: !entry.loading, - }); - }, - [types.TOGGLE_EDIT_MODE](state) { - Object.assign(state, { - editMode: !state.editMode, - }); - }, - [types.TOGGLE_DISCARD_POPUP](state, discardPopupOpen) { - Object.assign(state, { - discardPopupOpen, - }); - }, - [types.SET_COMMIT_REF](state, ref) { - Object.assign(state, { - currentRef: ref, - }); - }, - [types.SET_ROOT](state, isRoot) { - Object.assign(state, { - isRoot, - isInitialRoot: isRoot, - }); - }, - [types.SET_PREVIOUS_URL](state, previousUrl) { - Object.assign(state, { - previousUrl, - }); - }, - [types.SET_LAST_COMMIT_DATA](state, { entry, lastCommit }) { - Object.assign(entry.lastCommit, { - url: lastCommit.commit_path, - message: lastCommit.commit.message, - updatedAt: lastCommit.commit.authored_date, - }); - }, - ...fileMutations, - ...treeMutations, - ...branchMutations, -}; diff --git a/app/assets/javascripts/repo/stores/mutations/branch.js b/app/assets/javascripts/repo/stores/mutations/branch.js deleted file mode 100644 index d8229e8a620..00000000000 --- a/app/assets/javascripts/repo/stores/mutations/branch.js +++ /dev/null @@ -1,9 +0,0 @@ -import * as types from '../mutation_types'; - -export default { - [types.SET_CURRENT_BRANCH](state, currentBranch) { - Object.assign(state, { - currentBranch, - }); - }, -}; diff --git a/app/assets/javascripts/repo/stores/mutations/file.js b/app/assets/javascripts/repo/stores/mutations/file.js deleted file mode 100644 index f9ba80b9dc2..00000000000 --- a/app/assets/javascripts/repo/stores/mutations/file.js +++ /dev/null @@ -1,54 +0,0 @@ -import * as types from '../mutation_types'; -import { findIndexOfFile } from '../utils'; - -export default { - [types.SET_FILE_ACTIVE](state, { file, active }) { - Object.assign(file, { - active, - }); - }, - [types.TOGGLE_FILE_OPEN](state, file) { - Object.assign(file, { - opened: !file.opened, - }); - - if (file.opened) { - state.openFiles.push(file); - } else { - state.openFiles.splice(findIndexOfFile(state.openFiles, file), 1); - } - }, - [types.SET_FILE_DATA](state, { data, file }) { - Object.assign(file, { - blamePath: data.blame_path, - commitsPath: data.commits_path, - permalink: data.permalink, - rawPath: data.raw_path, - binary: data.binary, - html: data.html, - renderError: data.render_error, - }); - }, - [types.SET_FILE_RAW_DATA](state, { file, raw }) { - Object.assign(file, { - raw, - }); - }, - [types.UPDATE_FILE_CONTENT](state, { file, content }) { - const changed = content !== file.raw; - - Object.assign(file, { - content, - changed, - }); - }, - [types.DISCARD_FILE_CHANGES](state, file) { - Object.assign(file, { - content: '', - changed: false, - }); - }, - [types.CREATE_TMP_FILE](state, { file, parent }) { - parent.tree.push(file); - }, -}; diff --git a/app/assets/javascripts/repo/stores/mutations/tree.js b/app/assets/javascripts/repo/stores/mutations/tree.js deleted file mode 100644 index 130221c9fda..00000000000 --- a/app/assets/javascripts/repo/stores/mutations/tree.js +++ /dev/null @@ -1,27 +0,0 @@ -import * as types from '../mutation_types'; - -export default { - [types.TOGGLE_TREE_OPEN](state, tree) { - Object.assign(tree, { - opened: !tree.opened, - }); - }, - [types.SET_DIRECTORY_DATA](state, { data, tree }) { - Object.assign(tree, { - tree: data, - }); - }, - [types.SET_PARENT_TREE_URL](state, url) { - Object.assign(state, { - parentTreeUrl: url, - }); - }, - [types.SET_LAST_COMMIT_URL](state, { tree = state, url }) { - Object.assign(tree, { - lastCommitPath: url, - }); - }, - [types.CREATE_TMP_TREE](state, { parent, tmpEntry }) { - parent.tree.push(tmpEntry); - }, -}; diff --git a/app/assets/javascripts/repo/stores/state.js b/app/assets/javascripts/repo/stores/state.js deleted file mode 100644 index 0068834831e..00000000000 --- a/app/assets/javascripts/repo/stores/state.js +++ /dev/null @@ -1,24 +0,0 @@ -export default () => ({ - canCommit: false, - currentBranch: '', - currentBlobView: 'repo-preview', - currentRef: '', - discardPopupOpen: false, - editMode: false, - endpoints: {}, - isRoot: false, - isInitialRoot: false, - lastCommitPath: '', - loading: false, - onTopOfBranch: false, - openFiles: [], - path: '', - project: { - id: 0, - name: '', - url: '', - }, - parentTreeUrl: '', - previousUrl: '', - tree: [], -}); diff --git a/app/assets/javascripts/repo/stores/utils.js b/app/assets/javascripts/repo/stores/utils.js deleted file mode 100644 index fae1f4439a9..00000000000 --- a/app/assets/javascripts/repo/stores/utils.js +++ /dev/null @@ -1,127 +0,0 @@ -export const dataStructure = () => ({ - id: '', - key: '', - type: '', - name: '', - url: '', - path: '', - level: 0, - tempFile: false, - icon: '', - tree: [], - loading: false, - opened: false, - active: false, - changed: false, - lastCommitPath: '', - lastCommit: { - url: '', - message: '', - updatedAt: '', - }, - tree_url: '', - blamePath: '', - commitsPath: '', - permalink: '', - rawPath: '', - binary: false, - html: '', - raw: '', - content: '', - parentTreeUrl: '', - renderError: false, - base64: false, -}); - -export const decorateData = (entity) => { - const { - id, - type, - url, - name, - icon, - tree_url, - path, - renderError, - content = '', - tempFile = false, - active = false, - opened = false, - changed = false, - parentTreeUrl = '', - level = 0, - base64 = false, - } = entity; - - return { - ...dataStructure(), - id, - key: `${name}-${type}-${id}`, - type, - name, - url, - tree_url, - path, - level, - tempFile, - icon: `fa-${icon}`, - opened, - active, - parentTreeUrl, - changed, - renderError, - content, - base64, - }; -}; - -export const findEntry = (state, type, name) => state.tree.find( - f => f.type === type && f.name === name, -); -export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); - -export const setPageTitle = (title) => { - document.title = title; -}; - -export const pushState = (url) => { - history.pushState({ url }, '', url); -}; - -export const createTemp = ({ name, path, type, level, changed, content, base64 }) => { - const treePath = path ? `${path}/${name}` : name; - - return decorateData({ - id: new Date().getTime().toString(), - name, - type, - tempFile: true, - path: treePath, - icon: type === 'tree' ? 'folder' : 'file-text-o', - changed, - content, - parentTreeUrl: '', - level, - base64, - renderError: base64, - }); -}; - -export const createOrMergeEntry = ({ tree, entry, type, parentTreeUrl, level }) => { - const found = findEntry(tree, type, entry.name); - - if (found) { - return Object.assign({}, found, { - id: entry.id, - url: entry.url, - tempFile: false, - }); - } - - return decorateData({ - ...entry, - type, - parentTreeUrl, - level, - }); -}; diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue new file mode 100644 index 00000000000..dce23bd65f6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue @@ -0,0 +1,103 @@ + + + diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual-sidebar.scss index 2e417315ed7..5da06b90113 100644 --- a/app/assets/stylesheets/framework/contextual-sidebar.scss +++ b/app/assets/stylesheets/framework/contextual-sidebar.scss @@ -23,7 +23,6 @@ .context-header { position: relative; margin-right: 2px; - width: $contextual-sidebar-width; a { transition: padding $sidebar-transition-duration; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index b84d6c140be..1d6c7a5c472 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -219,6 +219,7 @@ $gl-input-padding: 10px; $gl-vert-padding: 6px; $gl-padding-top: 10px; $gl-sidebar-padding: 22px; +$gl-bar-padding: 3px; /* * Misc diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 6eb92c7baee..da3c2d7fa5d 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -22,9 +22,10 @@ } } -.multi-file { +.ide-view { display: flex; - height: calc(100vh - 145px); + height: calc(100vh - #{$header-height}); + color: $almost-black; border-top: 1px solid $white-dark; border-bottom: 1px solid $white-dark; @@ -35,12 +36,47 @@ } } +.with-performance-bar .ide-view { + height: calc(100vh - #{$header-height}); +} + .ide-file-list { flex: 1; - overflow: scroll; .file { cursor: pointer; + + &.file-open { + background: $white-normal; + } + + .repo-file-name { + white-space: nowrap; + text-overflow: ellipsis; + } + + .unsaved-icon { + color: $indigo-700; + float: right; + font-size: smaller; + line-height: 20px; + } + + .repo-new-btn { + display: none; + margin-top: -4px; + margin-bottom: -4px; + } + + &:hover { + .repo-new-btn { + display: block; + } + + .unsaved-icon { + display: none; + } + } } a { @@ -55,10 +91,9 @@ .multi-file-table-name, .multi-file-table-col-commit-message { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + overflow: visible; max-width: 0; + padding: 6px 12px; } .multi-file-table-name { @@ -66,6 +101,7 @@ } .multi-file-table-col-commit-message { + white-space: nowrap; width: 50%; } @@ -79,7 +115,7 @@ .multi-file-tabs { display: flex; - overflow: scroll; + overflow-x: auto; background-color: $white-normal; box-shadow: inset 0 -1px $white-dark; @@ -128,9 +164,38 @@ height: 0; } +.blob-editor-container { + flex: 1; + height: 0; + display: flex; + flex-direction: column; + justify-content: center; + + .vertical-center { + min-height: auto; + } +} + +.multi-file-editor-holder { + height: 100%; +} + .multi-file-editor-btn-group { - padding: $grid-size; + padding: $gl-bar-padding $gl-padding; border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + background: $white-light; +} + +.ide-status-bar { + padding: $gl-bar-padding $gl-padding; + background: $white-light; + display: flex; + justify-content: space-between; + + svg { + vertical-align: middle; + } } // Not great, but this is to deal with our current output @@ -138,10 +203,6 @@ height: 100%; overflow: scroll; - .blob-viewer { - height: 100%; - } - .file-content.code { display: flex; @@ -162,18 +223,101 @@ } } +.file-content.blob-no-preview { + a { + margin-left: auto; + margin-right: auto; + } +} + .multi-file-commit-panel { display: flex; flex-direction: column; height: 100%; width: 290px; - padding: $gl-padding; + padding: 0; background-color: $gray-light; border-left: 1px solid $white-dark; + .projects-sidebar { + display: flex; + flex-direction: column; + } + + .multi-file-commit-panel-inner { + display: flex; + flex: 1; + flex-direction: column; + } + + .multi-file-commit-panel-inner-scroll { + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + } + &.is-collapsed { width: 60px; - padding: 0; + + .multi-file-commit-list { + padding-top: $gl-padding; + overflow: hidden; + } + + .multi-file-context-bar-icon { + align-items: center; + + svg { + float: none; + margin: 0; + } + } + } + + .branch-container { + border-left: 4px solid $indigo-700; + margin-bottom: $gl-bar-padding; + } + + .branch-header { + background: $white-dark; + display: flex; + } + + .branch-header-title { + flex: 1; + padding: $grid-size $gl-padding; + color: $indigo-700; + font-weight: $gl-font-weight-bold; + + svg { + vertical-align: middle; + } + } + + .branch-header-btns { + padding: $gl-vert-padding $gl-padding; + } + + .left-collapse-btn { + display: none; + background: $gray-light; + text-align: left; + border-top: 1px solid $white-dark; + + svg { + vertical-align: middle; + } + } +} + +.multi-file-context-bar-icon { + padding: 10px; + + svg { + margin-right: 10px; + float: left; } } @@ -186,9 +330,9 @@ .multi-file-commit-panel-header { display: flex; align-items: center; - padding: 0 0 12px; margin-bottom: 12px; border-bottom: 1px solid $white-dark; + padding: $gl-btn-padding 0; &.is-collapsed { border-bottom: 1px solid $white-dark; @@ -197,23 +341,33 @@ margin-left: auto; margin-right: auto; } + + .multi-file-commit-panel-collapse-btn { + margin-right: auto; + margin-left: auto; + border-left: 0; + } } } -.multi-file-commit-panel-collapse-btn { - padding-top: 0; - padding-bottom: 0; - margin-left: auto; - font-size: 20px; +.multi-file-commit-panel-header-title { + display: flex; + flex: 1; + padding: $gl-btn-padding; - &.is-collapsed { - margin-right: auto; + svg { + margin-right: $gl-btn-padding; } } +.multi-file-commit-panel-collapse-btn { + border-left: 1px solid $white-dark; +} + .multi-file-commit-list { flex: 1; - overflow: scroll; + overflow: auto; + padding: $gl-padding; } .multi-file-commit-list-item { @@ -244,7 +398,7 @@ } .multi-file-commit-form { - padding-top: 12px; + padding: $gl-padding; border-top: 1px solid $white-dark; } @@ -295,3 +449,40 @@ } } } + +.ide-loading { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.ide-empty-state { + display: flex; + height: 100vh; + align-items: center; + justify-content: center; +} + +.repo-new-btn { + .dropdown-toggle svg { + margin-top: -2px; + margin-bottom: 2px; + } + + .dropdown-menu { + left: auto; + right: 0; + + label { + font-weight: $gl-font-weight-normal; + padding: 5px 8px; + margin-bottom: 0; + } + } +} + +.ide-flash-container.flash-container { + margin-top: $header-height; + margin-bottom: 0; +} diff --git a/app/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/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4754a67450f..d13407a06c8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -306,7 +306,7 @@ module ApplicationHelper cookies["sidebar_collapsed"] == "true" end - def show_new_repo? + def show_new_ide? cookies["new_repo"] == "true" && body_data_page != 'projects:show' end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 556ed233ccf..3c2ee2cb5bc 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -8,7 +8,7 @@ module BlobHelper %w(credits changelog news copying copyright license authors) end - def edit_path(project = @project, ref = @ref, path = @path, options = {}) + def edit_blob_path(project = @project, ref = @ref, path = @path, options = {}) project_edit_blob_path(project, tree_join(ref, path), options[:link_opts]) @@ -26,10 +26,10 @@ module BlobHelper button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' } # This condition applies to anonymous or users who can edit directly elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) - link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" + link_to 'Edit', edit_blob_path(project, ref, path, options), class: "#{common_classes} btn-sm" elsif current_user && can?(current_user, :fork_project, project) continue_params = { - to: edit_path(project, ref, path, options), + to: edit_blob_path(project, ref, path, options), notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } @@ -41,6 +41,43 @@ module BlobHelper end end + def ide_edit_path(project = @project, ref = @ref, path = @path, options = {}) + "#{ide_path}/project#{edit_blob_path(project, ref, path, options)}" + end + + def ide_edit_text + "#{_('Multi Edit')} #{_('Beta')}".html_safe + end + + def ide_blob_link(project = @project, ref = @ref, path = @path, options = {}) + return unless show_new_ide? + + blob = options.delete(:blob) + blob ||= project.repository.blob_at(ref, path) rescue nil + + return unless blob && blob.readable_text? + + common_classes = "btn js-edit-ide #{options[:extra_class]}" + + if !on_top_of_branch?(project, ref) + button_tag ide_edit_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' } + # This condition applies to anonymous or users who can edit directly + elsif current_user && can_modify_blob?(blob, project, ref) + link_to ide_edit_text, ide_edit_path(project, ref, path, options), class: "#{common_classes} btn-sm" + elsif current_user && can?(current_user, :fork_project, project) + continue_params = { + to: ide_edit_path(project, ref, path, options), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now + } + fork_path = project_forks_path(project, namespace_key: current_user.namespace.id, continue: continue_params) + + button_tag ide_edit_text, + class: common_classes, + data: { fork_path: fork_path } + end + end + def modify_file_link(project = @project, ref = @ref, path = @path, label:, action:, btn_class:, modal_type:) return unless current_user diff --git a/app/views/ide/index.html.haml b/app/views/ide/index.html.haml new file mode 100644 index 00000000000..8368e7a4563 --- /dev/null +++ b/app/views/ide/index.html.haml @@ -0,0 +1,12 @@ +- page_title 'IDE' + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'ide' + +.ide-flash-container.flash-container + +#ide.ide-loading + .text-center + = icon('spinner spin 2x') + %h2.clgray= _('IDE Loading ...') diff --git a/app/views/layouts/nav_only.html.haml b/app/views/layouts/nav_only.html.haml new file mode 100644 index 00000000000..6fa4b39dc10 --- /dev/null +++ b/app/views/layouts/nav_only.html.haml @@ -0,0 +1,13 @@ +!!! 5 +%html{ lang: I18n.locale, class: page_class } + = render "layouts/head" + %body{ class: "#{user_application_theme} #{@body_class}", data: { page: body_data_page } } + = render 'peek/bar' + = render "layouts/header/default" + = render 'shared/outdated_browser' + .mobile-overlay + .alert-wrapper + = render "layouts/broadcast" + = yield :flash_message + = render "layouts/flash" + = yield diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 3a7a99462a6..79530e78154 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -7,7 +7,7 @@ .nav-block = render 'projects/tree/tree_header', tree: @tree - - if !show_new_repo? && commit + - if commit = render 'shared/commit_well', commit: commit, ref: ref, project: project = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 281363d2e01..2a77dedd9a2 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -12,6 +12,7 @@ .btn-group{ role: "group" }< = edit_blob_link + = ide_blob_link - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index c4712bf3736..4d358052d43 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -6,21 +6,14 @@ - content_for :page_specific_javascripts do = webpack_bundle_tag 'blob' - - if show_new_repo? - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'repo' - = render 'projects/last_push' %div{ class: container_class } - - if show_new_repo? - = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_blob_path(@project, @id) - - else - #tree-holder.tree-holder - = render 'blob', blob: @blob + #tree-holder.tree-holder + = render 'blob', blob: @blob - if can_modify_blob?(@blob) = render 'projects/blob/remove' - - title = "Replace #{@blob.name}" - = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put + - title = "Replace #{@blob.name}" + = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put diff --git a/app/views/projects/tree/_old_tree_content.html.haml b/app/views/projects/tree/_old_tree_content.html.haml deleted file mode 100644 index 6ea78851b8d..00000000000 --- a/app/views/projects/tree/_old_tree_content.html.haml +++ /dev/null @@ -1,24 +0,0 @@ -.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } - .table-holder - %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } - %thead - %tr - %th= s_('ProjectFileTree|Name') - %th.hidden-xs - .pull-left= _('Last commit') - %th.text-right= _('Last update') - - if @path.present? - %tr.tree-item - %td.tree-item-file-name - = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' - %td - %td.hidden-xs - - = render_tree(tree) - - - if tree.readme - = render "projects/tree/readme", readme: tree.readme - -- if can_edit_tree? - = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post - = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_old_tree_header.html.haml b/app/views/projects/tree/_old_tree_header.html.haml deleted file mode 100644 index 7f636b7e0e8..00000000000 --- a/app/views/projects/tree/_old_tree_header.html.haml +++ /dev/null @@ -1,64 +0,0 @@ -- if on_top_of_branch? - - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' } -- else - - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } - -%ul.breadcrumb.repo-breadcrumb - %li - = link_to project_tree_path(@project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - %li - = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - - if current_user - %li - %a.btn.add-to-tree{ addtotree_toggle_attributes } - = sprite_icon('plus', size: 16, css_class: 'pull-left') - = sprite_icon('arrow-down', size: 16, css_class: 'pull-left') - - if on_top_of_branch? - .add-to-tree-dropdown - %ul.dropdown-menu - - if can_edit_tree? - %li - = link_to project_new_blob_path(@project, @id) do - #{ _('New file') } - %li - = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do - #{ _('Upload file') } - %li - = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do - #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) - %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('Upload file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - #{ _('New directory') } - - %li.divider - %li - = link_to new_project_branch_path(@project) do - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - #{ _('New tag') } diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index a4bdd67209d..6ea78851b8d 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -1,5 +1,24 @@ -- content_url = local_assigns.fetch(:content_url, nil) -- if show_new_repo? - = render 'shared/repo/repo', project: @project, content_url: content_url -- else - = render 'projects/tree/old_tree_content', tree: tree +.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } + .table-holder + %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } + %thead + %tr + %th= s_('ProjectFileTree|Name') + %th.hidden-xs + .pull-left= _('Last commit') + %th.text-right= _('Last update') + - if @path.present? + %tr.tree-item + %td.tree-item-file-name + = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' + %td + %td.hidden-xs + + = render_tree(tree) + + - if tree.readme + = render "projects/tree/readme", readme: tree.readme + +- if can_edit_tree? + = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post + = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index c02f7ee37ed..d1ecef39475 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -2,16 +2,78 @@ .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - - if show_new_repo? && can_push_branch?(@project, @ref) - .js-new-dropdown - - else - = render 'projects/tree/old_tree_header' + - if on_top_of_branch? + - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown' } + - else + - addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' } + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to project_tree_path(@project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) + + - if current_user + %li + %a.btn.add-to-tree{ addtotree_toggle_attributes } + = sprite_icon('plus', size: 16, css_class: 'pull-left') + = sprite_icon('arrow-down', size: 16, css_class: 'pull-left') + - if on_top_of_branch? + .add-to-tree-dropdown + %ul.dropdown-menu + - if can_edit_tree? + %li + = link_to project_new_blob_path(@project, @id) do + #{ _('New file') } + %li + = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do + #{ _('Upload file') } + %li + = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do + #{ _('New directory') } + - elsif can?(current_user, :fork_project, @project) + %li + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('Upload file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + #{ _('New directory') } + + %li.divider + %li + = link_to new_project_branch_path(@project) do + #{ _('New branch') } + %li + = link_to new_project_tag_path(@project) do + #{ _('New tag') } .tree-controls - - if show_new_repo? - .editable-mode - - else - = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' + - if show_new_ide? + = succeed " " do + = link_to ide_edit_path(@project, @id), class: 'btn btn-default' do + = ide_edit_text + + = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = render 'projects/find_file_link' diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 64cc70053ef..3b4057e56d0 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -6,11 +6,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") -- if show_new_repo? - - content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'repo' - -%div{ class: [(container_class unless show_new_repo?), ("limit-container-width" unless fluid_layout)] } +%div{ class: [(container_class), ("limit-container-width" unless fluid_layout)] } = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml index f4a4bfaec54..479bd2cdb38 100644 --- a/app/views/shared/_ref_switcher.html.haml +++ b/app/views/shared/_ref_switcher.html.haml @@ -1,6 +1,6 @@ - show_create = local_assigns.fetch(:show_create, false) -- show_new_branch_form = show_new_repo? && show_create && can?(current_user, :push_code, @project) +- show_new_branch_form = show_new_ide? && show_create && can?(current_user, :push_code, @project) - dropdown_toggle_text = @ref || @project.default_branch = form_tag switch_project_refs_path(@project), method: :get, class: "project-refs-form" do = hidden_field_tag :destination, destination diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml deleted file mode 100644 index 87e8c416194..00000000000 --- a/app/views/shared/repo/_repo.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -- @no_container = true; -#repo{ data: { root: @path.empty?.to_s, - root_url: project_tree_path(project), - url: content_url, - current_branch: @ref, - ref: @commit.id, - project_name: project.name, - project_url: project_path(project), - project_id: project.id, - new_merge_request_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '' }), - can_commit: (!!can_push_branch?(project, @ref)).to_s, - on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s, - current_path: @path } } diff --git a/changelogs/unreleased/40040-decouple-multi-file-editor-from-file-list.yml b/changelogs/unreleased/40040-decouple-multi-file-editor-from-file-list.yml new file mode 100644 index 00000000000..e2fade2bfd9 --- /dev/null +++ b/changelogs/unreleased/40040-decouple-multi-file-editor-from-file-list.yml @@ -0,0 +1,5 @@ +--- +title: Adds the multi file editor as a new beta feature +merge_request: 15430 +author: +type: feature diff --git a/config/routes.rb b/config/routes.rb index 016140e0ede..f162043dd5e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,6 +43,8 @@ Rails.application.routes.draw do get 'liveness' => 'health#liveness' get 'readiness' => 'health#readiness' post 'storage_check' => 'health#storage_check' + get 'ide' => 'ide#index' + get 'ide/*vueroute' => 'ide#index', format: false resources :metrics, only: [:index] mount Peek::Railtie => '/peek' diff --git a/config/webpack.config.js b/config/webpack.config.js index d8797bbf4d3..6daef243991 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -70,7 +70,7 @@ var config = { protected_branches: './protected_branches', protected_tags: './protected_tags', registry_list: './registry/index.js', - repo: './repo/index.js', + ide: './ide/index.js', sidebar: './sidebar/sidebar_bundle.js', schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js', schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js', @@ -204,7 +204,7 @@ var config = { 'pipelines', 'pipelines_details', 'registry_list', - 'repo', + 'ide', 'schedule_form', 'schedules_index', 'sidebar', diff --git a/package.json b/package.json index a5bf2309a0f..aa4e4e79f49 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "vue": "^2.5.8", "vue-loader": "^13.5.0", "vue-resource": "^1.3.4", + "vue-router": "^3.0.1", "vue-template-compiler": "^2.5.8", "vuex": "^3.0.1", "webpack": "^3.5.5", diff --git a/spec/features/projects/ref_switcher_spec.rb b/spec/features/projects/ref_switcher_spec.rb deleted file mode 100644 index 33ccbc1a29f..00000000000 --- a/spec/features/projects/ref_switcher_spec.rb +++ /dev/null @@ -1,78 +0,0 @@ -require 'rails_helper' - -feature 'Ref switcher', :js do - let(:user) { create(:user) } - let(:project) { create(:project, :public, :repository) } - - before do - project.team << [user, :master] - set_cookie('new_repo', 'true') - sign_in(user) - visit project_tree_path(project, 'master') - end - - it 'allow user to change ref by enter key' do - click_button 'master' - wait_for_requests - - page.within '.project-refs-form' do - input = find('input[type="search"]') - input.set 'binary' - wait_for_requests - - expect(find('.dropdown-content ul')).to have_selector('li', count: 7) - - page.within '.dropdown-content ul' do - input.native.send_keys :enter - end - end - - expect(page).to have_title 'add-pdf-text-binary' - end - - it "user selects ref with special characters" do - click_button 'master' - wait_for_requests - - page.within '.project-refs-form' do - page.fill_in 'Search branches and tags', with: "'test'" - click_link "'test'" - end - - expect(page).to have_title "'test'" - end - - context "create branch" do - let(:input) { find('.js-new-branch-name') } - - before do - click_button 'master' - wait_for_requests - - page.within '.project-refs-form' do - find(".dropdown-footer-list a").click - end - end - - it "shows error message for the invalid branch name" do - input.set 'foo bar' - click_button('Create') - wait_for_requests - expect(page).to have_content 'Branch name is invalid' - end - - it "should create new branch properly" do - input.set 'new-branch-name' - click_button('Create') - wait_for_requests - expect(find('.js-project-refs-dropdown')).to have_content 'new-branch-name' - end - - it "should create new branch by Enter key" do - input.set 'new-branch-name-2' - input.native.send_keys :enter - wait_for_requests - expect(find('.js-project-refs-dropdown')).to have_content 'new-branch-name-2' - end - end -end diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb index 8f06328962e..3f6d16c8acf 100644 --- a/spec/features/projects/tree/create_directory_spec.rb +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -13,6 +13,14 @@ feature 'Multi-file editor new directory', :js do visit project_tree_path(project, :master) wait_for_requests + + click_link('Multi Edit') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') end it 'creates directory in current directory' do @@ -21,17 +29,29 @@ feature 'Multi-file editor new directory', :js do click_link('New directory') page.within('.modal') do - find('.form-control').set('foldername') + 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 + find('.multi-file-commit-panel-collapse-btn').click - fill_in('commit-message', with: 'commit message') + fill_in('commit-message', with: 'commit message ide') click_button('Commit') - expect(page).to have_selector('td', text: 'commit message') + 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 index bdebc12ef47..ba71eef07f4 100644 --- a/spec/features/projects/tree/create_file_spec.rb +++ b/spec/features/projects/tree/create_file_spec.rb @@ -13,6 +13,14 @@ feature 'Multi-file editor new file', :js do visit project_tree_path(project, :master) wait_for_requests + + click_link('Multi Edit') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') end it 'creates file in current directory' do @@ -21,17 +29,19 @@ feature 'Multi-file editor new file', :js do click_link('New file') page.within('.modal') do - find('.form-control').set('filename') + find('.form-control').set('file name') click_button('Create file') end + wait_for_requests + find('.multi-file-commit-panel-collapse-btn').click - fill_in('commit-message', with: 'commit message') + fill_in('commit-message', with: 'commit message ide') click_button('Commit') - expect(page).to have_selector('td', text: 'commit message') + 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 index d4e57d1ecfa..9fbb1dbd0e8 100644 --- a/spec/features/projects/tree/upload_file_spec.rb +++ b/spec/features/projects/tree/upload_file_spec.rb @@ -15,6 +15,14 @@ feature 'Multi-file editor upload file', :js do visit project_tree_path(project, :master) wait_for_requests + + click_link('Multi Edit') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') end it 'uploads text file' do @@ -41,6 +49,5 @@ feature 'Multi-file editor upload file', :js do expect(page).to have_selector('.multi-file-tab', text: 'dk.png') expect(page).not_to have_selector('.monaco-editor') - expect(page).to have_content('The source could not be displayed for this temporary file.') end end diff --git a/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js index f750061a6a1..c4d3866c922 100644 --- a/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js +++ b/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import listCollapsed from '~/repo/components/commit_sidebar/list_collapsed.vue'; +import store from '~/ide/stores'; +import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; import { file } from '../../helpers'; diff --git a/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js index 18c9b46fcd9..fc7c9ae9dd7 100644 --- a/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js +++ b/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import listItem from '~/repo/components/commit_sidebar/list_item.vue'; +import listItem from '~/ide/components/commit_sidebar/list_item.vue'; import mountComponent from '../../../helpers/vue_mount_component_helper'; import { file } from '../../helpers'; diff --git a/spec/javascripts/repo/components/commit_sidebar/list_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_spec.js index df7e3c5de21..cb5240ad118 100644 --- a/spec/javascripts/repo/components/commit_sidebar/list_spec.js +++ b/spec/javascripts/repo/components/commit_sidebar/list_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import commitSidebarList from '~/repo/components/commit_sidebar/list.vue'; +import store from '~/ide/stores'; +import commitSidebarList from '~/ide/components/commit_sidebar/list.vue'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; import { file } from '../../helpers'; @@ -13,8 +13,11 @@ describe('Multi-file editor commit sidebar list', () => { vm = createComponentWithStore(Component, store, { title: 'Staged', fileList: [], - collapsed: false, - }).$mount(); + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); }); afterEach(() => { @@ -43,30 +46,14 @@ describe('Multi-file editor commit sidebar list', () => { describe('collapsed', () => { beforeEach((done) => { - vm.collapsed = true; + vm.$store.state.rightPanelCollapsed = true; Vue.nextTick(done); }); - it('adds collapsed class', () => { - expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull(); - }); - it('hides list', () => { expect(vm.$el.querySelector('.list-unstyled')).toBeNull(); expect(vm.$el.querySelector('.help-block')).toBeNull(); }); - - it('hides collapse button', () => { - expect(vm.$el.querySelector('.multi-file-commit-panel-collapse-btn')).toBeNull(); - }); - }); - - it('clicking toggle collapse button emits toggle event', () => { - spyOn(vm, '$emit'); - - vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click(); - - expect(vm.$emit).toHaveBeenCalledWith('toggleCollapsed'); }); }); diff --git a/spec/javascripts/repo/components/ide_context_bar_spec.js b/spec/javascripts/repo/components/ide_context_bar_spec.js new file mode 100644 index 00000000000..3f8f37d2343 --- /dev/null +++ b/spec/javascripts/repo/components/ide_context_bar_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ideContextBar from '~/ide/components/ide_context_bar.vue'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; + +describe('Multi-file editor right context bar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideContextBar); + + vm = createComponentWithStore(Component, store); + + 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(); + }); + + it('shows correct icon', () => { + expect(vm.currentIcon).toBe('angle-double-left'); + }); + }); + + it('clicking toggle collapse button collapses the bar', () => { + spyOn(vm, 'setPanelCollapsedStatus').and.returnValue(Promise.resolve()); + + vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click(); + + expect(vm.setPanelCollapsedStatus).toHaveBeenCalledWith({ + side: 'right', + collapsed: true, + }); + }); +}); diff --git a/spec/javascripts/repo/components/ide_repo_tree_spec.js b/spec/javascripts/repo/components/ide_repo_tree_spec.js new file mode 100644 index 00000000000..b6f70f585cd --- /dev/null +++ b/spec/javascripts/repo/components/ide_repo_tree_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ideRepoTree from '~/ide/components/ide_repo_tree.vue'; +import { file, resetStore } from '../helpers'; + +describe('IdeRepoTree', () => { + let vm; + + beforeEach(() => { + const IdeRepoTree = Vue.extend(ideRepoTree); + + vm = new IdeRepoTree({ + store, + propsData: { + treeId: 'abcproject/mybranch', + }, + }); + + vm.$store.state.currentBranch = 'master'; + vm.$store.state.isRoot = true; + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [file()], + }; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a sidebar', () => { + const tbody = vm.$el.querySelector('tbody'); + + expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy(); + expect(tbody.querySelector('.repo-file-options')).toBeFalsy(); + expect(tbody.querySelector('.prev-directory')).toBeFalsy(); + expect(tbody.querySelector('.loading-file')).toBeFalsy(); + expect(tbody.querySelector('.file')).toBeTruthy(); + }); + + it('renders 5 loading files if tree is loading', (done) => { + vm.$store.state.loading = true; + + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); + + done(); + }); + }); + + it('renders a prev directory if is not root', (done) => { + vm.$store.state.isRoot = false; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/repo/components/ide_side_bar_spec.js b/spec/javascripts/repo/components/ide_side_bar_spec.js new file mode 100644 index 00000000000..30e45169205 --- /dev/null +++ b/spec/javascripts/repo/components/ide_side_bar_spec.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ideSidebar from '~/ide/components/ide_side_bar.vue'; +import { resetStore } from '../helpers'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; + +describe('IdeSidebar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideSidebar); + + vm = createComponentWithStore(Component, store).$mount(); + + vm.$store.state.leftPanelCollapsed = false; + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a sidebar', () => { + expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull(); + }); + + describe('collapsed', () => { + beforeEach((done) => { + vm.$store.state.leftPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('adds collapsed class', () => { + expect(vm.$el.classList).toContain('is-collapsed'); + }); + + it('shows correct icon', () => { + expect(vm.currentIcon).toBe('angle-double-right'); + }); + }); +}); diff --git a/spec/javascripts/repo/components/ide_spec.js b/spec/javascripts/repo/components/ide_spec.js new file mode 100644 index 00000000000..20b8dc25dcb --- /dev/null +++ b/spec/javascripts/repo/components/ide_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ide from '~/ide/components/ide.vue'; +import { createComponentWithStore } from '../../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).$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/repo/components/new_branch_form_spec.js b/spec/javascripts/repo/components/new_branch_form_spec.js index 9a705a1f0ed..cd1d073ec18 100644 --- a/spec/javascripts/repo/components/new_branch_form_spec.js +++ b/spec/javascripts/repo/components/new_branch_form_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import newBranchForm from '~/repo/components/new_branch_form.vue'; +import store from '~/ide/stores'; +import newBranchForm from '~/ide/components/new_branch_form.vue'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { resetStore } from '../helpers'; diff --git a/spec/javascripts/repo/components/new_dropdown/index_spec.js b/spec/javascripts/repo/components/new_dropdown/index_spec.js index 93b10fc1fee..b001c1655b4 100644 --- a/spec/javascripts/repo/components/new_dropdown/index_spec.js +++ b/spec/javascripts/repo/components/new_dropdown/index_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import newDropdown from '~/repo/components/new_dropdown/index.vue'; +import store from '~/ide/stores'; +import newDropdown from '~/ide/components/new_dropdown/index.vue'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; import { resetStore } from '../../helpers'; @@ -10,8 +10,12 @@ describe('new dropdown component', () => { beforeEach(() => { const component = Vue.extend(newDropdown); - vm = createComponentWithStore(component, store); + vm = createComponentWithStore(component, store, { + branch: 'master', + path: '', + }); + vm.$store.state.currentProjectId = 'abcproject'; vm.$store.state.path = ''; vm.$mount(); @@ -23,9 +27,10 @@ describe('new dropdown component', () => { resetStore(vm.$store); }); - it('renders new file and new directory links', () => { + 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('New directory'); + expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe('Upload file'); + expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe('New directory'); }); describe('createNewItem', () => { @@ -36,7 +41,7 @@ describe('new dropdown component', () => { }); it('sets modalType to tree when new directory is clicked', () => { - vm.$el.querySelectorAll('a')[1].click(); + vm.$el.querySelectorAll('a')[2].click(); expect(vm.modalType).toBe('tree'); }); diff --git a/spec/javascripts/repo/components/new_dropdown/modal_spec.js b/spec/javascripts/repo/components/new_dropdown/modal_spec.js index 1ff7590ec79..233cca06ed0 100644 --- a/spec/javascripts/repo/components/new_dropdown/modal_spec.js +++ b/spec/javascripts/repo/components/new_dropdown/modal_spec.js @@ -1,12 +1,42 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import modal from '~/repo/components/new_dropdown/modal.vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import modal from '~/ide/components/new_dropdown/modal.vue'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; import { file, resetStore } from '../../helpers'; describe('new file modal component', () => { const Component = Vue.extend(modal); let vm; + let projectTree; + + beforeEach(() => { + spyOn(service, 'getProjectData').and.returnValue(Promise.resolve({ + data: { + id: '123', + }, + })); + + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + commit: { + id: '123branch', + }, + })); + + 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' }], + }), + })); + }); afterEach(() => { vm.$destroy(); @@ -17,12 +47,26 @@ describe('new file modal component', () => { ['tree', 'blob'].forEach((type) => { describe(type, () => { beforeEach(() => { + store.state.projects.abcproject = { + web_url: '', + }; + store.state.trees = []; + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + projectTree = store.state.trees['abcproject/mybranch']; + store.state.currentProjectId = 'abcproject'; + vm = createComponentWithStore(Component, store, { type, + branchId: 'master', path: '', - }).$mount(); + parent: projectTree, + }); vm.entryName = 'testing'; + + vm.$mount(); }); it(`sets modal title as ${type}`, () => { @@ -50,6 +94,9 @@ describe('new file modal component', () => { vm.createEntryInStore(); expect(vm.createTempEntry).toHaveBeenCalledWith({ + projectId: 'abcproject', + branchId: 'master', + parent: projectTree, name: 'testing', type, }); @@ -76,31 +123,18 @@ describe('new file modal component', () => { }); it('opens newly created file', (done) => { - vm.createEntryInStore(); - - setTimeout(() => { - expect(vm.$store.state.openFiles.length).toBe(1); - expect(vm.$store.state.openFiles[0].name).toBe(type === 'blob' ? 'testing' : '.gitkeep'); - - done(); - }); - }); - - it(`creates ${type} in the current stores path`, (done) => { - vm.$store.state.path = 'app'; - - vm.createEntryInStore(); - - setTimeout(() => { - expect(vm.$store.state.tree[0].path).toBe('app/testing'); - expect(vm.$store.state.tree[0].name).toBe('testing'); + if (type === 'blob') { + vm.createEntryInStore(); - if (type === 'tree') { - expect(vm.$store.state.tree[0].tree.length).toBe(1); - } + setTimeout(() => { + expect(vm.$store.state.openFiles.length).toBe(1); + expect(vm.$store.state.openFiles[0].name).toBe(type === 'blob' ? 'testing' : '.gitkeep'); + done(); + }); + } else { done(); - }); + } }); if (type === 'blob') { @@ -108,25 +142,27 @@ describe('new file modal component', () => { vm.createEntryInStore(); setTimeout(() => { - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe('testing'); - expect(vm.$store.state.tree[0].type).toBe('blob'); - expect(vm.$store.state.tree[0].tempFile).toBeTruthy(); + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + expect(baseTree.length).toBe(1); + expect(baseTree[0].name).toBe('testing'); + expect(baseTree[0].type).toBe('blob'); + expect(baseTree[0].tempFile).toBeTruthy(); done(); }); }); it('does not create temp file when file already exists', (done) => { - vm.$store.state.tree.push(file('testing', '1', type)); + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + baseTree.push(file('testing', '1', type)); vm.createEntryInStore(); setTimeout(() => { - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe('testing'); - expect(vm.$store.state.tree[0].type).toBe('blob'); - expect(vm.$store.state.tree[0].tempFile).toBeFalsy(); + expect(baseTree.length).toBe(1); + expect(baseTree[0].name).toBe('testing'); + expect(baseTree[0].type).toBe('blob'); + expect(baseTree[0].tempFile).toBeFalsy(); done(); }); @@ -135,48 +171,47 @@ describe('new file modal component', () => { it('creates new tree', () => { vm.createEntryInStore(); - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe('testing'); - expect(vm.$store.state.tree[0].type).toBe('tree'); - expect(vm.$store.state.tree[0].tempFile).toBeTruthy(); - expect(vm.$store.state.tree[0].tree.length).toBe(1); - expect(vm.$store.state.tree[0].tree[0].name).toBe('.gitkeep'); + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + expect(baseTree.length).toBe(1); + expect(baseTree[0].name).toBe('testing'); + expect(baseTree[0].type).toBe('tree'); + expect(baseTree[0].tempFile).toBeTruthy(); }); it('creates multiple trees when entryName has slashes', () => { vm.entryName = 'app/test'; vm.createEntryInStore(); - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe('app'); - expect(vm.$store.state.tree[0].tree[0].name).toBe('test'); - expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep'); + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + expect(baseTree.length).toBe(1); + expect(baseTree[0].name).toBe('app'); }); it('creates tree in existing tree', () => { - vm.$store.state.tree.push(file('app', '1', 'tree')); + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + baseTree.push(file('app', '1', 'tree')); vm.entryName = 'app/test'; vm.createEntryInStore(); - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe('app'); - expect(vm.$store.state.tree[0].tempFile).toBeFalsy(); - expect(vm.$store.state.tree[0].tree[0].tempFile).toBeTruthy(); - expect(vm.$store.state.tree[0].tree[0].name).toBe('test'); - expect(vm.$store.state.tree[0].tree[0].tree[0].name).toBe('.gitkeep'); + expect(baseTree.length).toBe(1); + expect(baseTree[0].name).toBe('app'); + expect(baseTree[0].tempFile).toBeFalsy(); + expect(baseTree[0].tree[0].tempFile).toBeTruthy(); + expect(baseTree[0].tree[0].name).toBe('test'); }); it('does not create new tree when already exists', () => { - vm.$store.state.tree.push(file('app', '1', 'tree')); + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + baseTree.push(file('app', '1', 'tree')); vm.entryName = 'app'; vm.createEntryInStore(); - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe('app'); - expect(vm.$store.state.tree[0].tempFile).toBeFalsy(); - expect(vm.$store.state.tree[0].tree.length).toBe(0); + expect(baseTree.length).toBe(1); + expect(baseTree[0].name).toBe('app'); + expect(baseTree[0].tempFile).toBeFalsy(); + expect(baseTree[0].tree.length).toBe(0); }); } }); @@ -188,6 +223,8 @@ describe('new file modal component', () => { vm = createComponentWithStore(Component, store, { type: 'tree', + projectId: 'abcproject', + branchId: 'master', path: '', }).$mount('.js-test'); diff --git a/spec/javascripts/repo/components/new_dropdown/upload_spec.js b/spec/javascripts/repo/components/new_dropdown/upload_spec.js index bf7893029b1..788c08e5279 100644 --- a/spec/javascripts/repo/components/new_dropdown/upload_spec.js +++ b/spec/javascripts/repo/components/new_dropdown/upload_spec.js @@ -1,19 +1,61 @@ import Vue from 'vue'; -import upload from '~/repo/components/new_dropdown/upload.vue'; -import store from '~/repo/stores'; +import upload from '~/ide/components/new_dropdown/upload.vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; import { resetStore } from '../../helpers'; describe('new dropdown upload', () => { let vm; + let projectTree; beforeEach(() => { + spyOn(service, 'getProjectData').and.returnValue(Promise.resolve({ + data: { + id: '123', + }, + })); + + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + commit: { + id: '123branch', + }, + })); + + 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' }], + }), + })); + const Component = Vue.extend(upload); + store.state.projects.abcproject = { + web_url: '', + }; + store.state.currentProjectId = 'abcproject'; + store.state.trees = []; + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + projectTree = store.state.trees['abcproject/mybranch']; + vm = createComponentWithStore(Component, store, { + branchId: 'master', path: '', + parent: projectTree, }); + vm.entryName = 'testing'; + vm.$mount(); }); @@ -65,23 +107,33 @@ describe('new dropdown upload', () => { vm.createFile(target, file, true); vm.$nextTick(() => { - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe(file.name); - expect(vm.$store.state.tree[0].content).toBe(target.result); + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + expect(baseTree.length).toBe(1); + expect(baseTree[0].name).toBe(file.name); + expect(baseTree[0].content).toBe(target.result); done(); }); }); it('creates new file in path', (done) => { - vm.$store.state.path = 'testing'; + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + const tree = { + type: 'tree', + name: 'testing', + path: 'testing', + tree: [], + }; + baseTree.push(tree); + + vm.parent = tree; vm.createFile(target, file, true); vm.$nextTick(() => { - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe(file.name); - expect(vm.$store.state.tree[0].content).toBe(target.result); - expect(vm.$store.state.tree[0].path).toBe(`testing/${file.name}`); + expect(baseTree.length).toBe(1); + expect(baseTree[0].tree[0].name).toBe(file.name); + expect(baseTree[0].tree[0].content).toBe(target.result); + expect(baseTree[0].tree[0].path).toBe(`testing/${file.name}`); done(); }); @@ -91,10 +143,11 @@ describe('new dropdown upload', () => { vm.createFile(binaryTarget, file, false); vm.$nextTick(() => { - expect(vm.$store.state.tree.length).toBe(1); - expect(vm.$store.state.tree[0].name).toBe(file.name); - expect(vm.$store.state.tree[0].content).toBe(binaryTarget.result.split('base64,')[1]); - expect(vm.$store.state.tree[0].base64).toBe(true); + const baseTree = vm.$store.state.trees['abcproject/mybranch'].tree; + expect(baseTree.length).toBe(1); + expect(baseTree[0].name).toBe(file.name); + expect(baseTree[0].content).toBe(binaryTarget.result.split('base64,')[1]); + expect(baseTree[0].base64).toBe(true); done(); }); diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js index 72712e058e5..cd93fb3ccbf 100644 --- a/spec/javascripts/repo/components/repo_commit_section_spec.js +++ b/spec/javascripts/repo/components/repo_commit_section_spec.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import * as urlUtils from '~/lib/utils/url_utility'; -import store from '~/repo/stores'; -import service from '~/repo/services'; -import repoCommitSection from '~/repo/components/repo_commit_section.vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import repoCommitSection from '~/ide/components/repo_commit_section.vue'; import getSetTimeoutPromise from '../../helpers/set_timeout_promise_helper'; import { file, resetStore } from '../helpers'; @@ -16,6 +16,18 @@ describe('RepoCommitSection', () => { store, }).$mount(); + comp.$store.state.currentProjectId = 'abcproject'; + comp.$store.state.currentBranchId = 'master'; + comp.$store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + + comp.$store.state.rightPanelCollapsed = false; comp.$store.state.currentBranch = 'master'; comp.$store.state.openFiles = [file(), file()]; comp.$store.state.openFiles.forEach(f => Object.assign(f, { @@ -29,7 +41,19 @@ describe('RepoCommitSection', () => { beforeEach((done) => { vm = createComponent(); - vm.collapsed = false; + 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); }); @@ -45,7 +69,6 @@ describe('RepoCommitSection', () => { const submitCommit = vm.$el.querySelector('form .btn'); expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); - expect(vm.$el.querySelector('.multi-file-commit-panel-section header').textContent.trim()).toEqual('Staged'); expect(changedFileElements.length).toEqual(2); changedFileElements.forEach((changedFile, i) => { diff --git a/spec/javascripts/repo/components/repo_edit_button_spec.js b/spec/javascripts/repo/components/repo_edit_button_spec.js index 44018464b35..2895b794506 100644 --- a/spec/javascripts/repo/components/repo_edit_button_spec.js +++ b/spec/javascripts/repo/components/repo_edit_button_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoEditButton from '~/repo/components/repo_edit_button.vue'; +import store from '~/ide/stores'; +import repoEditButton from '~/ide/components/repo_edit_button.vue'; import { file, resetStore } from '../helpers'; describe('RepoEditButton', () => { @@ -32,7 +32,7 @@ describe('RepoEditButton', () => { vm.$mount(); expect(vm.$el.querySelector('.btn')).not.toBeNull(); - expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Edit'); + expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit'); }); it('renders edit button with cancel text', () => { @@ -50,7 +50,7 @@ describe('RepoEditButton', () => { vm.$el.querySelector('.btn').click(); vm.$nextTick(() => { - expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Cancel edit'); + expect(vm.$el.querySelector('.btn').textContent.trim()).toBe('Edit'); done(); }); diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js index 81158cad639..e7b2ed08acd 100644 --- a/spec/javascripts/repo/components/repo_editor_spec.js +++ b/spec/javascripts/repo/components/repo_editor_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoEditor from '~/repo/components/repo_editor.vue'; -import monacoLoader from '~/repo/monaco_loader'; +import store from '~/ide/stores'; +import repoEditor from '~/ide/components/repo_editor.vue'; +import monacoLoader from '~/ide/monaco_loader'; import { file, resetStore } from '../helpers'; describe('RepoEditor', () => { diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js index d6e255e4810..115569a9117 100644 --- a/spec/javascripts/repo/components/repo_file_buttons_spec.js +++ b/spec/javascripts/repo/components/repo_file_buttons_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoFileButtons from '~/repo/components/repo_file_buttons.vue'; +import store from '~/ide/stores'; +import repoFileButtons from '~/ide/components/repo_file_buttons.vue'; import { file, resetStore } from '../helpers'; describe('RepoFileButtons', () => { diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js index bf9181fb09c..e8b370f97b4 100644 --- a/spec/javascripts/repo/components/repo_file_spec.js +++ b/spec/javascripts/repo/components/repo_file_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoFile from '~/repo/components/repo_file.vue'; +import store from '~/ide/stores'; +import repoFile from '~/ide/components/repo_file.vue'; import { file, resetStore } from '../helpers'; describe('RepoFile', () => { @@ -35,11 +35,10 @@ describe('RepoFile', () => { const fileIcon = vm.$el.querySelector('.file-icon'); expect(vm.$el.querySelector(`.${vm.file.icon}`).style.marginLeft).toEqual('0px'); - expect(name.href).toMatch(`/${vm.file.url}`); + expect(name.href).toMatch(''); expect(name.textContent.trim()).toEqual(vm.file.name); expect(fileIcon.classList.contains(vm.file.icon)).toBeTruthy(); expect(fileIcon.style.marginLeft).toEqual(`${vm.file.level * 10}px`); - expect(vm.$el.querySelectorAll('.animation-container').length).toBe(2); }); it('does render if hasFiles is true and is loading tree', () => { @@ -75,16 +74,16 @@ describe('RepoFile', () => { }); }); - it('fires clickedTreeRow when the link is clicked', () => { + it('fires clickFile when the link is clicked', () => { vm = createComponent({ file: file(), }); - spyOn(vm, 'clickedTreeRow'); + spyOn(vm, 'clickFile'); vm.$el.click(); - expect(vm.clickedTreeRow).toHaveBeenCalledWith(vm.file); + expect(vm.clickFile).toHaveBeenCalledWith(vm.file); }); describe('submodule', () => { diff --git a/spec/javascripts/repo/components/repo_loading_file_spec.js b/spec/javascripts/repo/components/repo_loading_file_spec.js index 031f2a9c0b2..18366fb89bc 100644 --- a/spec/javascripts/repo/components/repo_loading_file_spec.js +++ b/spec/javascripts/repo/components/repo_loading_file_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoLoadingFile from '~/repo/components/repo_loading_file.vue'; +import store from '~/ide/stores'; +import repoLoadingFile from '~/ide/components/repo_loading_file.vue'; import { resetStore } from '../helpers'; describe('RepoLoadingFile', () => { @@ -48,6 +48,7 @@ describe('RepoLoadingFile', () => { 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(() => { diff --git a/spec/javascripts/repo/components/repo_prev_directory_spec.js b/spec/javascripts/repo/components/repo_prev_directory_spec.js index 7f82ae36a64..ff26cab2262 100644 --- a/spec/javascripts/repo/components/repo_prev_directory_spec.js +++ b/spec/javascripts/repo/components/repo_prev_directory_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue'; +import store from '~/ide/stores'; +import repoPrevDirectory from '~/ide/components/repo_prev_directory.vue'; import { resetStore } from '../helpers'; describe('RepoPrevDirectory', () => { diff --git a/spec/javascripts/repo/components/repo_preview_spec.js b/spec/javascripts/repo/components/repo_preview_spec.js index 8d1a87494cf..e90837e4cb2 100644 --- a/spec/javascripts/repo/components/repo_preview_spec.js +++ b/spec/javascripts/repo/components/repo_preview_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoPreview from '~/repo/components/repo_preview.vue'; +import store from '~/ide/stores'; +import repoPreview from '~/ide/components/repo_preview.vue'; import { file, resetStore } from '../helpers'; describe('RepoPreview', () => { diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js deleted file mode 100644 index df7cf8aabbb..00000000000 --- a/spec/javascripts/repo/components/repo_sidebar_spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import Vue from 'vue'; -import store from '~/repo/stores'; -import repoSidebar from '~/repo/components/repo_sidebar.vue'; -import { file, resetStore } from '../helpers'; - -describe('RepoSidebar', () => { - let vm; - - beforeEach(() => { - const RepoSidebar = Vue.extend(repoSidebar); - - vm = new RepoSidebar({ - store, - }); - - vm.$store.state.isRoot = true; - vm.$store.state.tree.push(file()); - - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - - resetStore(vm.$store); - }); - - it('renders a sidebar', () => { - const thead = vm.$el.querySelector('thead'); - const tbody = vm.$el.querySelector('tbody'); - - expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy(); - expect(thead.querySelector('.name').textContent.trim()).toEqual('Name'); - expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit'); - expect(thead.querySelector('.last-update').textContent.trim()).toEqual('Last update'); - expect(tbody.querySelector('.repo-file-options')).toBeFalsy(); - expect(tbody.querySelector('.prev-directory')).toBeFalsy(); - expect(tbody.querySelector('.loading-file')).toBeFalsy(); - expect(tbody.querySelector('.file')).toBeTruthy(); - }); - - it('renders 5 loading files if tree is loading', (done) => { - vm.$store.state.tree = []; - vm.$store.state.loading = true; - - Vue.nextTick(() => { - expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); - - done(); - }); - }); - - it('renders a prev directory if is not root', (done) => { - vm.$store.state.isRoot = false; - - Vue.nextTick(() => { - expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy(); - - done(); - }); - }); -}); diff --git a/spec/javascripts/repo/components/repo_spec.js b/spec/javascripts/repo/components/repo_spec.js deleted file mode 100644 index b32d2c13af8..00000000000 --- a/spec/javascripts/repo/components/repo_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import store from '~/repo/stores'; -import repo from '~/repo/components/repo.vue'; -import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; -import { file, resetStore } from '../helpers'; - -describe('repo component', () => { - let vm; - - beforeEach(() => { - const Component = Vue.extend(repo); - - vm = createComponentWithStore(Component, store).$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.tree.push(file()); - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.panel-right')).toBeNull(); - - done(); - }); - }); -}); diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js index 7d2174196c9..507bca983df 100644 --- a/spec/javascripts/repo/components/repo_tab_spec.js +++ b/spec/javascripts/repo/components/repo_tab_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoTab from '~/repo/components/repo_tab.vue'; +import store from '~/ide/stores'; +import repoTab from '~/ide/components/repo_tab.vue'; import { file, resetStore } from '../helpers'; describe('RepoTab', () => { @@ -31,16 +31,16 @@ describe('RepoTab', () => { expect(name.textContent.trim()).toEqual(vm.tab.name); }); - it('calls setFileActive when clicking tab', () => { + it('fires clickFile when the link is clicked', () => { vm = createComponent({ tab: file(), }); - spyOn(vm, 'setFileActive'); + spyOn(vm, 'clickFile'); vm.$el.click(); - expect(vm.setFileActive).toHaveBeenCalledWith(vm.tab); + expect(vm.clickFile).toHaveBeenCalledWith(vm.tab); }); it('calls closeFile when clicking close button', () => { diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js index 1fb2242c051..0beaf643793 100644 --- a/spec/javascripts/repo/components/repo_tabs_spec.js +++ b/spec/javascripts/repo/components/repo_tabs_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import repoTabs from '~/repo/components/repo_tabs.vue'; +import store from '~/ide/stores'; +import repoTabs from '~/ide/components/repo_tabs.vue'; import { file, resetStore } from '../helpers'; describe('RepoTabs', () => { diff --git a/spec/javascripts/repo/helpers.js b/spec/javascripts/repo/helpers.js index 820a44992b4..ac43d221198 100644 --- a/spec/javascripts/repo/helpers.js +++ b/spec/javascripts/repo/helpers.js @@ -1,5 +1,5 @@ -import { decorateData } from '~/repo/stores/utils'; -import state from '~/repo/stores/state'; +import { decorateData } from '~/ide/stores/utils'; +import state from '~/ide/stores/state'; export const resetStore = (store) => { store.replaceState(state()); @@ -12,4 +12,5 @@ export const file = (name = 'name', id = name, type = '') => decorateData({ url: 'url', name, path: name, + lastCommit: {}, }); diff --git a/spec/javascripts/repo/lib/common/disposable_spec.js b/spec/javascripts/repo/lib/common/disposable_spec.js index 62c3913bf4d..af12ca15369 100644 --- a/spec/javascripts/repo/lib/common/disposable_spec.js +++ b/spec/javascripts/repo/lib/common/disposable_spec.js @@ -1,4 +1,4 @@ -import Disposable from '~/repo/lib/common/disposable'; +import Disposable from '~/ide/lib/common/disposable'; describe('Multi-file editor library disposable class', () => { let instance; diff --git a/spec/javascripts/repo/lib/common/model_manager_spec.js b/spec/javascripts/repo/lib/common/model_manager_spec.js index 8c134f178c0..563c2e33834 100644 --- a/spec/javascripts/repo/lib/common/model_manager_spec.js +++ b/spec/javascripts/repo/lib/common/model_manager_spec.js @@ -1,6 +1,6 @@ /* global monaco */ -import monacoLoader from '~/repo/monaco_loader'; -import ModelManager from '~/repo/lib/common/model_manager'; +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', () => { diff --git a/spec/javascripts/repo/lib/common/model_spec.js b/spec/javascripts/repo/lib/common/model_spec.js index d41ade237ca..878a4a3f3fe 100644 --- a/spec/javascripts/repo/lib/common/model_spec.js +++ b/spec/javascripts/repo/lib/common/model_spec.js @@ -1,6 +1,6 @@ /* global monaco */ -import monacoLoader from '~/repo/monaco_loader'; -import Model from '~/repo/lib/common/model'; +import monacoLoader from '~/ide/monaco_loader'; +import Model from '~/ide/lib/common/model'; import { file } from '../../helpers'; describe('Multi-file editor library model', () => { diff --git a/spec/javascripts/repo/lib/decorations/controller_spec.js b/spec/javascripts/repo/lib/decorations/controller_spec.js index 2e32e8fa0bd..fea12d74dca 100644 --- a/spec/javascripts/repo/lib/decorations/controller_spec.js +++ b/spec/javascripts/repo/lib/decorations/controller_spec.js @@ -1,8 +1,8 @@ /* global monaco */ -import monacoLoader from '~/repo/monaco_loader'; -import editor from '~/repo/lib/editor'; -import DecorationsController from '~/repo/lib/decorations/controller'; -import Model from '~/repo/lib/common/model'; +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', () => { diff --git a/spec/javascripts/repo/lib/diff/controller_spec.js b/spec/javascripts/repo/lib/diff/controller_spec.js index ed62e28d3a3..1d55c165260 100644 --- a/spec/javascripts/repo/lib/diff/controller_spec.js +++ b/spec/javascripts/repo/lib/diff/controller_spec.js @@ -1,10 +1,10 @@ /* global monaco */ -import monacoLoader from '~/repo/monaco_loader'; -import editor from '~/repo/lib/editor'; -import ModelManager from '~/repo/lib/common/model_manager'; -import DecorationsController from '~/repo/lib/decorations/controller'; -import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/repo/lib/diff/controller'; -import { computeDiff } from '~/repo/lib/diff/diff'; +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', () => { diff --git a/spec/javascripts/repo/lib/diff/diff_spec.js b/spec/javascripts/repo/lib/diff/diff_spec.js index 3269ec5d2c9..57f3ac3d365 100644 --- a/spec/javascripts/repo/lib/diff/diff_spec.js +++ b/spec/javascripts/repo/lib/diff/diff_spec.js @@ -1,4 +1,4 @@ -import { computeDiff } from '~/repo/lib/diff/diff'; +import { computeDiff } from '~/ide/lib/diff/diff'; describe('Multi-file editor library diff calculator', () => { describe('computeDiff', () => { diff --git a/spec/javascripts/repo/lib/editor_options_spec.js b/spec/javascripts/repo/lib/editor_options_spec.js index b4887d063ed..edbf5450dce 100644 --- a/spec/javascripts/repo/lib/editor_options_spec.js +++ b/spec/javascripts/repo/lib/editor_options_spec.js @@ -1,4 +1,4 @@ -import editorOptions from '~/repo/lib/editor_options'; +import editorOptions from '~/ide/lib/editor_options'; describe('Multi-file editor library editor options', () => { it('returns an array', () => { diff --git a/spec/javascripts/repo/lib/editor_spec.js b/spec/javascripts/repo/lib/editor_spec.js index cd32832a232..8d51d48a782 100644 --- a/spec/javascripts/repo/lib/editor_spec.js +++ b/spec/javascripts/repo/lib/editor_spec.js @@ -1,6 +1,6 @@ /* global monaco */ -import monacoLoader from '~/repo/monaco_loader'; -import editor from '~/repo/lib/editor'; +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; import { file } from '../helpers'; describe('Multi-file editor library', () => { diff --git a/spec/javascripts/repo/monaco_loader_spec.js b/spec/javascripts/repo/monaco_loader_spec.js index 887a80160fc..b8ac36972aa 100644 --- a/spec/javascripts/repo/monaco_loader_spec.js +++ b/spec/javascripts/repo/monaco_loader_spec.js @@ -1,5 +1,5 @@ import monacoContext from 'monaco-editor/dev/vs/loader'; -import monacoLoader from '~/repo/monaco_loader'; +import monacoLoader from '~/ide/monaco_loader'; describe('MonacoLoader', () => { it('calls require.config and exports require', () => { diff --git a/spec/javascripts/repo/stores/actions/branch_spec.js b/spec/javascripts/repo/stores/actions/branch_spec.js index af9d6835a67..00d16fd790d 100644 --- a/spec/javascripts/repo/stores/actions/branch_spec.js +++ b/spec/javascripts/repo/stores/actions/branch_spec.js @@ -1,5 +1,5 @@ -import store from '~/repo/stores'; -import service from '~/repo/services'; +import store from '~/ide/stores'; +import service from '~/ide/services'; import { resetStore } from '../../helpers'; describe('Multi-file store branch actions', () => { @@ -16,19 +16,25 @@ describe('Multi-file store branch actions', () => { })); spyOn(history, 'pushState'); - store.state.project.id = 2; - store.state.currentBranch = 'testing'; + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'testing'; + store.state.projects.abcproject = { + branches: { + master: { + workingReference: '1', + }, + }, + }; }); it('creates new branch', (done) => { store.dispatch('createNewBranch', 'master') .then(() => { - expect(store.state.currentBranch).toBe('testing'); - expect(service.createBranch).toHaveBeenCalledWith(2, { + expect(store.state.currentBranchId).toBe('testing'); + expect(service.createBranch).toHaveBeenCalledWith('abcproject', { branch: 'master', ref: 'testing', }); - expect(history.pushState).toHaveBeenCalled(); done(); }) diff --git a/spec/javascripts/repo/stores/actions/file_spec.js b/spec/javascripts/repo/stores/actions/file_spec.js index 099c0556e71..8ce01d3bf12 100644 --- a/spec/javascripts/repo/stores/actions/file_spec.js +++ b/spec/javascripts/repo/stores/actions/file_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import store from '~/repo/stores'; -import service from '~/repo/services'; +import store from '~/ide/stores'; +import service from '~/ide/services'; import { file, resetStore } from '../../helpers'; describe('Multi-file store file actions', () => { @@ -24,8 +24,6 @@ describe('Multi-file store file actions', () => { localFile.parentTreeUrl = 'parentTreeUrl'; store.state.openFiles.push(localFile); - - spyOn(history, 'pushState'); }); afterEach(() => { @@ -82,15 +80,6 @@ describe('Multi-file store file actions', () => { }).catch(done.fail); }); - it('calls pushState when no open files are left', (done) => { - store.dispatch('closeFile', { file: localFile }) - .then(() => { - expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'parentTreeUrl'); - - done(); - }).catch(done.fail); - }); - it('sets next file as active', (done) => { const f = file(); store.state.openFiles.push(f); @@ -322,8 +311,26 @@ describe('Multi-file store file actions', () => { }); describe('createTempFile', () => { + let projectTree; + beforeEach(() => { document.body.innerHTML += '
'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + branches: { + master: { + workingReference: '1', + }, + }, + }; + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + projectTree = store.state.trees['abcproject/mybranch']; }); afterEach(() => { @@ -332,11 +339,13 @@ describe('Multi-file store file actions', () => { it('creates temp file', (done) => { store.dispatch('createTempFile', { - tree: store.state, name: 'test', + projectId: 'abcproject', + branchId: 'mybranch', + parent: projectTree, }).then((f) => { expect(f.tempFile).toBeTruthy(); - expect(store.state.tree.length).toBe(1); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1); done(); }).catch(done.fail); @@ -344,8 +353,10 @@ describe('Multi-file store file actions', () => { it('adds tmp file to open files', (done) => { store.dispatch('createTempFile', { - tree: store.state, name: 'test', + projectId: 'abcproject', + branchId: 'mybranch', + parent: projectTree, }).then((f) => { expect(store.state.openFiles.length).toBe(1); expect(store.state.openFiles[0].name).toBe(f.name); @@ -356,8 +367,10 @@ describe('Multi-file store file actions', () => { it('sets tmp file as active', (done) => { store.dispatch('createTempFile', { - tree: store.state, name: 'test', + projectId: 'abcproject', + branchId: 'mybranch', + parent: projectTree, }).then((f) => { expect(f.active).toBeTruthy(); @@ -367,8 +380,10 @@ describe('Multi-file store file actions', () => { it('enters edit mode if file is not base64', (done) => { store.dispatch('createTempFile', { - tree: store.state, name: 'test', + projectId: 'abcproject', + branchId: 'mybranch', + parent: projectTree, }).then(() => { expect(store.state.editMode).toBeTruthy(); @@ -376,24 +391,14 @@ describe('Multi-file store file actions', () => { }).catch(done.fail); }); - it('does not enter edit mode if file is base64', (done) => { - store.dispatch('createTempFile', { - tree: store.state, - name: 'test', - base64: true, - }).then(() => { - expect(store.state.editMode).toBeFalsy(); - - done(); - }).catch(done.fail); - }); - it('creates flash message is file already exists', (done) => { - store.state.tree.push(file('test', '1', 'blob')); + store.state.trees['abcproject/mybranch'].tree.push(file('test', '1', 'blob')); store.dispatch('createTempFile', { - tree: store.state, name: 'test', + projectId: 'abcproject', + branchId: 'mybranch', + parent: projectTree, }).then(() => { expect(document.querySelector('.flash-alert')).not.toBeNull(); @@ -402,11 +407,13 @@ describe('Multi-file store file actions', () => { }); it('increases level of file', (done) => { - store.state.level = 1; + store.state.trees['abcproject/mybranch'].level = 1; store.dispatch('createTempFile', { - tree: store.state, name: 'test', + projectId: 'abcproject', + branchId: 'mybranch', + parent: projectTree, }).then((f) => { expect(f.level).toBe(2); diff --git a/spec/javascripts/repo/stores/actions/tree_spec.js b/spec/javascripts/repo/stores/actions/tree_spec.js index 2bbc49d5a9f..65351dbb7d9 100644 --- a/spec/javascripts/repo/stores/actions/tree_spec.js +++ b/spec/javascripts/repo/stores/actions/tree_spec.js @@ -1,10 +1,30 @@ import Vue from 'vue'; -import * as urlUtils from '~/lib/utils/url_utility'; -import store from '~/repo/stores'; -import service from '~/repo/services'; +import store from '~/ide/stores'; +import service from '~/ide/services'; import { file, resetStore } from '../../helpers'; describe('Multi-file store tree actions', () => { + let projectTree; + + const basicCallParameters = { + endpoint: 'rootEndpoint', + projectId: 'abcproject', + branch: 'master', + }; + + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + afterEach(() => { resetStore(store); }); @@ -24,38 +44,32 @@ describe('Multi-file store tree actions', () => { submodules: [{ name: 'submodule' }], }), })); - spyOn(history, 'pushState'); - - Object.assign(store.state.endpoints, { - rootEndpoint: 'rootEndpoint', - }); }); it('calls service getTreeData', (done) => { - store.dispatch('getTreeData') - .then(() => { - expect(service.getTreeData).toHaveBeenCalledWith('rootEndpoint'); + store.dispatch('getTreeData', basicCallParameters) + .then(() => { + expect(service.getTreeData).toHaveBeenCalledWith('rootEndpoint'); - done(); - }).catch(done.fail); + done(); + }).catch(done.fail); }); it('adds data into tree', (done) => { - store.dispatch('getTreeData') - .then(Vue.nextTick) + store.dispatch('getTreeData', basicCallParameters) .then(() => { - expect(store.state.tree.length).toBe(3); - expect(store.state.tree[0].type).toBe('tree'); - expect(store.state.tree[1].type).toBe('submodule'); - expect(store.state.tree[2].type).toBe('blob'); + projectTree = store.state.trees['abcproject/master']; + expect(projectTree.tree.length).toBe(3); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[1].type).toBe('submodule'); + expect(projectTree.tree[2].type).toBe('blob'); done(); }).catch(done.fail); }); it('sets parent tree URL', (done) => { - store.dispatch('getTreeData') - .then(Vue.nextTick) + store.dispatch('getTreeData', basicCallParameters) .then(() => { expect(store.state.parentTreeUrl).toBe('parent_tree_url'); @@ -64,10 +78,9 @@ describe('Multi-file store tree actions', () => { }); it('sets last commit path', (done) => { - store.dispatch('getTreeData') - .then(Vue.nextTick) + store.dispatch('getTreeData', basicCallParameters) .then(() => { - expect(store.state.lastCommitPath).toBe('last_commit_path'); + expect(store.state.trees['abcproject/master'].lastCommitPath).toBe('last_commit_path'); done(); }).catch(done.fail); @@ -76,8 +89,7 @@ describe('Multi-file store tree actions', () => { it('sets root if not currently at root', (done) => { store.state.isInitialRoot = false; - store.dispatch('getTreeData') - .then(Vue.nextTick) + store.dispatch('getTreeData', basicCallParameters) .then(() => { expect(store.state.isInitialRoot).toBeTruthy(); expect(store.state.isRoot).toBeTruthy(); @@ -87,7 +99,7 @@ describe('Multi-file store tree actions', () => { }); it('sets page title', (done) => { - store.dispatch('getTreeData') + store.dispatch('getTreeData', basicCallParameters) .then(() => { expect(document.title).toBe('test'); @@ -95,40 +107,15 @@ describe('Multi-file store tree actions', () => { }).catch(done.fail); }); - it('toggles loading', (done) => { - store.dispatch('getTreeData') - .then(() => { - expect(store.state.loading).toBeTruthy(); - - return Vue.nextTick(); - }) - .then(() => { - expect(store.state.loading).toBeFalsy(); - - done(); - }).catch(done.fail); - }); - - it('calls pushState with endpoint', (done) => { - store.dispatch('getTreeData') - .then(Vue.nextTick) - .then(() => { - expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'rootEndpoint'); - - done(); - }).catch(done.fail); - }); - it('calls getLastCommitData if prevLastCommitPath is not null', (done) => { const getLastCommitDataSpy = jasmine.createSpy('getLastCommitData'); const oldGetLastCommitData = store._actions.getLastCommitData; // eslint-disable-line store._actions.getLastCommitData = [getLastCommitDataSpy]; // eslint-disable-line store.state.prevLastCommitPath = 'test'; - store.dispatch('getTreeData') - .then(Vue.nextTick) + store.dispatch('getTreeData', basicCallParameters) .then(() => { - expect(getLastCommitDataSpy).toHaveBeenCalledWith(store.state); + expect(getLastCommitDataSpy).toHaveBeenCalledWith(projectTree); store._actions.getLastCommitData = oldGetLastCommitData; // eslint-disable-line @@ -149,6 +136,8 @@ describe('Multi-file store tree actions', () => { store._actions.getTreeData = [getTreeDataSpy]; // eslint-disable-line tree = { + projectId: 'abcproject', + branchId: 'master', opened: false, tree: [], }; @@ -175,10 +164,11 @@ describe('Multi-file store tree actions', () => { tree, }).then(() => { expect(getTreeDataSpy).toHaveBeenCalledWith({ + projectId: 'abcproject', + branch: 'master', endpoint: 'test', tree, }); - expect(store.state.previousUrl).toBe('test'); done(); }).catch(done.fail); @@ -199,155 +189,29 @@ describe('Multi-file store tree actions', () => { done(); }).catch(done.fail); }); - - it('pushes new state', (done) => { - spyOn(history, 'pushState'); - Object.assign(tree, { - opened: true, - parentTreeUrl: 'testing', - }); - - store.dispatch('toggleTreeOpen', { - endpoint: 'test', - tree, - }).then(() => { - expect(history.pushState).toHaveBeenCalledWith(jasmine.anything(), '', 'testing'); - - done(); - }).catch(done.fail); - }); - }); - - describe('clickedTreeRow', () => { - describe('tree', () => { - let toggleTreeOpenSpy; - let oldToggleTreeOpen; - - beforeEach(() => { - toggleTreeOpenSpy = jasmine.createSpy('toggleTreeOpen'); - - oldToggleTreeOpen = store._actions.toggleTreeOpen; // eslint-disable-line - store._actions.toggleTreeOpen = [toggleTreeOpenSpy]; // eslint-disable-line - }); - - afterEach(() => { - store._actions.toggleTreeOpen = oldToggleTreeOpen; // eslint-disable-line - }); - - it('opens tree', (done) => { - const tree = { - url: 'a', - type: 'tree', - }; - - store.dispatch('clickedTreeRow', tree) - .then(() => { - expect(toggleTreeOpenSpy).toHaveBeenCalledWith({ - endpoint: tree.url, - tree, - }); - - done(); - }).catch(done.fail); - }); - }); - - describe('submodule', () => { - let row; - - beforeEach(() => { - spyOn(urlUtils, 'visitUrl'); - - row = { - url: 'submoduleurl', - type: 'submodule', - loading: false, - }; - }); - - it('toggles loading for row', (done) => { - store.dispatch('clickedTreeRow', row) - .then(() => { - expect(row.loading).toBeTruthy(); - - done(); - }).catch(done.fail); - }); - - it('opens submodule URL', (done) => { - store.dispatch('clickedTreeRow', row) - .then(() => { - expect(urlUtils.visitUrl).toHaveBeenCalledWith('submoduleurl'); - - done(); - }).catch(done.fail); - }); - }); - - describe('blob', () => { - let row; - - beforeEach(() => { - row = { - type: 'blob', - opened: false, - }; - }); - - it('calls getFileData', (done) => { - const getFileDataSpy = jasmine.createSpy('getFileData'); - const oldGetFileData = store._actions.getFileData; // eslint-disable-line - store._actions.getFileData = [getFileDataSpy]; // eslint-disable-line - - store.dispatch('clickedTreeRow', row) - .then(() => { - expect(getFileDataSpy).toHaveBeenCalledWith(row); - - store._actions.getFileData = oldGetFileData; // eslint-disable-line - - done(); - }).catch(done.fail); - }); - - it('calls setFileActive when file is opened', (done) => { - const setFileActiveSpy = jasmine.createSpy('setFileActive'); - const oldSetFileActive = store._actions.setFileActive; // eslint-disable-line - store._actions.setFileActive = [setFileActiveSpy]; // eslint-disable-line - - row.opened = true; - - store.dispatch('clickedTreeRow', row) - .then(() => { - expect(setFileActiveSpy).toHaveBeenCalledWith(row); - - store._actions.setFileActive = oldSetFileActive; // eslint-disable-line - - done(); - }).catch(done.fail); - }); - }); }); describe('createTempTree', () => { - it('creates temp tree', (done) => { - store.dispatch('createTempTree', 'test') - .then(() => { - expect(store.state.tree[0].tempFile).toBeTruthy(); - expect(store.state.tree[0].name).toBe('test'); - expect(store.state.tree[0].type).toBe('tree'); - - done(); - }).catch(done.fail); + beforeEach(() => { + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + projectTree = store.state.trees['abcproject/mybranch']; }); - it('creates .gitkeep file in temp tree', (done) => { - store.dispatch('createTempTree', 'test') - .then(() => { - expect(store.state.tree[0].tree[0].tempFile).toBeTruthy(); - expect(store.state.tree[0].tree[0].name).toBe('.gitkeep'); + it('creates temp tree', (done) => { + store.dispatch('createTempTree', { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + name: 'test', + parent: projectTree, + }) + .then(() => { + expect(projectTree.tree[0].name).toBe('test'); + expect(projectTree.tree[0].type).toBe('tree'); - done(); - }).catch(done.fail); + done(); + }).catch(done.fail); }); it('creates new folder inside another tree', (done) => { @@ -357,35 +221,46 @@ describe('Multi-file store tree actions', () => { tree: [], }; - store.state.tree.push(tree); + projectTree.tree.push(tree); - store.dispatch('createTempTree', 'testing/test') - .then(() => { - expect(store.state.tree[0].name).toBe('testing'); - expect(store.state.tree[0].tree[0].tempFile).toBeTruthy(); - expect(store.state.tree[0].tree[0].name).toBe('test'); - expect(store.state.tree[0].tree[0].type).toBe('tree'); + store.dispatch('createTempTree', { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + name: 'testing/test', + parent: projectTree, + }) + .then(() => { + expect(projectTree.tree[0].name).toBe('testing'); + expect(projectTree.tree[0].tree[0].tempFile).toBeTruthy(); + expect(projectTree.tree[0].tree[0].name).toBe('test'); + expect(projectTree.tree[0].tree[0].type).toBe('tree'); - done(); - }).catch(done.fail); + done(); + }).catch(done.fail); }); it('does not create new tree if already exists', (done) => { const tree = { type: 'tree', name: 'testing', + endpoint: 'test', tree: [], }; - store.state.tree.push(tree); + projectTree.tree.push(tree); - store.dispatch('createTempTree', 'testing/test') - .then(() => { - expect(store.state.tree[0].name).toBe('testing'); - expect(store.state.tree[0].tempFile).toBeUndefined(); + store.dispatch('createTempTree', { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + name: 'testing/test', + parent: projectTree, + }) + .then(() => { + expect(projectTree.tree[0].name).toBe('testing'); + expect(projectTree.tree[0].tempFile).toBeUndefined(); - done(); - }).catch(done.fail); + done(); + }).catch(done.fail); }); }); @@ -405,12 +280,17 @@ describe('Multi-file store tree actions', () => { }]), })); - store.state.tree.push(file('testing', '1', 'tree')); - store.state.lastCommitPath = 'lastcommitpath'; + 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') + store.dispatch('getLastCommitData', projectTree) .then(() => { expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath'); @@ -419,22 +299,22 @@ describe('Multi-file store tree actions', () => { }); it('updates trees last commit data', (done) => { - store.dispatch('getLastCommitData') - .then(Vue.nextTick) + store.dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) .then(() => { - expect(store.state.tree[0].lastCommit.message).toBe('commit message'); + expect(projectTree.tree[0].lastCommit.message).toBe('commit message'); done(); }).catch(done.fail); }); it('does not update entry if not found', (done) => { - store.state.tree[0].name = 'a'; + projectTree.tree[0].name = 'a'; - store.dispatch('getLastCommitData') + store.dispatch('getLastCommitData', projectTree) .then(Vue.nextTick) .then(() => { - expect(store.state.tree[0].lastCommit.message).not.toBe('commit message'); + expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message'); done(); }).catch(done.fail); diff --git a/spec/javascripts/repo/stores/actions_spec.js b/spec/javascripts/repo/stores/actions_spec.js index 21d87e46216..0b0d34f072a 100644 --- a/spec/javascripts/repo/stores/actions_spec.js +++ b/spec/javascripts/repo/stores/actions_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import * as urlUtils from '~/lib/utils/url_utility'; -import store from '~/repo/stores'; -import service from '~/repo/services'; +import store from '~/ide/stores'; +import service from '~/ide/services'; import { resetStore, file } from '../helpers'; describe('Multi-file store actions', () => { @@ -110,6 +110,7 @@ describe('Multi-file store actions', () => { it('can force closed if there are changed files', (done) => { store.state.editMode = true; + store.state.openFiles.push(file()); store.state.openFiles[0].changed = true; @@ -125,7 +126,6 @@ describe('Multi-file store actions', () => { it('discards file changes', (done) => { const f = file(); store.state.editMode = true; - store.state.tree.push(f); store.state.openFiles.push(f); f.changed = true; @@ -141,8 +141,6 @@ describe('Multi-file store actions', () => { describe('toggleBlobView', () => { it('sets edit mode view if in edit mode', (done) => { - store.state.editMode = true; - store.dispatch('toggleBlobView') .then(() => { expect(store.state.currentBlobView).toBe('repo-editor'); @@ -153,6 +151,8 @@ describe('Multi-file store actions', () => { }); it('sets preview mode view if not in edit mode', (done) => { + store.state.editMode = false; + store.dispatch('toggleBlobView') .then(() => { expect(store.state.currentBlobView).toBe('repo-preview'); @@ -165,9 +165,15 @@ describe('Multi-file store actions', () => { describe('checkCommitStatus', () => { beforeEach(() => { - store.state.project.id = 2; - store.state.currentBranch = 'master'; - store.state.currentRef = '1'; + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + branches: { + master: { + workingReference: '1', + }, + }, + }; }); it('calls service', (done) => { @@ -177,7 +183,7 @@ describe('Multi-file store actions', () => { store.dispatch('checkCommitStatus') .then(() => { - expect(service.getBranchData).toHaveBeenCalledWith(2, 'master'); + expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master'); done(); }) @@ -221,7 +227,17 @@ describe('Multi-file store actions', () => { document.body.innerHTML += '
'; - store.state.project.id = 123; + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'webUrl', + branches: { + master: { + workingReference: '1', + }, + }, + }; + payload = { branch: 'master', }; @@ -248,7 +264,7 @@ describe('Multi-file store actions', () => { it('calls service', (done) => { store.dispatch('commitChanges', { payload, newMr: false }) .then(() => { - expect(service.commit).toHaveBeenCalledWith(123, payload); + expect(service.commit).toHaveBeenCalledWith('abcproject', payload); done(); }).catch(done.fail); @@ -284,17 +300,6 @@ describe('Multi-file store actions', () => { }).catch(done.fail); }); - it('toggles edit mode', (done) => { - store.state.editMode = true; - - store.dispatch('commitChanges', { payload, newMr: false }) - .then(() => { - expect(store.state.editMode).toBeFalsy(); - - done(); - }).catch(done.fail); - }); - it('closes all files', (done) => { store.state.openFiles.push(file()); store.state.openFiles[0].opened = true; @@ -317,23 +322,12 @@ describe('Multi-file store actions', () => { }).catch(done.fail); }); - it('updates commit ref', (done) => { - store.dispatch('commitChanges', { payload, newMr: false }) - .then(() => { - expect(store.state.currentRef).toBe('123456'); - - done(); - }).catch(done.fail); - }); - it('redirects to new merge request page', (done) => { spyOn(urlUtils, 'visitUrl'); - store.state.endpoints.newMergeRequestUrl = 'newMergeRequestUrl?branch='; - store.dispatch('commitChanges', { payload, newMr: true }) .then(() => { - expect(urlUtils.visitUrl).toHaveBeenCalledWith('newMergeRequestUrl?branch=master'); + expect(urlUtils.visitUrl).toHaveBeenCalledWith('webUrl/merge_requests/new?merge_request%5Bsource_branch%5D=master'); done(); }).catch(done.fail); @@ -363,15 +357,30 @@ describe('Multi-file store actions', () => { }); describe('createTempEntry', () => { + beforeEach(() => { + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + store.state.projects.abcproject = { + web_url: '', + }; + }); + it('creates a temp tree', (done) => { + const projectTree = store.state.trees['abcproject/mybranch']; + store.dispatch('createTempEntry', { + projectId: 'abcproject', + branchId: 'mybranch', + parent: projectTree, name: 'test', type: 'tree', }) .then(() => { - expect(store.state.tree.length).toBe(1); - expect(store.state.tree[0].tempFile).toBeTruthy(); - expect(store.state.tree[0].type).toBe('tree'); + const baseTree = projectTree.tree; + expect(baseTree.length).toBe(1); + expect(baseTree[0].tempFile).toBeTruthy(); + expect(baseTree[0].type).toBe('tree'); done(); }) @@ -379,14 +388,20 @@ describe('Multi-file store actions', () => { }); it('creates temp file', (done) => { + const projectTree = store.state.trees['abcproject/mybranch']; + store.dispatch('createTempEntry', { + projectId: 'abcproject', + branchId: 'mybranch', + parent: projectTree, name: 'test', type: 'blob', }) .then(() => { - expect(store.state.tree.length).toBe(1); - expect(store.state.tree[0].tempFile).toBeTruthy(); - expect(store.state.tree[0].type).toBe('blob'); + const baseTree = projectTree.tree; + expect(baseTree.length).toBe(1); + expect(baseTree[0].tempFile).toBeTruthy(); + expect(baseTree[0].type).toBe('blob'); done(); }) diff --git a/spec/javascripts/repo/stores/getters_spec.js b/spec/javascripts/repo/stores/getters_spec.js index 952b8ec3a59..d0d5934f29a 100644 --- a/spec/javascripts/repo/stores/getters_spec.js +++ b/spec/javascripts/repo/stores/getters_spec.js @@ -1,5 +1,5 @@ -import * as getters from '~/repo/stores/getters'; -import state from '~/repo/stores/state'; +import * as getters from '~/ide/stores/getters'; +import state from '~/ide/stores/state'; import { file } from '../helpers'; describe('Multi-file store getters', () => { @@ -9,20 +9,6 @@ describe('Multi-file store getters', () => { localState = state(); }); - describe('treeList', () => { - it('returns flat tree list', () => { - localState.tree.push(file('1')); - localState.tree[0].tree.push(file('2')); - localState.tree[0].tree[0].tree.push(file('3')); - - const treeList = getters.treeList(localState); - - expect(treeList.length).toBe(3); - expect(treeList[1].name).toBe(localState.tree[0].tree[0].name); - expect(treeList[2].name).toBe(localState.tree[0].tree[0].tree[0].name); - }); - }); - describe('changedFiles', () => { it('returns a list of changed opened files', () => { localState.openFiles.push(file()); @@ -49,7 +35,7 @@ describe('Multi-file store getters', () => { localState.openFiles.push(file()); localState.openFiles.push(file('active')); - expect(getters.activeFile(localState)).toBeUndefined(); + expect(getters.activeFile(localState)).toBeNull(); }); }); @@ -67,18 +53,6 @@ describe('Multi-file store getters', () => { }); }); - describe('isCollapsed', () => { - it('returns true if state has open files', () => { - localState.openFiles.push(file()); - - expect(getters.isCollapsed(localState)).toBeTruthy(); - }); - - it('returns false if state has no open files', () => { - expect(getters.isCollapsed(localState)).toBeFalsy(); - }); - }); - describe('canEditFile', () => { beforeEach(() => { localState.onTopOfBranch = true; @@ -109,12 +83,6 @@ describe('Multi-file store getters', () => { expect(getters.canEditFile(localState)).toBeFalsy(); }); - - it('returns false if user can commit but on a branch', () => { - localState.onTopOfBranch = false; - - expect(getters.canEditFile(localState)).toBeFalsy(); - }); }); describe('modifiedFiles', () => { diff --git a/spec/javascripts/repo/stores/mutations/branch_spec.js b/spec/javascripts/repo/stores/mutations/branch_spec.js index 3c06794d5e3..a7167537ef2 100644 --- a/spec/javascripts/repo/stores/mutations/branch_spec.js +++ b/spec/javascripts/repo/stores/mutations/branch_spec.js @@ -1,5 +1,5 @@ -import mutations from '~/repo/stores/mutations/branch'; -import state from '~/repo/stores/state'; +import mutations from '~/ide/stores/mutations/branch'; +import state from '~/ide/stores/state'; describe('Multi-file store branch mutations', () => { let localState; @@ -12,7 +12,7 @@ describe('Multi-file store branch mutations', () => { it('sets currentBranch', () => { mutations.SET_CURRENT_BRANCH(localState, 'master'); - expect(localState.currentBranch).toBe('master'); + expect(localState.currentBranchId).toBe('master'); }); }); }); diff --git a/spec/javascripts/repo/stores/mutations/file_spec.js b/spec/javascripts/repo/stores/mutations/file_spec.js index 2f2835dde1f..947a60587df 100644 --- a/spec/javascripts/repo/stores/mutations/file_spec.js +++ b/spec/javascripts/repo/stores/mutations/file_spec.js @@ -1,5 +1,5 @@ -import mutations from '~/repo/stores/mutations/file'; -import state from '~/repo/stores/state'; +import mutations from '~/ide/stores/mutations/file'; +import state from '~/ide/stores/state'; import { file } from '../../helpers'; describe('Multi-file store file mutations', () => { diff --git a/spec/javascripts/repo/stores/mutations/tree_spec.js b/spec/javascripts/repo/stores/mutations/tree_spec.js index 1c76cfed9c8..cf1248ba28b 100644 --- a/spec/javascripts/repo/stores/mutations/tree_spec.js +++ b/spec/javascripts/repo/stores/mutations/tree_spec.js @@ -1,5 +1,5 @@ -import mutations from '~/repo/stores/mutations/tree'; -import state from '~/repo/stores/state'; +import mutations from '~/ide/stores/mutations/tree'; +import state from '~/ide/stores/state'; import { file } from '../../helpers'; describe('Multi-file store tree mutations', () => { diff --git a/spec/javascripts/repo/stores/mutations_spec.js b/spec/javascripts/repo/stores/mutations_spec.js index d1c9885e01d..5fd8ad94972 100644 --- a/spec/javascripts/repo/stores/mutations_spec.js +++ b/spec/javascripts/repo/stores/mutations_spec.js @@ -1,5 +1,5 @@ -import mutations from '~/repo/stores/mutations'; -import state from '~/repo/stores/state'; +import mutations from '~/ide/stores/mutations'; +import state from '~/ide/stores/state'; import { file } from '../helpers'; describe('Multi-file store mutations', () => { @@ -65,11 +65,11 @@ describe('Multi-file store mutations', () => { it('toggles editMode', () => { mutations.TOGGLE_EDIT_MODE(localState); - expect(localState.editMode).toBeTruthy(); + expect(localState.editMode).toBeFalsy(); mutations.TOGGLE_EDIT_MODE(localState); - expect(localState.editMode).toBeFalsy(); + expect(localState.editMode).toBeTruthy(); }); }); @@ -85,14 +85,6 @@ describe('Multi-file store mutations', () => { }); }); - describe('SET_COMMIT_REF', () => { - it('sets currentRef', () => { - mutations.SET_COMMIT_REF(localState, '123'); - - expect(localState.currentRef).toBe('123'); - }); - }); - describe('SET_ROOT', () => { it('sets isRoot & initialRoot', () => { mutations.SET_ROOT(localState, true); @@ -107,11 +99,27 @@ describe('Multi-file store mutations', () => { }); }); - describe('SET_PREVIOUS_URL', () => { - it('sets previousUrl', () => { - mutations.SET_PREVIOUS_URL(localState, 'testing'); + 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.previousUrl).toBe('testing'); + expect(localState.rightPanelCollapsed).toBeFalsy(); }); }); }); diff --git a/spec/javascripts/repo/stores/utils_spec.js b/spec/javascripts/repo/stores/utils_spec.js index 37287c587d7..89745a2029e 100644 --- a/spec/javascripts/repo/stores/utils_spec.js +++ b/spec/javascripts/repo/stores/utils_spec.js @@ -1,4 +1,6 @@ -import * as utils from '~/repo/stores/utils'; +import * as utils from '~/ide/stores/utils'; +import state from '~/ide/stores/state'; +import { file } from '../helpers'; describe('Multi-file store utils', () => { describe('setPageTitle', () => { @@ -9,13 +11,28 @@ describe('Multi-file store utils', () => { }); }); - describe('pushState', () => { - it('calls history.pushState', () => { - spyOn(history, 'pushState'); + describe('treeList', () => { + let localState; - utils.pushState('test'); + beforeEach(() => { + localState = state(); + }); + + it('returns flat tree list', () => { + localState.trees = []; + localState.trees['abcproject/mybranch'] = { + tree: [], + }; + const baseTree = localState.trees['abcproject/mybranch'].tree; + baseTree.push(file('1')); + baseTree[0].tree.push(file('2')); + baseTree[0].tree[0].tree.push(file('3')); + + const treeList = utils.treeList(localState, 'abcproject/mybranch'); - expect(history.pushState).toHaveBeenCalledWith({ url: 'test' }, '', 'test'); + expect(treeList.length).toBe(3); + expect(treeList[1].name).toBe(baseTree[0].tree[0].name); + expect(treeList[2].name).toBe(baseTree[0].tree[0].tree[0].name); }); }); @@ -52,10 +69,10 @@ describe('Multi-file store utils', () => { }); describe('findIndexOfFile', () => { - let state; + let localState; beforeEach(() => { - state = [{ + localState = [{ path: '1', }, { path: '2', @@ -63,7 +80,7 @@ describe('Multi-file store utils', () => { }); it('finds in the index of an entry by path', () => { - const index = utils.findIndexOfFile(state, { + const index = utils.findIndexOfFile(localState, { path: '2', }); @@ -72,10 +89,10 @@ describe('Multi-file store utils', () => { }); describe('findEntry', () => { - let state; + let localState; beforeEach(() => { - state = { + localState = { tree: [{ type: 'tree', name: 'test', @@ -87,14 +104,14 @@ describe('Multi-file store utils', () => { }); it('returns an entry found by name', () => { - const foundEntry = utils.findEntry(state, 'tree', 'test'); + 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(state, 'blob', 'test'); + const foundEntry = utils.findEntry(localState.tree, 'blob', 'test'); expect(foundEntry).toBeUndefined(); }); diff --git a/yarn.lock b/yarn.lock index 55d0d33c9f2..26e2b790f98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6528,6 +6528,10 @@ vue-resource@^1.3.4: dependencies: got "^7.0.0" +vue-router@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9" + vue-style-loader@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/vue-style-loader/-/vue-style-loader-3.0.3.tgz#623658f81506aef9d121cdc113a4f5c9cac32df7" -- cgit v1.2.1 From e1f5c2b194fa8ee898446517f1802c6e28312b0b Mon Sep 17 00:00:00 2001 From: Mario de la Ossa Date: Mon, 18 Dec 2017 17:08:28 -0600 Subject: Do not show Vue pagination if only one page --- .../vue_shared/components/table_pagination.vue | 8 +++++++- changelogs/unreleased/33609-hide-pagination.yml | 5 +++++ .../vue_shared/components/table_pagination_spec.js | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/33609-hide-pagination.yml diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue index 710452bb3d3..33096b53cf8 100644 --- a/app/assets/javascripts/vue_shared/components/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue @@ -122,11 +122,17 @@ export default { return items; }, + showPagination() { + return this.pageInfo.totalPages > 1; + }, }, };