diff --git a/.rubocop_todo/gitlab/feature_available_usage.yml b/.rubocop_todo/gitlab/feature_available_usage.yml
index 2993409a8283cd3c5afef652349b6136a862b99c..38c54bf41938a35d29b726c212f79333b88b2cbe 100644
--- a/.rubocop_todo/gitlab/feature_available_usage.yml
+++ b/.rubocop_todo/gitlab/feature_available_usage.yml
@@ -27,6 +27,7 @@ Gitlab/FeatureAvailableUsage:
     - 'ee/app/helpers/ee/form_helper.rb'
     - 'ee/app/helpers/ee/graph_helper.rb'
     - 'ee/app/helpers/ee/issues_helper.rb'
+    - 'ee/app/helpers/ee/lock_helper.rb'
     - 'ee/app/helpers/ee/operations_helper.rb'
     - 'ee/app/helpers/ee/projects/incidents_helper.rb'
     - 'ee/app/helpers/ee/projects_helper.rb'
diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml
index 17fc374e1e4ef9d95f1d6864f2b4ce6eaac1a4f3..40e6ec8b7aa03a34b9ea813341527a3690fbb9ba 100644
--- a/.rubocop_todo/layout/line_length.yml
+++ b/.rubocop_todo/layout/line_length.yml
@@ -535,6 +535,7 @@ Layout/LineLength:
     - 'ee/app/helpers/ee/integrations_helper.rb'
     - 'ee/app/helpers/ee/issues_helper.rb'
     - 'ee/app/helpers/ee/labels_helper.rb'
+    - 'ee/app/helpers/ee/lock_helper.rb'
     - 'ee/app/helpers/ee/merge_requests_helper.rb'
     - 'ee/app/helpers/ee/mirror_helper.rb'
     - 'ee/app/helpers/ee/notes_helper.rb'
@@ -4208,6 +4209,7 @@ Layout/LineLength:
     - 'spec/views/projects/settings/ci_cd/_autodevops_form.html.haml_spec.rb'
     - 'spec/views/projects/settings/operations/show.html.haml_spec.rb'
     - 'spec/views/projects/tags/index.html.haml_spec.rb'
+    - 'spec/views/projects/tree/show.html.haml_spec.rb'
     - 'spec/views/shared/milestones/_issuable.html.haml_spec.rb'
     - 'spec/views/shared/projects/_project.html.haml_spec.rb'
     - 'spec/views/shared/snippets/_snippet.html.haml_spec.rb'
diff --git a/.rubocop_todo/lint/unused_method_argument.yml b/.rubocop_todo/lint/unused_method_argument.yml
index d5302716428dc7c4444b218e47f1670011eb87d6..f7995c5ab54b5fa337fced26e31a3253836c64ce 100644
--- a/.rubocop_todo/lint/unused_method_argument.yml
+++ b/.rubocop_todo/lint/unused_method_argument.yml
@@ -204,6 +204,7 @@ Lint/UnusedMethodArgument:
     - 'ee/app/graphql/types/security_orchestration/security_policy_source_type.rb'
     - 'ee/app/graphql/types/vulnerability_detail_type.rb'
     - 'ee/app/graphql/types/vulnerability_location_type.rb'
+    - 'ee/app/helpers/ee/lock_helper.rb'
     - 'ee/app/models/boards/epic_board.rb'
     - 'ee/app/models/burndown.rb'
     - 'ee/app/models/concerns/elastic/application_versioned_search.rb'
diff --git a/.rubocop_todo/rails/helper_instance_variable.yml b/.rubocop_todo/rails/helper_instance_variable.yml
index a72b9e70174433f0a6a8d06c81aad88112e7849f..c4812f05ce4517ab9a3f0be3307fb012f28a3b6a 100644
--- a/.rubocop_todo/rails/helper_instance_variable.yml
+++ b/.rubocop_todo/rails/helper_instance_variable.yml
@@ -59,6 +59,7 @@ Rails/HelperInstanceVariable:
     - 'ee/app/helpers/ee/integrations_helper.rb'
     - 'ee/app/helpers/ee/kerberos_helper.rb'
     - 'ee/app/helpers/ee/labels_helper.rb'
+    - 'ee/app/helpers/ee/lock_helper.rb'
     - 'ee/app/helpers/ee/mirror_helper.rb'
     - 'ee/app/helpers/ee/notes_helper.rb'
     - 'ee/app/helpers/ee/operations_helper.rb'
