From b8a08b233d3e99b3980cff8600bb5b15b96c22fd Mon Sep 17 00:00:00 2001
From: Paulina Sedlak-Jakubowska <psedlak-jakubowska@gitlab.com>
Date: Wed, 6 Nov 2024 11:06:31 +0000
Subject: [PATCH] Add history button to header area app

Create the base for header area app to be used
on tree and blob view. Move tree History control.
Add a check to initiate old History control only
when its element is present.
---
 .../javascripts/pages/projects/show/index.js  |   2 +
 .../repository/components/header_area.vue     | 188 ++++++++++++++++++
 .../{ => header_area}/blob_controls.vue       |   6 +-
 .../{ => header_area}/breadcrumbs.vue         |  17 +-
 app/assets/javascripts/repository/index.js    |  59 +++---
 .../javascripts/repository/init_header_app.js |  71 +++++++
 .../repository/utils/url_utility.js           |  10 +
 app/views/projects/_files.html.haml           |  11 +-
 app/views/projects/_readme.html.haml          |  14 +-
 .../projects/tree/_tree_header.html.haml      |   2 -
 qa/qa/page/project/show.rb                    |   2 +-
 scripts/frontend/quarantined_vue3_specs.txt   |   4 +-
 .../{ => header_area}/blob_controls_spec.js   |   4 +-
 .../{ => header_area}/breadcrumbs_spec.js     |   5 +-
 .../repository/components/header_area_spec.js | 160 +++++++++++++++
 .../repository/utils/url_utility_spec.js      |  38 ++++
 16 files changed, 545 insertions(+), 48 deletions(-)
 create mode 100644 app/assets/javascripts/repository/components/header_area.vue
 rename app/assets/javascripts/repository/components/{ => header_area}/blob_controls.vue (96%)
 rename app/assets/javascripts/repository/components/{ => header_area}/breadcrumbs.vue (94%)
 create mode 100644 app/assets/javascripts/repository/init_header_app.js
 create mode 100644 app/assets/javascripts/repository/utils/url_utility.js
 rename spec/frontend/repository/components/{ => header_area}/blob_controls_spec.js (96%)
 rename spec/frontend/repository/components/{ => header_area}/breadcrumbs_spec.js (98%)
 create mode 100644 spec/frontend/repository/components/header_area_spec.js
 create mode 100644 spec/frontend/repository/utils/url_utility_spec.js

diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index eb6cb11832cf3..8c05fabbb2428 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -11,6 +11,7 @@ import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal';
 import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue';
 import initSourceCodeDropdowns from '~/vue_shared/components/download_dropdown/init_download_dropdowns';
 import EmptyProject from '~/pages/projects/show/empty_project';
+import initHeaderApp from '~/repository/init_header_app';
 import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
 import { initHomePanel } from '../home_panel';
 
@@ -27,6 +28,7 @@ if (document.querySelector('.blob-viewer')) {
   import(/* webpackChunkName: 'blobViewer' */ '~/blob/viewer')
     .then(({ BlobViewer }) => {
       new BlobViewer(); // eslint-disable-line no-new
+      initHeaderApp(true);
     })
     .catch(() => {});
 }
diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue
new file mode 100644
index 0000000000000..b301b9a7f928f
--- /dev/null
+++ b/app/assets/javascripts/repository/components/header_area.vue
@@ -0,0 +1,188 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Shortcuts from '~/behaviors/shortcuts/shortcuts';
+import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
+import { keysFor, START_SEARCH_PROJECT_FILE } from '~/behaviors/shortcuts/keybindings';
+import { sanitize } from '~/lib/dompurify';
+import { InternalEvents } from '~/tracking';
+import { FIND_FILE_BUTTON_CLICK } from '~/tracking/constants';
+import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
+import { generateHistoryUrl } from '~/repository/utils/url_utility';
+import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.vue';
+import BlobControls from '~/repository/components/header_area/blob_controls.vue';
+
+export default {
+  name: 'HeaderArea',
+  i18n: {
+    compare: __('Compare'),
+    findFile: __('Find file'),
+    history: __('History'),
+  },
+  components: {
+    GlButton,
+    RefSelector,
+    Breadcrumbs,
+    BlobControls,
+  },
+  directives: {
+    GlTooltip: GlTooltipDirective,
+  },
+  inject: [
+    'canCollaborate',
+    'canEditTree',
+    'canPushCode',
+    'originalBranch',
+    'selectedBranch',
+    'newBranchPath',
+    'newTagPath',
+    'newBlobPath',
+    'forkNewBlobPath',
+    'forkNewDirectoryPath',
+    'forkUploadBlobPath',
+    'uploadPath',
+    'newDirPath',
+    'projectRootPath',
+    'comparePath',
+    'isReadmeView',
+  ],
+  props: {
+    projectPath: {
+      type: String,
+      required: true,
+    },
+    refType: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    currentRef: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    historyLink: {
+      type: String,
+      required: true,
+    },
+    projectId: {
+      type: String,
+      required: true,
+    },
+  },
+  computed: {
+    isTreeView() {
+      return this.$route.name !== 'blobPathDecoded';
+    },
+    historyPath() {
+      const url = generateHistoryUrl(
+        this.historyLink,
+        this.$route.params.path,
+        this.$route.meta.refType || this.$route.query.ref_type,
+      );
+
+      return url.href;
+    },
+    getRefType() {
+      return this.$route.query.ref_type;
+    },
+    currentPath() {
+      return this.$route.params.path;
+    },
+    refSelectorQueryParams() {
+      return {
+        sort: 'updated_desc',
+      };
+    },
+    refSelectorValue() {
+      return this.refType ? joinPaths('refs', this.refType, this.currentRef) : this.currentRef;
+    },
+    findFileTooltip() {
+      const { description } = START_SEARCH_PROJECT_FILE;
+      const key = this.findFileShortcutKey;
+      return shouldDisableShortcuts()
+        ? null
+        : sanitize(`${description} <kbd class="flat gl-ml-1" aria-hidden=true>${key}</kbd>`);
+    },
+    findFileShortcutKey() {
+      return keysFor(START_SEARCH_PROJECT_FILE)[0];
+    },
+  },
+  methods: {
+    onInput(selectedRef) {
+      visitUrl(generateRefDestinationPath(this.projectRootPath, this.originalBranch, selectedRef));
+    },
+    handleFindFile() {
+      InternalEvents.trackEvent(FIND_FILE_BUTTON_CLICK);
+      Shortcuts.focusSearchFile();
+    },
+  },
+};
+</script>
+
+<template>
+  <section class="nav-block gl-flex gl-flex-col gl-items-stretch sm:gl-flex-row">
+    <div class="tree-ref-container mb-2 mb-md-0 gl-flex gl-flex-wrap gl-gap-2">
+      <ref-selector
+        v-if="!isReadmeView"
+        class="tree-ref-holder gl-max-w-26"
+        data-testid="ref-dropdown-container"
+        :project-id="projectId"
+        :value="refSelectorValue"
+        use-symbolic-ref-names
+        :query-params="refSelectorQueryParams"
+        @input="onInput"
+      />
+      <breadcrumbs
+        v-if="!isReadmeView"
+        class="js-repo-breadcrumbs"
+        :current-path="currentPath"
+        :ref-type="getRefType"
+        :can-collaborate="canCollaborate"
+        :can-edit-tree="canEditTree"
+        :can-push-code="canPushCode"
+        :original-branch="originalBranch"
+        :selected-branch="selectedBranch"
+        :new-branch-path="newBranchPath"
+        :new-tag-path="newTagPath"
+        :new-blob-path="newBlobPath"
+        :fork-new-blob-path="forkNewBlobPath"
+        :fork-new-directory-path="forkNewDirectoryPath"
+        :fork-upload-blob-path="forkUploadBlobPath"
+        :upload-path="uploadPath"
+        :new-dir-path="newDirPath"
+      />
+    </div>
+
+    <!-- Tree controls -->
+    <div v-if="isTreeView" class="tree-controls gl-mb-3 gl-flex gl-flex-wrap gl-gap-3 sm:gl-mb-0">
+      <!-- EE: = render_if_exists 'projects/tree/lock_link' -->
+      <gl-button
+        v-if="comparePath"
+        data-testid="tree-compare-control"
+        :href="comparePath"
+        class="shortcuts-compare"
+        >{{ $options.i18n.compare }}</gl-button
+      >
+      <gl-button v-if="!isReadmeView" :href="historyPath" data-testid="tree-history-control">{{
+        $options.i18n.history
+      }}</gl-button>
+      <gl-button
+        v-gl-tooltip.html="findFileTooltip"
+        :aria-keyshortcuts="findFileShortcutKey"
+        data-testid="tree-find-file-control"
+        class="gl-mt-3 gl-w-full sm:gl-mt-0 sm:gl-w-auto"
+        @click="handleFindFile"
+      >
+        {{ $options.i18n.findFile }}
+      </gl-button>
+      <!-- web ide -->
+      <!-- code + mobile panel -->
+    </div>
+
+    <!-- Blob controls -->
+    <blob-controls :project-path="projectPath" :ref-type="getRefType" />
+  </section>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/header_area/blob_controls.vue
similarity index 96%
rename from app/assets/javascripts/repository/components/blob_controls.vue
rename to app/assets/javascripts/repository/components/header_area/blob_controls.vue
index 4611afa270a6d..2c5c163f7a8ed 100644
--- a/app/assets/javascripts/repository/components/blob_controls.vue
+++ b/app/assets/javascripts/repository/components/header_area/blob_controls.vue
@@ -18,9 +18,9 @@ import {
 import { sanitize } from '~/lib/dompurify';
 import { InternalEvents } from '~/tracking';
 import { FIND_FILE_BUTTON_CLICK } from '~/tracking/constants';
-import { updateElementsVisibility } from '../utils/dom';
-import blobControlsQuery from '../queries/blob_controls.query.graphql';
-import { getRefType } from '../utils/ref_type';
+import { updateElementsVisibility } from '~/repository/utils/dom';
+import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql';
+import { getRefType } from '~/repository/utils/ref_type';
 
 export default {
   i18n: {
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue
similarity index 94%
rename from app/assets/javascripts/repository/components/breadcrumbs.vue
rename to app/assets/javascripts/repository/components/header_area/breadcrumbs.vue
index 3dea54d5995b1..b53607f08b7c0 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue
@@ -5,11 +5,11 @@ import permissionsQuery from 'shared_queries/repository/permissions.query.graphq
 import { joinPaths, escapeFileUrl, buildURLwithRefType } from '~/lib/utils/url_utility';
 import { BV_SHOW_MODAL } from '~/lib/utils/constants';
 import { __ } from '~/locale';
-import getRefMixin from '../mixins/get_ref';
-import projectPathQuery from '../queries/project_path.query.graphql';
-import projectShortPathQuery from '../queries/project_short_path.query.graphql';
-import UploadBlobModal from './upload_blob_modal.vue';
-import NewDirectoryModal from './new_directory_modal.vue';
+import getRefMixin from '~/repository/mixins/get_ref';
+import projectPathQuery from '~/repository/queries/project_path.query.graphql';
+import projectShortPathQuery from '~/repository/queries/project_short_path.query.graphql';
+import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+import NewDirectoryModal from '~/repository/components/new_directory_modal.vue';
 
 const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
 const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory';
@@ -31,7 +31,7 @@ export default {
       query: permissionsQuery,
       variables() {
         return {
-          projectPath: this.projectPath,
+          projectPath: this.projectPath || this.projectRootPath,
         };
       },
       update: (data) => data.project?.userPermissions,
@@ -44,6 +44,11 @@ export default {
     GlModal: GlModalDirective,
   },
   mixins: [getRefMixin],
+  inject: {
+    projectRootPath: {
+      default: '',
+    },
+  },
   props: {
     currentPath: {
       type: String,
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 00c52f6a7607f..17b8d618bf926 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
 // eslint-disable-next-line no-restricted-imports
 import Vuex from 'vuex';
 import { parseBoolean } from '~/lib/utils/common_utils';
-import { joinPaths, escapeFileUrl, visitUrl } from '~/lib/utils/url_utility';
+import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
 import { __ } from '~/locale';
 import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
 import PerformancePlugin from '~/performance/vue_performance_plugin';
@@ -12,10 +12,10 @@ import RefSelector from '~/ref/components/ref_selector.vue';
 import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker';
 import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue';
 import App from './components/app.vue';
-import Breadcrumbs from './components/breadcrumbs.vue';
+import Breadcrumbs from './components/header_area/breadcrumbs.vue';
 import ForkInfo from './components/fork_info.vue';
 import LastCommit from './components/last_commit.vue';
-import BlobControls from './components/blob_controls.vue';
+import BlobControls from './components/header_area/blob_controls.vue';
 import apolloProvider from './graphql';
 import commitsQuery from './queries/commits.query.graphql';
 import projectPathQuery from './queries/project_path.query.graphql';
@@ -24,7 +24,9 @@ import refsQuery from './queries/ref.query.graphql';
 import createRouter from './router';
 import { updateFormAction } from './utils/dom';
 import { setTitle } from './utils/title';
+import { generateHistoryUrl } from './utils/url_utility';
 import { generateRefDestinationPath } from './utils/ref_switcher_utils';
+import initHeaderApp from './init_header_app';
 
 Vue.use(Vuex);
 Vue.use(PerformancePlugin, {
@@ -196,6 +198,7 @@ export default function setupVueRepositoryList() {
     });
   };
 
+  initHeaderApp();
   initCodeDropdown();
   initLastCommitApp();
   initBlobControlsApp();
@@ -258,31 +261,33 @@ export default function setupVueRepositoryList() {
   }
 
   const treeHistoryLinkEl = document.getElementById('js-tree-history-link');
-  const { historyLink } = treeHistoryLinkEl.dataset;
-  // eslint-disable-next-line no-new
-  new Vue({
-    el: treeHistoryLinkEl,
-    router,
-    render(h) {
-      const url = new URL(window.location.href);
-      url.pathname = `${historyLink}/${
-        this.$route.params.path ? escapeFileUrl(this.$route.params.path) : ''
-      }`;
-      url.searchParams.set('ref_type', this.$route.meta.refType || this.$route.query.ref_type);
-      return h(
-        GlButton,
-        {
-          attrs: {
-            href: url.href,
-            // Ideally passing this class to `props` should work
-            // But it doesn't work here. :(
-            class: 'btn btn-default btn-md gl-button',
+  if (treeHistoryLinkEl) {
+    const { historyLink } = treeHistoryLinkEl.dataset;
+    // eslint-disable-next-line no-new
+    new Vue({
+      el: treeHistoryLinkEl,
+      router,
+      render(h) {
+        const url = generateHistoryUrl(
+          historyLink,
+          this.$route.params.path,
+          this.$route.meta.refType || this.$route.query.ref_type,
+        );
+        return h(
+          GlButton,
+          {
+            attrs: {
+              href: url.href,
+              // Ideally passing this class to `props` should work
+              // But it doesn't work here. :(
+              class: 'btn btn-default btn-md gl-button',
+            },
           },
-        },
-        [__('History')],
-      );
-    },
-  });
+          [__('History')],
+        );
+      },
+    });
+  }
 
   initWebIdeLink({ el: document.getElementById('js-tree-web-ide-link'), router });
 
diff --git a/app/assets/javascripts/repository/init_header_app.js b/app/assets/javascripts/repository/init_header_app.js
new file mode 100644
index 0000000000000..a2d932582c8c7
--- /dev/null
+++ b/app/assets/javascripts/repository/init_header_app.js
@@ -0,0 +1,71 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import apolloProvider from './graphql';
+import HeaderArea from './components/header_area.vue';
+import createRouter from './router';
+
+export default function initHeaderApp(isReadmeView = false) {
+  const headerEl = document.getElementById('js-repository-blob-header-app');
+  if (headerEl) {
+    const {
+      historyLink,
+      ref,
+      escapedRef,
+      refType,
+      projectId,
+      breadcrumbsCanCollaborate,
+      breadcrumbsCanEditTree,
+      breadcrumbsCanPushCode,
+      breadcrumbsSelectedBranch,
+      breadcrumbsNewBranchPath,
+      breadcrumbsNewTagPath,
+      breadcrumbsNewBlobPath,
+      breadcrumbsForkNewBlobPath,
+      breadcrumbsForkNewDirectoryPath,
+      breadcrumbsForkUploadBlobPath,
+      breadcrumbsUploadPath,
+      breadcrumbsNewDirPath,
+      projectRootPath,
+      comparePath,
+      projectPath,
+    } = headerEl.dataset;
+
+    // eslint-disable-next-line no-new
+    new Vue({
+      el: headerEl,
+      provide: {
+        canCollaborate: parseBoolean(breadcrumbsCanCollaborate),
+        canEditTree: parseBoolean(breadcrumbsCanEditTree),
+        canPushCode: parseBoolean(breadcrumbsCanPushCode),
+        originalBranch: ref,
+        selectedBranch: breadcrumbsSelectedBranch,
+        newBranchPath: breadcrumbsNewBranchPath,
+        newTagPath: breadcrumbsNewTagPath,
+        newBlobPath: breadcrumbsNewBlobPath,
+        forkNewBlobPath: breadcrumbsForkNewBlobPath,
+        forkNewDirectoryPath: breadcrumbsForkNewDirectoryPath,
+        forkUploadBlobPath: breadcrumbsForkUploadBlobPath,
+        uploadPath: breadcrumbsUploadPath,
+        newDirPath: breadcrumbsNewDirPath,
+        projectRootPath,
+        comparePath,
+        isReadmeView,
+      },
+      apolloProvider,
+      router: createRouter(projectPath, escapedRef),
+      render(h) {
+        return h(HeaderArea, {
+          props: {
+            refType,
+            currentRef: ref,
+            historyLink,
+            // BlobControls:
+            projectPath,
+            // RefSelector:
+            projectId,
+          },
+        });
+      },
+    });
+  }
+}
diff --git a/app/assets/javascripts/repository/utils/url_utility.js b/app/assets/javascripts/repository/utils/url_utility.js
new file mode 100644
index 0000000000000..91f004ba9b3f2
--- /dev/null
+++ b/app/assets/javascripts/repository/utils/url_utility.js
@@ -0,0 +1,10 @@
+import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
+
+export function generateHistoryUrl(historyLink, path, refType) {
+  const url = new URL(window.location.href);
+
+  url.pathname = joinPaths(historyLink, path ? escapeFileUrl(path) : '');
+  url.searchParams.set('ref_type', refType);
+
+  return url;
+}
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 138c99852c164..f665c0e484645 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -4,10 +4,17 @@
 - if readme_path = @project.repository.readme_path
   - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json")
 - add_page_specific_style 'page_bundles/commit_description'
+- add_page_specific_style 'page_bundles/projects'
+- unless @ref.blank? ||  @repository&.root_ref == @ref
+  - compare_path = project_compare_index_path(@project, from:  @repository&.root_ref, to: @ref)
 
 #tree-holder.tree-holder.clearfix.js-per-page.gl-mt-5{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } }
-  .nav-block.gl-flex.gl-flex-col.sm:gl-flex-row.gl-items-stretch
-    = render 'projects/tree/tree_header', tree: @tree
+  - if params[:common_repository_blob_header_app] == 'true'
+    #js-repository-blob-header-app{ data: { project_id: @project.id, ref: ref, ref_type: @ref_type.to_s, history_link: project_commits_path(@project, @ref), breadcrumbs: breadcrumb_data_attributes, project_root_path: project_path(@project), project_path: project.full_path, compare_path: compare_path, escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref) } }
+
+  - 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 fc9ddb650e9cf..08dc61e5694d0 100644
--- a/app/views/projects/_readme.html.haml
+++ b/app/views/projects/_readme.html.haml
@@ -1,7 +1,17 @@
+- ref = local_assigns.fetch(:ref) { current_ref }
+- project = local_assigns.fetch(:project) { @project }
+- add_page_specific_style 'page_bundles/projects'
+- unless @ref.blank? ||  @repository&.root_ref == @ref
+  - compare_path = project_compare_index_path(@project, from:  @repository&.root_ref, to: @ref)
+
 - if (readme = @repository.readme) && readme.rich_viewer
   .tree-holder.gl-mt-5
-    .nav-block.mt-0
-      = render 'projects/tree/tree_header', tree: @tree
+    - if params[:common_repository_blob_header_app] == 'true'
+      #js-repository-blob-header-app{ data: { project_id: @project.id, ref: ref, ref_type: @ref_type.to_s, history_link: project_commits_path(@project, @ref), breadcrumbs: breadcrumb_data_attributes, project_root_path: project_path(@project), project_path: project.full_path, compare_path: compare_path, 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
       .file-header-content
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index da84dc9cffea4..53adf6b4d7b05 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -1,5 +1,3 @@
-- add_page_specific_style 'page_bundles/projects'
-
 .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) } }
diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb
index 1652e15cb21da..f45852a1d4615 100644
--- a/qa/qa/page/project/show.rb
+++ b/qa/qa/page/project/show.rb
@@ -48,7 +48,7 @@ class Show < Page::Base
           element 'quick-actions-container'
         end
 
-        view 'app/assets/javascripts/repository/components/breadcrumbs.vue' do
+        view 'app/assets/javascripts/repository/components/header_area/breadcrumbs.vue' do
           element 'add-to-tree'
           element 'new-file-menu-item'
         end
diff --git a/scripts/frontend/quarantined_vue3_specs.txt b/scripts/frontend/quarantined_vue3_specs.txt
index 1adfae5b8a917..2ca7cbc9a0fcf 100644
--- a/scripts/frontend/quarantined_vue3_specs.txt
+++ b/scripts/frontend/quarantined_vue3_specs.txt
@@ -379,8 +379,8 @@ spec/frontend/releases/components/asset_links_form_spec.js
 spec/frontend/releases/components/tag_create_spec.js
 spec/frontend/releases/components/tag_field_exsting_spec.js
 spec/frontend/releases/components/tag_search_spec.js
-spec/frontend/repository/components/blob_controls_spec.js
-spec/frontend/repository/components/breadcrumbs_spec.js
+spec/frontend/repository/components/header_area/blob_controls_spec.js
+spec/frontend/repository/components/header_area/breadcrumbs_spec.js
 spec/frontend/repository/components/table/index_spec.js
 spec/frontend/repository/components/table/row_spec.js
 spec/frontend/repository/router_spec.js
diff --git a/spec/frontend/repository/components/blob_controls_spec.js b/spec/frontend/repository/components/header_area/blob_controls_spec.js
similarity index 96%
rename from spec/frontend/repository/components/blob_controls_spec.js
rename to spec/frontend/repository/components/header_area/blob_controls_spec.js
index d36b0b8e7c965..7e3a55223a9b0 100644
--- a/spec/frontend/repository/components/blob_controls_spec.js
+++ b/spec/frontend/repository/components/header_area/blob_controls_spec.js
@@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo';
 
 import createMockApollo from 'helpers/mock_apollo_helper';
 import waitForPromises from 'helpers/wait_for_promises';
-import BlobControls from '~/repository/components/blob_controls.vue';
+import BlobControls from '~/repository/components/header_area/blob_controls.vue';
 import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper';
@@ -13,7 +13,7 @@ import { resetShortcutsForTests } from '~/behaviors/shortcuts';
 import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob';
 import Shortcuts from '~/behaviors/shortcuts/shortcuts';
 import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
-import { blobControlsDataMock, refMock } from '../mock_data';
+import { blobControlsDataMock, refMock } from '../../mock_data';
 
 jest.mock('~/repository/utils/dom');
 jest.mock('~/behaviors/shortcuts/shortcuts_blob');
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/header_area/breadcrumbs_spec.js
similarity index 98%
rename from spec/frontend/repository/components/breadcrumbs_spec.js
rename to spec/frontend/repository/components/header_area/breadcrumbs_spec.js
index 57ee050298693..92f888c678bc9 100644
--- a/spec/frontend/repository/components/breadcrumbs_spec.js
+++ b/spec/frontend/repository/components/header_area/breadcrumbs_spec.js
@@ -2,7 +2,7 @@ import Vue, { nextTick } from 'vue';
 import VueApollo from 'vue-apollo';
 import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
 import { shallowMount, RouterLinkStub } from '@vue/test-utils';
-import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
+import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.vue';
 import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
 import NewDirectoryModal from '~/repository/components/new_directory_modal.vue';
 import waitForPromises from 'helpers/wait_for_promises';
@@ -55,6 +55,9 @@ describe('Repository breadcrumbs component', () => {
 
     wrapper = shallowMount(Breadcrumbs, {
       apolloProvider,
+      provide: {
+        projectRootPath: TEST_PROJECT_PATH,
+      },
       propsData: {
         currentPath,
         ...extraProps,
diff --git a/spec/frontend/repository/components/header_area_spec.js b/spec/frontend/repository/components/header_area_spec.js
new file mode 100644
index 0000000000000..a4f768b1e364a
--- /dev/null
+++ b/spec/frontend/repository/components/header_area_spec.js
@@ -0,0 +1,160 @@
+import { RouterLinkStub } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import HeaderArea from '~/repository/components/header_area.vue';
+import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.vue';
+import BlobControls from '~/repository/components/header_area/blob_controls.vue';
+import Shortcuts from '~/behaviors/shortcuts/shortcuts';
+import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper';
+
+const defaultMockRoute = {
+  params: {
+    path: '',
+  },
+  meta: {
+    refType: '',
+  },
+  query: {
+    ref_type: '',
+  },
+};
+
+const defaultProvided = {
+  canCollaborate: true,
+  canEditTree: true,
+  canPushCode: true,
+  originalBranch: 'main',
+  selectedBranch: 'feature/new-ui',
+  newBranchPath: '/project/new-branch',
+  newTagPath: '/project/new-tag',
+  newBlobPath: '/project/new-file',
+  forkNewBlobPath: '/project/fork/new-file',
+  forkNewDirectoryPath: '/project/fork/new-directory',
+  forkUploadBlobPath: '/project/fork/upload',
+  uploadPath: '/project/upload',
+  newDirPath: '/project/new-directory',
+  projectRootPath: '/project/root/path',
+  comparePath: undefined,
+  isReadmeView: false,
+};
+
+describe('HeaderArea', () => {
+  let wrapper;
+
+  const findBreadcrumbs = () => wrapper.findComponent(Breadcrumbs);
+  const findRefSelector = () => wrapper.findComponent(RefSelector);
+  const findHistoryButton = () => wrapper.findByTestId('tree-history-control');
+  const findFindFileButton = () => wrapper.findByTestId('tree-find-file-control');
+  const findCompareButton = () => wrapper.findByTestId('tree-compare-control');
+  const { bindInternalEventDocument } = useMockInternalEventsTracking();
+
+  const createComponent = (props = {}, routeName = 'blobPathDecoded', provided = {}) => {
+    return shallowMountExtended(HeaderArea, {
+      provide: {
+        ...defaultProvided,
+        ...provided,
+      },
+      propsData: {
+        projectPath: 'test/project',
+        historyLink: '/history',
+        refType: 'branch',
+        projectId: '123',
+        refSelectorValue: 'refs/heads/main',
+        ...props,
+      },
+      stubs: {
+        RouterLink: RouterLinkStub,
+      },
+      mocks: {
+        $route: {
+          ...defaultMockRoute,
+          name: routeName,
+        },
+      },
+    });
+  };
+
+  beforeEach(() => {
+    wrapper = createComponent();
+  });
+
+  it('renders the component', () => {
+    expect(wrapper.exists()).toBe(true);
+  });
+
+  it('renders RefSelector', () => {
+    expect(findRefSelector().exists()).toBe(true);
+  });
+
+  it('renders Breadcrumbs component', () => {
+    expect(findBreadcrumbs().exists()).toBe(true);
+  });
+
+  describe('when rendered for tree view', () => {
+    beforeEach(() => {
+      wrapper = createComponent({}, 'treePathDecoded');
+    });
+
+    describe('History button', () => {
+      it('renders History button with correct href', () => {
+        expect(findHistoryButton().exists()).toBe(true);
+        expect(findHistoryButton().attributes('href')).toContain('/history');
+      });
+    });
+
+    describe('Find file button', () => {
+      it('renders Find file button', () => {
+        expect(findFindFileButton().exists()).toBe(true);
+      });
+
+      it('triggers a `focusSearchFile` shortcut when the findFile button is clicked', () => {
+        jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue();
+        findFindFileButton().vm.$emit('click');
+
+        expect(Shortcuts.focusSearchFile).toHaveBeenCalled();
+      });
+
+      it('emits a tracking event when the Find file button is clicked', () => {
+        const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
+        jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue();
+
+        findFindFileButton().vm.$emit('click');
+
+        expect(trackEventSpy).toHaveBeenCalledWith('click_find_file_button_on_repository_pages');
+      });
+    });
+
+    describe('Compare button', () => {
+      it('does not render Compare button for root ref', () => {
+        expect(findCompareButton().exists()).not.toBe(true);
+      });
+
+      it('renders Compare button for non-root ref', () => {
+        wrapper = createComponent({}, 'treePathDecoded', { comparePath: 'test/project/compare' });
+        expect(findCompareButton().exists()).toBe(true);
+      });
+    });
+  });
+
+  describe('when rendered for blob view', () => {
+    it('renders BlobControls component with correct props', () => {
+      wrapper = createComponent({ refType: 'branch' });
+      const blobControls = wrapper.findComponent(BlobControls);
+      expect(blobControls.exists()).toBe(true);
+      expect(blobControls.props('projectPath')).toBe('test/project');
+      expect(blobControls.props('refType')).toBe('');
+    });
+  });
+
+  describe('when isReadmeView is true', () => {
+    beforeEach(() => {
+      wrapper = createComponent({}, 'treePathDecoded', { isReadmeView: true });
+    });
+
+    it('does not render RefSelector, Breadcrumbs and History button', () => {
+      expect(findRefSelector().exists()).toBe(false);
+      expect(findBreadcrumbs().exists()).toBe(false);
+      expect(findHistoryButton().exists()).toBe(false);
+    });
+  });
+});
diff --git a/spec/frontend/repository/utils/url_utility_spec.js b/spec/frontend/repository/utils/url_utility_spec.js
new file mode 100644
index 0000000000000..19128340a6f37
--- /dev/null
+++ b/spec/frontend/repository/utils/url_utility_spec.js
@@ -0,0 +1,38 @@
+import { generateHistoryUrl } from '~/repository/utils/url_utility';
+
+describe('Repository URL utilities', () => {
+  describe('generateHistoryUrl', () => {
+    it('generates correct URL with path and ref type', () => {
+      const historyLink = '/-/commits';
+      const path = 'path/to/file.js';
+      const refType = 'branch';
+
+      const result = generateHistoryUrl(historyLink, path, refType);
+
+      expect(result.pathname).toBe('/-/commits/path/to/file.js');
+      expect(result.searchParams.get('ref_type')).toBe('branch');
+    });
+
+    it('generates correct URL when path is empty', () => {
+      const historyLink = '/-/commits';
+      const path = '';
+      const refType = 'tag';
+
+      const result = generateHistoryUrl(historyLink, path, refType);
+
+      expect(result.pathname).toBe('/-/commits');
+      expect(result.searchParams.get('ref_type')).toBe('tag');
+    });
+
+    it('escapes special characters in path', () => {
+      const historyLink = '/-/commits';
+      const path = 'path/to/file with spaces.js';
+      const refType = 'branch';
+
+      const result = generateHistoryUrl(historyLink, path, refType);
+
+      expect(result.pathname).toBe('/-/commits/path/to/file%20with%20spaces.js');
+      expect(result.searchParams.get('ref_type')).toBe('branch');
+    });
+  });
+});
-- 
GitLab