diff --git a/.rubocop_todo/rspec/context_wording.yml b/.rubocop_todo/rspec/context_wording.yml
index 891abed2ca7a894c67d0a4f01edb4a19bc06f2fa..9f2d6559e8185dee5c66701cf87e76f1779ca040 100644
--- a/.rubocop_todo/rspec/context_wording.yml
+++ b/.rubocop_todo/rspec/context_wording.yml
@@ -246,6 +246,7 @@ RSpec/ContextWording:
     - 'ee/spec/helpers/ee/groups_helper_spec.rb'
     - 'ee/spec/helpers/ee/issuables_helper_spec.rb'
     - 'ee/spec/helpers/ee/issues_helper_spec.rb'
+    - 'ee/spec/helpers/ee/lock_helper_spec.rb'
     - 'ee/spec/helpers/ee/operations_helper_spec.rb'
     - 'ee/spec/helpers/ee/personal_access_tokens_helper_spec.rb'
     - 'ee/spec/helpers/ee/projects/security/api_fuzzing_configuration_helper_spec.rb'
diff --git a/.rubocop_todo/rspec/factory_bot/avoid_create.yml b/.rubocop_todo/rspec/factory_bot/avoid_create.yml
index ba9d700a16dfee0e0caeaac7dd6c6655c3e1d56e..cd51e58ef5266881f1027400c68f2058d3d971dc 100644
--- a/.rubocop_todo/rspec/factory_bot/avoid_create.yml
+++ b/.rubocop_todo/rspec/factory_bot/avoid_create.yml
@@ -31,6 +31,7 @@ RSpec/FactoryBot/AvoidCreate:
     - 'ee/spec/helpers/ee/issuables_helper_spec.rb'
     - 'ee/spec/helpers/ee/issues_helper_spec.rb'
     - 'ee/spec/helpers/ee/labels_helper_spec.rb'
+    - 'ee/spec/helpers/ee/lock_helper_spec.rb'
     - 'ee/spec/helpers/ee/namespace_user_cap_reached_alert_helper_spec.rb'
     - 'ee/spec/helpers/ee/namespaces_helper_spec.rb'
     - 'ee/spec/helpers/ee/operations_helper_spec.rb'
@@ -584,6 +585,7 @@ RSpec/FactoryBot/AvoidCreate:
     - 'spec/views/projects/settings/merge_requests/show.html.haml_spec.rb'
     - 'spec/views/projects/settings/operations/show.html.haml_spec.rb'
     - 'spec/views/projects/tags/index.html.haml_spec.rb'
+    - 'spec/views/projects/tree/show.html.haml_spec.rb'
     - 'spec/views/search/_results.html.haml_spec.rb'
     - 'spec/views/shared/_label_row.html.haml_spec.rb'
     - 'spec/views/shared/issuable/_sidebar.html.haml_spec.rb'
diff --git a/.rubocop_todo/rspec/feature_category.yml b/.rubocop_todo/rspec/feature_category.yml
index 48ef0e0e9d5b897e8d51a5f979e086d55ad7457f..7ceee9c6bd0f482d41c618c0ddacb42a07608c6f 100644
--- a/.rubocop_todo/rspec/feature_category.yml
+++ b/.rubocop_todo/rspec/feature_category.yml
@@ -363,6 +363,7 @@ RSpec/FeatureCategory:
     - 'ee/spec/helpers/ee/groups/settings_helper_spec.rb'
     - 'ee/spec/helpers/ee/hooks_helper_spec.rb'
     - 'ee/spec/helpers/ee/labels_helper_spec.rb'
+    - 'ee/spec/helpers/ee/lock_helper_spec.rb'
     - 'ee/spec/helpers/ee/namespace_user_cap_reached_alert_helper_spec.rb'
     - 'ee/spec/helpers/ee/operations_helper_spec.rb'
     - 'ee/spec/helpers/ee/profiles_helper_spec.rb'
@@ -3965,6 +3966,7 @@ RSpec/FeatureCategory:
     - 'spec/views/projects/settings/integrations/edit.html.haml_spec.rb'
     - 'spec/views/projects/settings/operations/show.html.haml_spec.rb'
     - 'spec/views/projects/tags/index.html.haml_spec.rb'
+    - 'spec/views/projects/tree/show.html.haml_spec.rb'
     - 'spec/views/shared/_label_row.html.haml_spec.rb'
     - 'spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb'
     - 'spec/views/shared/gitlab_version/_security_patch_upgrade_alert.html.haml_spec.rb'
diff --git a/.rubocop_todo/rspec/named_subject.yml b/.rubocop_todo/rspec/named_subject.yml
index 22ee999caad96f4b789fecbe53ffc21a29e0631b..75ecde8c27d8238b14588825ed0ff4cb4a84ff06 100644
--- a/.rubocop_todo/rspec/named_subject.yml
+++ b/.rubocop_todo/rspec/named_subject.yml
@@ -184,6 +184,7 @@ RSpec/NamedSubject:
     - 'ee/spec/helpers/ee/gitlab_routing_helper_spec.rb'
     - 'ee/spec/helpers/ee/groups/group_members_helper_spec.rb'
     - 'ee/spec/helpers/ee/groups_helper_spec.rb'
+    - 'ee/spec/helpers/ee/lock_helper_spec.rb'
     - 'ee/spec/helpers/ee/merge_requests_helper_spec.rb'
     - 'ee/spec/helpers/ee/operations_helper_spec.rb'
     - 'ee/spec/helpers/ee/projects/incidents_helper_spec.rb'
diff --git a/.rubocop_todo/rspec/receive_messages.yml b/.rubocop_todo/rspec/receive_messages.yml
index 1f635a419676180514b9c85a4c5f45cb8612bf15..581c114c0d1d6cd9af745a3904526202aaec2492 100644
--- a/.rubocop_todo/rspec/receive_messages.yml
+++ b/.rubocop_todo/rspec/receive_messages.yml
@@ -25,6 +25,7 @@ RSpec/ReceiveMessages:
     - 'ee/spec/helpers/ee/ide_helper_spec.rb'
     - 'ee/spec/helpers/ee/issuables_helper_spec.rb'
     - 'ee/spec/helpers/ee/issues_helper_spec.rb'
+    - 'ee/spec/helpers/ee/lock_helper_spec.rb'
     - 'ee/spec/helpers/ee/namespaces_helper_spec.rb'
     - 'ee/spec/helpers/ee/registrations_helper_spec.rb'
     - 'ee/spec/helpers/ee/subscribable_banner_helper_spec.rb'
@@ -570,6 +571,7 @@ RSpec/ReceiveMessages:
     - 'spec/views/projects/settings/operations/show.html.haml_spec.rb'
     - 'spec/views/projects/settings/repository/_protected_branches.html.haml_spec.rb'
     - 'spec/views/projects/tags/index.html.haml_spec.rb'
+    - 'spec/views/projects/tree/show.html.haml_spec.rb'
     - 'spec/views/shared/_broadcast_message.html.haml_spec.rb'
     - 'spec/views/shared/projects/_list.html.haml_spec.rb'
     - 'spec/views/shared/projects/_project_card.html.haml_spec.rb'
diff --git a/.rubocop_todo/rspec/subject_declaration.yml b/.rubocop_todo/rspec/subject_declaration.yml
index ec46ac41a7a1f153db1afe82adfadcf84c97a309..052605f94d5cb7f5e69162bf60632f1ed15b8315 100644
--- a/.rubocop_todo/rspec/subject_declaration.yml
+++ b/.rubocop_todo/rspec/subject_declaration.yml
@@ -2,6 +2,7 @@
 RSpec/SubjectDeclaration:
   Exclude:
     - 'ee/spec/finders/app_sec/fuzzing/coverage/corpuses_finder_spec.rb'
+    - 'ee/spec/helpers/ee/lock_helper_spec.rb'
     - 'ee/spec/helpers/nav/new_dropdown_helper_spec.rb'
     - 'ee/spec/lib/ee/api/helpers/notes_helpers_spec.rb'
     - 'ee/spec/lib/gitlab/expiring_subscription_message_spec.rb'
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 074eafce2272802b200d5dcbd4fd3b83eb766ab8..16956a51b0ea6b20a1c2b4d1bbe1e1df0807b7ef 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -72,9 +72,10 @@ const initCodeDropdown = () => {
 
   const { sshUrl, httpUrl, kerberosUrl } = codeDropdownEl.dataset;
 
-  const CodeDropdownComponent = gon.features.directoryCodeDropdownUpdates
-    ? CompactCodeDropdown
-    : CodeDropdown;
+  const CodeDropdownComponent =
+    gon.features.directoryCodeDropdownUpdates && gon.features.blobRepositoryVueHeaderApp
+      ? CompactCodeDropdown
+      : CodeDropdown;
 
   return new Vue({
     el: codeDropdownEl,
diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue
index b000dc8cf0aad1737dbf5edd1291797b3925a68e..93c8481a3b75489458aad31731cd7358af13533e 100644
--- a/app/assets/javascripts/repository/components/header_area.vue
+++ b/app/assets/javascripts/repository/components/header_area.vue
@@ -265,7 +265,7 @@ export default {
           :upload-path="uploadPath"
           :new-dir-path="newDirPath"
         />
-        <!-- EE lock directory -->
+        <!-- EE: = render_if_exists 'projects/tree/lock_link' -->
         <lock-directory-button v-if="!isRoot" :project-path="projectPath" :path="currentPath" />
         <gl-button
           v-gl-tooltip.html="findFileTooltip"
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 3a809c520843b0cb8c10d1e356ad7d29626446c7..7b5c975ddfe4270693d510c5fb73b0ba5f1f30f4 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -188,9 +188,10 @@ export default function setupVueRepositoryList() {
     const { sshUrl, httpUrl, kerberosUrl, xcodeUrl, directoryDownloadLinks } =
       codeDropdownEl.dataset;
 
-    const CodeDropdownComponent = gon.features.directoryCodeDropdownUpdates
-      ? CompactCodeDropdown
-      : CodeDropdown;
+    const CodeDropdownComponent =
+      gon.features.directoryCodeDropdownUpdates && gon.features.blobRepositoryVueHeaderApp
+        ? CompactCodeDropdown
+        : CodeDropdown;
 
     return new Vue({
       el: codeDropdownEl,
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 7feaf58246289ed519aaed5aa1ad3f8963496bc4..4ef92f34ea6ed576e91ff74406d27bd4bc376b9c 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -47,6 +47,7 @@ class Projects::BlobController < Projects::ApplicationController
     push_frontend_feature_flag(:inline_blame, @project)
     push_frontend_feature_flag(:blob_overflow_menu, current_user)
     push_frontend_feature_flag(:filter_blob_path, current_user)
+    push_frontend_feature_flag(:blob_repository_vue_header_app, @project)
     push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
     push_frontend_feature_flag(:directory_code_dropdown_updates, current_user)
   end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 8fbf44e58f9e6af2a67600c6df03e3cf10c37e21..f4a93d03fa3c422e5f5674cb28b647a05ed18c6c 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -19,6 +19,7 @@ class Projects::TreeController < Projects::ApplicationController
 
   before_action do
     push_frontend_feature_flag(:inline_blame, @project)
+    push_frontend_feature_flag(:blob_repository_vue_header_app, @project)
     push_frontend_feature_flag(:blob_overflow_menu, current_user)
     push_frontend_feature_flag(:filter_blob_path, current_user)
     push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 0f4784cbdd0d51691fdd1dbd6b70b2739a25dbbf..99a3e9f2725ca2cdcd0c0b95e4b65498db89a4ef 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -42,6 +42,7 @@ class ProjectsController < Projects::ApplicationController
     push_frontend_feature_flag(:edit_branch_rules, @project)
     # TODO: We need to remove the FF eventually when we rollout page_specific_styles
     push_frontend_feature_flag(:page_specific_styles, current_user)
+    push_frontend_feature_flag(:blob_repository_vue_header_app, @project)
     push_frontend_feature_flag(:blob_overflow_menu, current_user)
     push_frontend_feature_flag(:filter_blob_path, current_user)
     push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index c0751180a5370ef203b2e1e0936d822db221f794..5cf20ac8931088b83eb39eccca6cad853d610a0e 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -8,7 +8,12 @@
 - add_page_specific_style 'page_bundles/projects'
 
 #tree-holder.tree-holder.clearfix.js-per-page.gl-mt-5{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } }
-  #js-repository-blob-header-app{ data: vue_tree_header_app_data(project, repository, ref, pipeline) }
+  - if Feature.enabled?(:blob_repository_vue_header_app, project)
+    #js-repository-blob-header-app{ data: vue_tree_header_app_data(project, repository, ref, pipeline) }
+
+  - else
+    .nav-block.gl-flex.gl-flex-col.sm:gl-flex-row.gl-items-stretch
+      = render 'projects/tree/tree_header', tree: @tree
 
   - if project.forked?
     #js-fork-info{ data: vue_fork_divergence_data(project, ref) }
diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml
index 664b036519efd5469db75d6964472f464454ea4a..f62667206ccfc926f05059fd821f61b21431bed7 100644
--- a/app/views/projects/_readme.html.haml
+++ b/app/views/projects/_readme.html.haml
@@ -8,18 +8,23 @@
 
 - if (readme = @repository.readme) && readme.rich_viewer
   .tree-holder.gl-mt-5
-    #js-repository-blob-header-app{ data: {
-      project_id: @project.id,
-      ref: ref,
-      ref_type: @ref_type.to_s,
-      breadcrumbs: breadcrumb_data_attributes,
-      project_root_path: project_path(@project),
-      project_path: project.full_path,
-      compare_path: compare_path,
-      web_ide_button_options: web_ide_button_data.merge(fork_options).to_json,
-      web_ide_button_default_branch: @project.default_branch_or_main,
-      escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref)
-    } }
+    - if Feature.enabled?(:blob_repository_vue_header_app, project)
+      #js-repository-blob-header-app{ data: {
+        project_id: @project.id,
+        ref: ref,
+        ref_type: @ref_type.to_s,
+        breadcrumbs: breadcrumb_data_attributes,
+        project_root_path: project_path(@project),
+        project_path: project.full_path,
+        compare_path: compare_path,
+        web_ide_button_options: web_ide_button_data.merge(fork_options).to_json,
+        web_ide_button_default_branch: @project.default_branch_or_main,
+        escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref)
+      } }
+
+    - else
+      .nav-block.mt-0
+        = render 'projects/tree/tree_header', tree: @tree
 
   %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) }
     .js-file-title.file-title-flex-parent
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index ee9d2fe73e1a68ce4a274c5826fb4d2a7fe92c9b..4e91e31674fd1de39d6ec47c4b55021be33f5785 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -6,7 +6,10 @@
 - if blob.rich_viewer && blob.extension != 'geojson'
   - add_page_startup_api_call local_assigns.fetch(:viewer_url) { url_for(safe_params.merge(viewer: blob.rich_viewer.type, format: :json)) }
 
-#js-repository-blob-header-app{ data: vue_blob_header_app_data(project, blob, ref) }
+- if Feature.enabled?(:blob_repository_vue_header_app, project)
+  #js-repository-blob-header-app{ data: vue_blob_header_app_data(project, blob, ref) }
+- else
+  = render "projects/blob/breadcrumb", blob: blob
 
 - if project.forked?
   #js-fork-info{ data: vue_fork_divergence_data(project, ref) }
diff --git a/app/views/projects/buttons/_compare.html.haml b/app/views/projects/buttons/_compare.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..82b1b837fbb9fda0222f9f1722d4a0c8eb2f8963
--- /dev/null
+++ b/app/views/projects/buttons/_compare.html.haml
@@ -0,0 +1,8 @@
+- project = local_assigns.fetch(:project)
+- ref = local_assigns.fetch(:ref, nil)
+- root_ref = local_assigns.fetch(:root_ref, nil)
+- unless ref.blank? || root_ref == ref
+  - compare_path = project_compare_index_path(project, from: root_ref, to: ref)
+
+  = link_button_to compare_path, class: 'shortcuts-compare', rel: 'nofollow' do
+    = _('Compare')
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..8d353c4c4a76bde5362e43dcc992447916b764e1
--- /dev/null
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -0,0 +1,21 @@
+.tree-ref-container.gl-flex.gl-flex-wrap.gl-gap-2.mb-2.mb-md-0
+  .tree-ref-holder.gl-max-w-26{ data: { testid: 'ref-dropdown-container' } }
+    #js-tree-ref-switcher{ data: { project_id: @project.id, ref_type: @ref_type.to_s, project_root_path: project_path(@project) } }
+
+  #js-repo-breadcrumb{ data: breadcrumb_data_attributes }
+
+#js-blob-controls
+.tree-controls
+  .gl-flex.gl-flex-wrap.gl-gap-3.gl-mb-3.sm:gl-mb-0
+    = render_if_exists 'projects/tree/lock_link'
+    = render 'projects/buttons/compare', project: @project, ref: @ref, root_ref: @repository&.root_ref
+
+    = render 'projects/find_file_link'
+    = render 'shared/web_ide_button', blob: nil, css_classes: 'gl-w-full sm:gl-w-auto'
+
+    .project-code-holder.gl-hidden.sm:gl-inline-block
+      = render "projects/buttons/code", dropdown_class: 'dropdown-menu-right', ref: @ref
+
+  .project-code-holder.gl-flex.gl-gap-3{ class: 'sm:!gl-hidden' }
+    = render 'projects/buttons/download', project: @project, ref: @ref
+    = render "shared/mobile_clone_panel", ref: @ref
diff --git a/config/feature_flags/gitlab_com_derisk/blob_repository_vue_header_app.yml b/config/feature_flags/gitlab_com_derisk/blob_repository_vue_header_app.yml
new file mode 100644
index 0000000000000000000000000000000000000000..dbe3028f77915151b7439df0beb22ca0d674c0cb
--- /dev/null
+++ b/config/feature_flags/gitlab_com_derisk/blob_repository_vue_header_app.yml
@@ -0,0 +1,9 @@
+---
+name: blob_repository_vue_header_app
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/509327
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/176182
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/513582
+milestone: '17.9'
+group: group::source code
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/ee/app/assets/javascripts/repository/index.js b/ee/app/assets/javascripts/repository/index.js
index b25fd6c9a539b5cddcd957697faef41b5d69d6dd..82ce2a5231f1a4217f97f6868ee3931f41e01ad3 100644
--- a/ee/app/assets/javascripts/repository/index.js
+++ b/ee/app/assets/javascripts/repository/index.js
@@ -1,7 +1,45 @@
 import Vue from 'vue';
+import { createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
 import initTree from '~/repository';
 import CodeOwners from '../vue_shared/components/code_owners/code_owners.vue';
 
+const initPathLocks = (data, router) => {
+  if (data.pathLocksAvailable) {
+    const toggleBtn = document.querySelector('a.js-path-lock');
+    if (!toggleBtn) return;
+
+    toggleBtn.addEventListener('click', async (e) => {
+      e.preventDefault();
+
+      const { dataset } = e.currentTarget;
+      const message =
+        dataset.state === 'lock'
+          ? __('Are you sure you want to lock this directory?')
+          : __('Are you sure you want to unlock this directory?');
+
+      const confirmed = await confirmAction(message);
+      if (!confirmed) return;
+
+      toggleBtn.setAttribute('disabled', 'disabled');
+
+      axios
+        .post(data.pathLocksToggle, {
+          path: router.currentRoute.params.path.replace(/^\//, ''),
+        })
+        .then(() => window.location.reload())
+        .catch(() => {
+          toggleBtn.removeAttribute('disabled');
+          createAlert({
+            message: __('An error occurred while initializing path locks'),
+          });
+        });
+    });
+  }
+};
+
 const initCodeOwnersApp = (router, apolloProvider, projectPath) => {
   const codeOwnersEl = document.querySelector('#js-code-owners');
   if (!codeOwnersEl) return null;
@@ -26,7 +64,10 @@ const initCodeOwnersApp = (router, apolloProvider, projectPath) => {
 };
 
 export default () => {
-  const { router, apolloProvider, projectPath } = initTree();
+  const { router, data, apolloProvider, projectPath } = initTree();
 
+  if (!gon.features.blobRepositoryVueHeaderApp) {
+    initPathLocks(data, router);
+  }
   initCodeOwnersApp(router, apolloProvider, projectPath);
 };
diff --git a/ee/app/helpers/ee/lock_helper.rb b/ee/app/helpers/ee/lock_helper.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e5abdfc31453618802acea04bc48141ca8ef2e46
--- /dev/null
+++ b/ee/app/helpers/ee/lock_helper.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module EE
+  module LockHelper
+    DEFAULT_CSS_CLASSES = 'path-lock js-path-lock js-hide-on-root hidden'
+
+    def lock_file_link(project = @project, path = @path)
+      return unless project.feature_available?(:file_locks)
+      return unless current_user
+
+      path_lock = project.find_path_lock(path, downstream: true)
+
+      if path_lock
+        locker = path_lock.user.name
+
+        if path_lock.exact?(path)
+          exact_lock_file_link(path_lock, locker)
+        elsif path_lock.upstream?(path)
+          upstream_lock_file_link(path_lock, locker)
+        elsif path_lock.downstream?(path)
+          downstream_lock_file_link(path_lock, locker)
+        end
+      else
+        _lock_link(current_user, project)
+      end
+    end
+
+    private
+
+    def exact_lock_file_link(path_lock, locker)
+      if can_unlock?(path_lock)
+        tooltip = path_lock.user == current_user ? '' : "Locked by #{locker}"
+        enabled_lock_link("Unlock", tooltip, :unlock)
+      else
+        disabled_lock_link("Unlock", "Locked by #{locker}. You do not have permission to unlock this")
+      end
+    end
+
+    def upstream_lock_file_link(path_lock, locker)
+      additional_phrase = can_unlock?(path_lock) ? 'Unlock that directory in order to unlock this' : 'You do not have permission to unlock it'
+      disabled_lock_link("Unlock", "#{locker} has a lock on \"#{path_lock.path}\". #{additional_phrase}")
+    end
+
+    def downstream_lock_file_link(path_lock, locker)
+      additional_phrase = can_unlock?(path_lock) ? 'Unlock this in order to proceed' : 'You do not have permission to unlock it'
+      disabled_lock_link("Lock", "This directory cannot be locked while #{locker} has a lock on \"#{path_lock.path}\". #{additional_phrase}")
+    end
+
+    def _lock_link(user, project)
+      if can?(current_user, :push_code, project)
+        enabled_lock_link("Lock", '', :lock)
+      else
+        disabled_lock_link("Lock", "You do not have permission to lock this")
+      end
+    end
+
+    def disabled_lock_link(label, title)
+      # Disabled buttons with tooltips should have the tooltip attached
+      # to a wrapper element https://bootstrap-vue.org/docs/components/tooltip#disabled-elements
+      button = render Pajamas::ButtonComponent.new(disabled: true, button_options: { class: DEFAULT_CSS_CLASSES, data: { testid: 'disabled-lock-button' } }) do
+        label
+      end
+      content_tag(:span, button, title: title, class: 'btn-group has-tooltip')
+    end
+
+    def enabled_lock_link(label, title, state)
+      render Pajamas::ButtonComponent.new(href: '#', button_options: { class: "#{DEFAULT_CSS_CLASSES} has-tooltip", title: title, data: { state: state, toggle: 'tooltip', testid: 'lock-button' } }) do
+        label
+      end
+    end
+  end
+end
diff --git a/ee/app/views/projects/tree/_lock_link.html.haml b/ee/app/views/projects/tree/_lock_link.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..fe03d7db3675fb0fb547bf0c1ec040198fd23825
--- /dev/null
+++ b/ee/app/views/projects/tree/_lock_link.html.haml
@@ -0,0 +1 @@
+= lock_file_link
diff --git a/ee/spec/helpers/ee/lock_helper_spec.rb b/ee/spec/helpers/ee/lock_helper_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..89dcb59dec9a91bebdfb427ded383df69563194c
--- /dev/null
+++ b/ee/spec/helpers/ee/lock_helper_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe EE::LockHelper do
+  describe '#lock_file_link' do
+    let!(:path_lock) { create :path_lock, path: 'app/models' }
+    let(:path) { path_lock.path }
+    let(:user) { path_lock.user }
+    let(:project) { path_lock.project }
+    let_it_be(:disabled_attr) { 'disabled="disabled"' }
+
+    before do
+      allow(helper).to receive(:can?).and_return(true)
+      allow(helper).to receive(:current_user).and_return(user)
+      allow(project).to receive(:feature_available?).with(:file_locks).and_return(true)
+
+      project.reload
+    end
+
+    context 'when there is no lock' do
+      let(:subject) { helper.lock_file_link(project, '.gitignore') }
+      let_it_be(:tooltip_text) { "You do not have permission to lock this" }
+
+      context 'when user can push code to the project' do
+        it 'returns an enabled "Lock" button without a tooltip' do
+          expect(subject).to match('Lock')
+          expect(subject).not_to match(disabled_attr)
+          expect(subject).not_to match(tooltip_text)
+        end
+      end
+
+      context 'when user cannot push code to the project' do
+        before do
+          allow(helper).to receive(:can?).and_return(false)
+        end
+
+        it 'returns a disabled "Lock" button with a tooltip' do
+          expect(subject).to match('Lock')
+          expect(subject).to match(disabled_attr)
+          expect(subject).to match(tooltip_text)
+        end
+      end
+    end
+
+    context 'when there is no conflicting lock' do
+      let(:subject) { helper.lock_file_link(project, path) }
+      let_it_be(:tooltip_text) { "Locked by" }
+
+      context 'when user is allowed to unlock the path' do
+        context 'when path was locked by the current user' do
+          it 'returns an enabled "Unlock" button without a tooltip' do
+            expect(subject).to match('Unlock')
+            expect(subject).not_to match(disabled_attr)
+            expect(subject).not_to match(tooltip_text)
+          end
+        end
+
+        context 'wnen path was locked by someone else' do
+          let(:user2) { create :user }
+
+          before do
+            allow(helper).to receive(:current_user).and_return(user2)
+          end
+
+          it 'returns an enabled "Unlock" button with a tooltip' do
+            expect(subject).to match('Unlock')
+            expect(subject).not_to match(disabled_attr)
+            expect(subject).to match(tooltip_text)
+          end
+        end
+      end
+
+      context 'when user is not allowed to unlock the path' do
+        before do
+          allow(helper).to receive(:can?).and_return(false)
+        end
+
+        it 'returns a disabled "Unlock" button with a tooltip' do
+          expect(subject).to match('Unlock')
+          expect(subject).to match(disabled_attr)
+          expect(subject).to match(tooltip_text)
+        end
+      end
+    end
+
+    context 'when there is an upstream lock' do
+      let(:requested_path) { 'app/models/user.rb' }
+      let(:subject) { helper.lock_file_link(project, requested_path) }
+
+      context 'when user is allowed to unlock the upstream path' do
+        it 'returns a disabled "Unlock" button with a tooltip' do
+          expect(subject).to match('Unlock')
+          expect(subject).to match(disabled_attr)
+          expect(subject).to match("Unlock that directory in order to unlock this")
+        end
+      end
+
+      context 'when user is not allowed to unlock the upstream path' do
+        before do
+          allow(helper).to receive(:can?).and_return(false)
+        end
+
+        it 'returns a disabled "Unlock" button with a tooltip' do
+          expect(subject).to match('Unlock')
+          expect(subject).to match(disabled_attr)
+          expect(subject).to match("You do not have permission to unlock it")
+        end
+      end
+    end
+
+    context 'when there is a downstream lock' do
+      let(:subject) { helper.lock_file_link(project, 'app') }
+
+      context 'when user is allowed to unlock the downstream path' do
+        it 'returns a disabled "Lock" button with a tooltip' do
+          expect(subject).to match('Lock')
+          expect(subject).to match(disabled_attr)
+          expect(subject).to match("Unlock this in order to proceed")
+        end
+      end
+
+      context 'when user is not allowed to unlock the downstream path' do
+        before do
+          allow(helper).to receive(:can?).and_return(false)
+        end
+
+        it 'returns a disabled "Lock" button with a tooltip' do
+          expect(subject).to match('Lock')
+          expect(subject).to match(disabled_attr)
+          expect(subject).to match("You do not have permission to unlock it")
+        end
+      end
+    end
+  end
+end
diff --git a/spec/frontend/repository/components/header_area_spec.js b/spec/frontend/repository/components/header_area_spec.js
index 2f959786fc559f44b6b994cd05b4384781388da8..6f68c509cc34f3e78d264236fd5a489686afedce 100644
--- a/spec/frontend/repository/components/header_area_spec.js
+++ b/spec/frontend/repository/components/header_area_spec.js
@@ -80,15 +80,8 @@ describe('HeaderArea', () => {
     expect(wrapper.exists()).toBe(true);
   });
 
-  describe('Ref selector', () => {
-    it('renders correctly', () => {
-      expect(findRefSelector().exists()).toBe(true);
-    });
-
-    it('renders correctly when branch names ending with .json', () => {
-      createComponent({ props: { refSelectorValue: 'ends-with.json' } });
-      expect(findRefSelector().exists()).toBe(true);
-    });
+  it('renders RefSelector', () => {
+    expect(findRefSelector().exists()).toBe(true);
   });
 
   it('renders Breadcrumbs component', () => {
diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml
index d6fef67dc9c82a8679dc70610cf1dca5e9831aac..06b2553da71342f8063a2e21e8ae24e673908bed 100644
--- a/spec/support/rspec_order_todo.yml
+++ b/spec/support/rspec_order_todo.yml
@@ -665,6 +665,7 @@
 - './ee/spec/helpers/ee/issuables_helper_spec.rb'
 - './ee/spec/helpers/ee/issues_helper_spec.rb'
 - './ee/spec/helpers/ee/labels_helper_spec.rb'
+- './ee/spec/helpers/ee/lock_helper_spec.rb'
 - './ee/spec/helpers/ee/namespaces_helper_spec.rb'
 - './ee/spec/helpers/ee/namespace_user_cap_reached_alert_helper_spec.rb'
 - './ee/spec/helpers/ee/operations_helper_spec.rb'
diff --git a/spec/views/projects/tree/show.html.haml_spec.rb b/spec/views/projects/tree/show.html.haml_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4e77173d31183a703b2566ef63d739f8ab1bfce0
--- /dev/null
+++ b/spec/views/projects/tree/show.html.haml_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'projects/tree/show' do
+  include Devise::Test::ControllerHelpers
+
+  let_it_be(:project) { create(:project, :repository, create_branch: 'bar') }
+  let(:repository) { project.repository }
+  let(:ref) { 'master' }
+  let(:commit) { repository.commit(ref) }
+  let(:path) { '' }
+  let(:tree) { repository.tree(commit.id, path) }
+
+  before do
+    stub_feature_flags(blob_repository_vue_header_app: false)
+    assign(:project, project)
+    assign(:repository, repository)
+
+    allow(view).to receive(:can?).and_return(true)
+    allow(view).to receive(:can_collaborate_with_project?).and_return(true)
+    allow(view).to receive_message_chain('user_access.can_push_to_branch?').and_return(true)
+    allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings)
+    allow(view).to receive(:current_user).and_return(project.creator)
+
+    assign(:id, File.join(ref, path))
+    assign(:ref, ref)
+    assign(:path, path)
+    assign(:last_commit, commit)
+    assign(:tree, tree)
+  end
+
+  context 'for branch names ending on .json' do
+    let(:ref) { 'ends-with.json' }
+
+    it 'displays correctly' do
+      render
+
+      expect(rendered).to have_css('#js-tree-ref-switcher')
+    end
+  end
+
+  context 'when on root ref' do
+    let(:ref) { repository.root_ref }
+
+    it 'hides compare button' do
+      render
+
+      expect(rendered).not_to include('Compare')
+    end
+  end
+
+  context 'when not on root ref' do
+    let(:ref) { 'bar' }
+
+    it 'shows a compare button' do
+      render
+
+      expect(rendered).to include('Compare')
+    end
+  end
+end