From 4a7e36f410cc0a9466167b94dc76e7c92deddebe Mon Sep 17 00:00:00 2001
From: Paulina Sedlak-Jakubowska <psedlak-jakubowska@gitlab.com>
Date: Fri, 17 Jan 2025 15:07:14 +0000
Subject: [PATCH] Remove unused prop

The only component that was passing that prop has been
refactored to not use BlobHeader component at all in
https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175292

Changelog: changed
---
 .../blob/components/blob_header.vue           |   7 +-
 .../components/blob_content_viewer.vue        |   1 +
 .../repository/components/header_area.vue     |   3 +-
 .../components/header_area/blob_controls.vue  |  41 +++++-
 .../header_area/blob_overflow_menu.vue        |  34 +++--
 .../javascripts/repository/init_header_app.js |   2 +
 .../queries/blob_controls.query.graphql       |  16 ++
 app/controllers/projects/tree_controller.rb   |   1 +
 app/helpers/blob_helper.rb                    |   1 +
 ee/spec/frontend/repository/mock_data.js      |   1 +
 scripts/frontend/quarantined_vue3_specs.txt   |   1 +
 .../features/projects/blobs/blob_show_spec.rb |   4 +
 .../projects/files/user_browses_files_spec.rb |   2 +
 spec/features/projects/view_on_env_spec.rb    |   2 +
 .../blob/components/blob_header_spec.js       |  16 +-
 .../header_area/blob_controls_spec.js         | 138 +++++++++++++++---
 .../header_area/blob_overflow_menu_spec.js    |  27 +++-
 spec/frontend/repository/mock_data.js         |  23 ++-
 spec/helpers/blob_helper_spec.rb              |   1 +
 19 files changed, 268 insertions(+), 53 deletions(-)

diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index 63f84fd9e5dc7..9793c5854651d 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -38,7 +38,7 @@ export default {
       type: Object,
       required: true,
     },
-    hideDefaultActions: {
+    isBlobPage: {
       type: Boolean,
       required: false,
       default: false,
@@ -121,9 +121,6 @@ export default {
     };
   },
   computed: {
-    showDefaultActions() {
-      return !this.hideDefaultActions;
-    },
     showWebIdeLink() {
       return !this.blob.archived && this.blob.editBlobPath;
     },
@@ -206,7 +203,7 @@ export default {
       <slot name="actions"></slot>
 
       <default-actions
-        v-if="showDefaultActions"
+        v-if="!glFeatures.blobOverflowMenu || (glFeatures.blobOverflowMenu && !isBlobPage)"
         :raw-path="blob.externalStorageUrl || blob.rawPath"
         :active-viewer="viewer"
         :has-render-error="hasRenderError"
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index c05cbdaffe3ae..829cc757d67cc 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -335,6 +335,7 @@ export default {
     <gl-loading-icon v-if="isLoading" size="sm" />
     <div v-if="blobInfo && !isLoading" id="fileHolder" class="file-holder">
       <blob-header
+        is-blob-page
         :blob="blobInfo"
         :hide-viewer-switcher="isBinaryFileType || isUsingLfs"
         :is-binary="isBinaryFileType"
diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue
index 5301d789ae74d..1d3b0cbe07161 100644
--- a/app/assets/javascripts/repository/components/header_area.vue
+++ b/app/assets/javascripts/repository/components/header_area.vue
@@ -78,6 +78,7 @@ export default {
     'kerberosUrl',
     'downloadLinks',
     'downloadArtifacts',
+    'isBinary',
   ],
   props: {
     projectPath: {
@@ -296,7 +297,7 @@ export default {
       </div>
 
       <!-- Blob controls -->
-      <blob-controls :project-path="projectPath" :ref-type="getRefType" />
+      <blob-controls :project-path="projectPath" :ref-type="getRefType" :is-binary="isBinary" />
     </div>
   </section>
 </template>
diff --git a/app/assets/javascripts/repository/components/header_area/blob_controls.vue b/app/assets/javascripts/repository/components/header_area/blob_controls.vue
index b3b8668afe6b0..7fe14d5720e61 100644
--- a/app/assets/javascripts/repository/components/header_area/blob_controls.vue
+++ b/app/assets/javascripts/repository/components/header_area/blob_controls.vue
@@ -3,6 +3,7 @@ import { GlButton, GlTooltipDirective } from '@gitlab/ui';
 import { __ } from '~/locale';
 import { createAlert } from '~/alert';
 import getRefMixin from '~/repository/mixins/get_ref';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
 import initSourcegraph from '~/sourcegraph';
 import Shortcuts from '~/behaviors/shortcuts/shortcuts';
 import { addShortcutsExtension } from '~/behaviors/shortcuts';
@@ -21,6 +22,8 @@ import { FIND_FILE_BUTTON_CLICK } from '~/tracking/constants';
 import { updateElementsVisibility } from '~/repository/utils/dom';
 import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql';
 import { getRefType } from '~/repository/utils/ref_type';
+import { TEXT_FILE_TYPE } from '../../constants';
+import OverflowMenu from './blob_overflow_menu.vue';
 
 export default {
   i18n: {
@@ -33,11 +36,12 @@ export default {
   buttonClassList: 'sm:gl-w-auto gl-w-full sm:gl-mt-0 gl-mt-3',
   components: {
     GlButton,
+    OverflowMenu,
   },
   directives: {
     GlTooltip: GlTooltipDirective,
   },
-  mixins: [getRefMixin],
+  mixins: [getRefMixin, glFeatureFlagMixin()],
   apollo: {
     project: {
       query: blobControlsQuery,
@@ -67,6 +71,11 @@ export default {
       required: false,
       default: null,
     },
+    isBinary: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
   },
   data() {
     return {
@@ -74,6 +83,9 @@ export default {
     };
   },
   computed: {
+    isLoadingRepositoryBlob() {
+      return this.$apollo.queries.project.loading;
+    },
     filePath() {
       return this.$route.params.path;
     },
@@ -86,6 +98,12 @@ export default {
     showBlameButton() {
       return !this.blobInfo.storedExternally && this.blobInfo.externalStorage !== 'lfs';
     },
+    isBinaryFileType() {
+      return this.isBinary || this.blobInfo.simpleViewer?.fileType !== TEXT_FILE_TYPE;
+    },
+    rawPath() {
+      return this.blobInfo.externalStorageUrl || this.blobInfo.rawPath;
+    },
     findFileShortcutKey() {
       return keysFor(START_SEARCH_PROJECT_FILE)[0];
     },
@@ -106,6 +124,9 @@ export default {
         ? null
         : sanitize(`${description} <kbd class="flat gl-ml-1" aria-hidden=true>${key}</kbd>`);
     },
+    isEmpty() {
+      return this.blobInfo.rawSize === '0';
+    },
   },
   watch: {
     showBlobControls(shouldShow) {
@@ -136,11 +157,14 @@ export default {
       InternalEvents.trackEvent(FIND_FILE_BUTTON_CLICK);
       Shortcuts.focusSearchFile();
     },
+    onCopy() {
+      navigator.clipboard.writeText(this.blobInfo.rawTextBlob);
+    },
   },
 };
 </script>
 <template>
-  <div v-if="showBlobControls" class="gl-flex gl-flex-wrap gl-items-baseline gl-gap-3">
+  <div v-if="showBlobControls" class="gl-flex gl-flex-wrap gl-items-center gl-gap-3">
     <gl-button
       v-gl-tooltip.html="findFileTooltip"
       :aria-keyshortcuts="findFileShortcutKey"
@@ -170,5 +194,18 @@ export default {
     >
       {{ $options.i18n.permalink }}
     </gl-button>
+
+    <overflow-menu
+      v-if="!isLoadingRepositoryBlob && glFeatures.blobOverflowMenu"
+      :raw-path="rawPath"
+      :rich-viewer="blobInfo.richViewer"
+      :simple-viewer="blobInfo.simpleViewer"
+      :is-binary="isBinaryFileType"
+      :environment-name="blobInfo.environmentFormattedExternalUrl"
+      :environment-path="blobInfo.environmentExternalUrlForRouteMap"
+      :is-empty="isEmpty"
+      :override-copy="true"
+      @copy="onCopy"
+    />
   </div>
 </template>
diff --git a/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue b/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue
index 1a0795e8d1996..2911170258fed 100644
--- a/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue
+++ b/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue
@@ -2,6 +2,7 @@
 import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlTooltipDirective } from '@gitlab/ui';
 import { sprintf, s__, __ } from '~/locale';
 import { setUrlParams, relativePathToAbsolute, getBaseURL } from '~/lib/utils/url_utility';
+import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
 
 export const i18n = {
   dropdownLabel: __('Actions'),
@@ -10,12 +11,8 @@ export const i18n = {
   btnRawTitle: s__('BlobViewer|Open raw'),
 };
 
-const RICH_BLOB_VIEWER = 'rich';
-const SIMPLE_BLOB_VIEWER = 'simple';
-
 export default {
   i18n,
-  RICH_BLOB_VIEWER,
   components: {
     GlDisclosureDropdown,
     GlDisclosureDropdownItem,
@@ -36,15 +33,15 @@ export default {
       type: String,
       required: true,
     },
-    activeViewer: {
-      type: String,
-      default: SIMPLE_BLOB_VIEWER,
+    richViewer: {
+      type: Object,
       required: false,
+      default: () => {},
     },
-    hasRenderError: {
-      type: Boolean,
+    simpleViewer: {
+      type: Object,
       required: false,
-      default: false,
+      default: () => {},
     },
     isBinary: {
       type: Boolean,
@@ -73,11 +70,26 @@ export default {
     },
   },
   computed: {
+    activeViewerType() {
+      if (this.$route?.query?.plain !== '1') {
+        const richViewer = document.querySelector('.blob-viewer[data-type="rich"]');
+        if (richViewer) {
+          return RICH_BLOB_VIEWER;
+        }
+      }
+      return SIMPLE_BLOB_VIEWER;
+    },
+    viewer() {
+      return this.activeViewerType === RICH_BLOB_VIEWER ? this.richViewer : this.simpleViewer;
+    },
+    hasRenderError() {
+      return Boolean(this.viewer.renderError);
+    },
     downloadUrl() {
       return setUrlParams({ inline: false }, relativePathToAbsolute(this.rawPath, getBaseURL()));
     },
     copyDisabled() {
-      return this.activeViewer === this.$options.RICH_BLOB_VIEWER;
+      return this.activeViewerType === RICH_BLOB_VIEWER;
     },
     getBlobHashTarget() {
       if (this.overrideCopy) {
diff --git a/app/assets/javascripts/repository/init_header_app.js b/app/assets/javascripts/repository/init_header_app.js
index 9c062c3cd3dfa..6275ca1b18c98 100644
--- a/app/assets/javascripts/repository/init_header_app.js
+++ b/app/assets/javascripts/repository/init_header_app.js
@@ -61,6 +61,7 @@ export default function initHeaderApp({ router, isReadmeView = false, isBlobView
       downloadLinks,
       downloadArtifacts,
       projectShortPath,
+      isBinary,
     } = headerEl.dataset;
 
     const {
@@ -125,6 +126,7 @@ export default function initHeaderApp({ router, isReadmeView = false, isBlobView
         downloadLinks: downloadLinks ? JSON.parse(downloadLinks) : null,
         downloadArtifacts: downloadArtifacts ? JSON.parse(downloadArtifacts) : [],
         isBlobView,
+        isBinary: parseBoolean(isBinary),
       },
       apolloProvider,
       router: router || createRouter(projectPath, escapedRef),
diff --git a/app/assets/javascripts/repository/queries/blob_controls.query.graphql b/app/assets/javascripts/repository/queries/blob_controls.query.graphql
index 0f714d1f67944..e6e6505932081 100644
--- a/app/assets/javascripts/repository/queries/blob_controls.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_controls.query.graphql
@@ -9,6 +9,22 @@ query getBlobControls($projectPath: ID!, $filePath: String!, $ref: String!, $ref
           permalinkPath
           storedExternally
           externalStorage
+          environmentFormattedExternalUrl
+          environmentExternalUrlForRouteMap
+          rawPath
+          rawTextBlob
+          simpleViewer {
+            fileType
+            tooLarge
+            type
+            renderError
+          }
+          richViewer {
+            fileType
+            tooLarge
+            type
+            renderError
+          }
         }
       }
     }
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 03e9b1ae5e3de..95e40221eb9b1 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_overflow_menu, current_user)
     push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
   end
 
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 58480d66d43c8..1ae3c63e3e921 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -306,6 +306,7 @@ def vue_blob_app_data(project, blob, ref)
   def vue_blob_header_app_data(project, blob, ref)
     {
       blob_path: blob.path,
+      is_binary: blob.binary?,
       breadcrumbs: breadcrumb_data_attributes,
       escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref),
       history_link: project_commits_path(project, ref),
diff --git a/ee/spec/frontend/repository/mock_data.js b/ee/spec/frontend/repository/mock_data.js
index 08981030825a0..ac696a07e02f8 100644
--- a/ee/spec/frontend/repository/mock_data.js
+++ b/ee/spec/frontend/repository/mock_data.js
@@ -65,6 +65,7 @@ export const headerAppInjected = {
   downloadArtifacts: [
     'https://gitlab.com/example-group/example-project/-/jobs/artifacts/main/download?job=build',
   ],
+  isBinary: false,
 };
 
 export const userPermissionsMock = {
diff --git a/scripts/frontend/quarantined_vue3_specs.txt b/scripts/frontend/quarantined_vue3_specs.txt
index 85690d5427d3f..92bf9242274e9 100644
--- a/scripts/frontend/quarantined_vue3_specs.txt
+++ b/scripts/frontend/quarantined_vue3_specs.txt
@@ -248,6 +248,7 @@ spec/frontend/releases/components/app_edit_new_spec.js
 spec/frontend/releases/components/asset_links_form_spec.js
 spec/frontend/releases/components/tag_field_exsting_spec.js
 spec/frontend/repository/components/header_area/blob_controls_spec.js
+spec/frontend/repository/components/header_area/blob_overflow_menu_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/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb
index 2089c9df145d7..3b4962ee155da 100644
--- a/spec/features/projects/blobs/blob_show_spec.rb
+++ b/spec/features/projects/blobs/blob_show_spec.rb
@@ -29,6 +29,10 @@ def create_file(file_name, content)
     ).execute
   end
 
+  before do
+    stub_feature_flags(blob_overflow_menu: false)
+  end
+
   context 'Ruby file' do
     before do
       visit_blob('files/ruby/popen.rb')
diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb
index 59bf3922865d7..555fb630890ba 100644
--- a/spec/features/projects/files/user_browses_files_spec.rb
+++ b/spec/features/projects/files/user_browses_files_spec.rb
@@ -17,6 +17,8 @@
 
   before do
     sign_in(user)
+
+    stub_feature_flags(blob_overflow_menu: false)
   end
 
   it "shows last commit for current directory", :js do
diff --git a/spec/features/projects/view_on_env_spec.rb b/spec/features/projects/view_on_env_spec.rb
index 5f502c0297a2c..db1fff201b3f8 100644
--- a/spec/features/projects/view_on_env_spec.rb
+++ b/spec/features/projects/view_on_env_spec.rb
@@ -41,6 +41,8 @@
         file_path: file_path,
         file_content: '# Noop'
       ).execute
+
+      stub_feature_flags(blob_overflow_menu: false)
     end
 
     context 'and an active deployment' do
diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js
index 8d63f2a22e045..70f0b3c315a31 100644
--- a/spec/frontend/blob/components/blob_header_spec.js
+++ b/spec/frontend/blob/components/blob_header_spec.js
@@ -22,6 +22,7 @@ describe('Blob Header Default Actions', () => {
 
   const defaultProvide = {
     blobHash: 'foo-bar',
+    glFeatures: { blobOverflowMenu: true },
   };
 
   const findDefaultActions = () => wrapper.findComponent(DefaultActions);
@@ -126,6 +127,12 @@ describe('Blob Header Default Actions', () => {
       });
     });
 
+    it('does not render DefaultActions when on blob page', () => {
+      createComponent({ propsData: { isBlobPage: true } });
+
+      expect(findDefaultActions().exists()).toBe(false);
+    });
+
     it.each([[{ showBlameToggle: true }], [{ showBlameToggle: false }]])(
       'passes the `showBlameToggle` prop to the viewer switcher',
       (propsData) => {
@@ -153,15 +160,6 @@ describe('Blob Header Default Actions', () => {
       expect(findViewSwitcher().exists()).toBe(false);
     });
 
-    it('does not render default actions is corresponding prop is passed', () => {
-      createComponent({
-        propsData: {
-          hideDefaultActions: true,
-        },
-      });
-      expect(findDefaultActions().exists()).toBe(false);
-    });
-
     it.each`
       slotContent      | key
       ${'Foo Prepend'} | ${'prepend'}
diff --git a/spec/frontend/repository/components/header_area/blob_controls_spec.js b/spec/frontend/repository/components/header_area/blob_controls_spec.js
index 39a1c194f8129..422164f79353c 100644
--- a/spec/frontend/repository/components/header_area/blob_controls_spec.js
+++ b/spec/frontend/repository/components/header_area/blob_controls_spec.js
@@ -1,6 +1,5 @@
 import Vue, { nextTick } from 'vue';
 import VueApollo from 'vue-apollo';
-
 import createMockApollo from 'helpers/mock_apollo_helper';
 import waitForPromises from 'helpers/wait_for_promises';
 import BlobControls from '~/repository/components/header_area/blob_controls.vue';
@@ -13,6 +12,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 OverflowMenu from '~/repository/components/header_area/blob_overflow_menu.vue';
 import { blobControlsDataMock, refMock } from '../../mock_data';
 
 jest.mock('~/repository/utils/dom');
@@ -23,24 +23,45 @@ let router;
 let wrapper;
 let mockResolver;
 
-const createComponent = async () => {
+const createComponent = async (
+  props = {},
+  blobInfoOverrides = {},
+  glFeatures = { blobOverflowMenu: false },
+) => {
   Vue.use(VueApollo);
 
-  const project = { ...blobControlsDataMock };
   const projectPath = 'some/project';
-
   router = createRouter(projectPath, refMock);
 
   router.replace({ name: 'blobPath', params: { path: '/some/file.js' } });
 
-  mockResolver = jest.fn().mockResolvedValue({ data: { project } });
+  mockResolver = jest.fn().mockResolvedValue({
+    data: {
+      project: {
+        id: '1234',
+        repository: {
+          blobs: {
+            nodes: [{ ...blobControlsDataMock.repository.blobs.nodes[0], ...blobInfoOverrides }],
+          },
+        },
+      },
+    },
+  });
 
   await resetShortcutsForTests();
 
   wrapper = shallowMountExtended(BlobControls, {
     router,
     apolloProvider: createMockApollo([[blobControlsQuery, mockResolver]]),
-    propsData: { projectPath },
+    provide: {
+      glFeatures,
+    },
+    propsData: {
+      projectPath,
+      isBinary: false,
+      refType: 'heads',
+      ...props,
+    },
     mixins: [{ data: () => ({ ref: refMock }) }],
   });
 
@@ -51,29 +72,56 @@ describe('Blob controls component', () => {
   const findFindButton = () => wrapper.findByTestId('find');
   const findBlameButton = () => wrapper.findByTestId('blame');
   const findPermalinkButton = () => wrapper.findByTestId('permalink');
+  const findOverflowMenu = () => wrapper.findComponent(OverflowMenu);
   const { bindInternalEventDocument } = useMockInternalEventsTracking();
 
   beforeEach(() => createComponent());
 
-  it('triggers a `focusSearchFile` shortcut when the findFile button is clicked', () => {
-    const findFileButton = findFindButton();
-    jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue();
-    findFileButton.vm.$emit('click');
+  describe('FindFile button', () => {
+    it('renders FindFile button', () => {
+      expect(findFindButton().exists()).toBe(true);
+    });
 
-    expect(Shortcuts.focusSearchFile).toHaveBeenCalled();
-  });
+    it('triggers a `focusSearchFile` shortcut when the findFile button is clicked', () => {
+      const findFileButton = findFindButton();
+      jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue();
+      findFileButton.vm.$emit('click');
 
-  it('emits a tracking event when the Find file button is clicked', () => {
-    const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
-    jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue();
+      expect(Shortcuts.focusSearchFile).toHaveBeenCalled();
+    });
 
-    findFindButton().vm.$emit('click');
+    it('emits a tracking event when the Find file button is clicked', () => {
+      const { trackEventSpy } = bindInternalEventDocument(wrapper.element);
+      jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue();
 
-    expect(trackEventSpy).toHaveBeenCalledWith('click_find_file_button_on_repository_pages');
+      findFindButton().vm.$emit('click');
+
+      expect(trackEventSpy).toHaveBeenCalledWith('click_find_file_button_on_repository_pages');
+    });
   });
 
-  it('renders a blame button with the correct href', () => {
-    expect(findBlameButton().attributes('href')).toBe('blame/file.js');
+  describe('Blame button', () => {
+    it('renders a blame button with the correct href', () => {
+      expect(findBlameButton().attributes('href')).toBe('blame/file.js');
+    });
+
+    it('does not render blame button when blobInfo.storedExternally is true', async () => {
+      await createComponent({}, { storedExternally: true });
+
+      expect(findBlameButton().exists()).toBe(false);
+    });
+
+    it('does not render blame button when blobInfo.externalStorage is "lfs"', async () => {
+      await createComponent({}, { externalStorage: 'lfs' });
+
+      expect(findBlameButton().exists()).toBe(false);
+    });
+
+    it('renders blame button when blobInfo.storedExternally is false and externalStorage is not "lfs"', async () => {
+      await createComponent({}, { storedExternally: false, externalStorage: null });
+
+      expect(findBlameButton().exists()).toBe(true);
+    });
   });
 
   it('renders a permalink button with the correct href', () => {
@@ -105,4 +153,56 @@ describe('Blob controls component', () => {
   it('loads the BlobLinePermalinkUpdater', () => {
     expect(BlobLinePermalinkUpdater).toHaveBeenCalled();
   });
+
+  describe('BlobOverflow dropdown', () => {
+    it('renders BlobOverflow component with correct props', async () => {
+      await createComponent({}, {}, { blobOverflowMenu: true });
+
+      expect(findOverflowMenu().exists()).toBe(true);
+      expect(findOverflowMenu().props()).toEqual({
+        rawPath: 'https://testing.com/flightjs/flight/snippets/51/raw',
+        isBinary: true,
+        environmentName: '',
+        environmentPath: '',
+        isEmpty: false,
+        overrideCopy: true,
+        simpleViewer: {
+          renderError: null,
+          tooLarge: false,
+          type: 'simple',
+          fileType: 'rich',
+        },
+        richViewer: {
+          renderError: 'too big file',
+          tooLarge: false,
+          type: 'rich',
+          fileType: 'rich',
+        },
+      });
+    });
+
+    it('passes the correct isBinary value to BlobOverflow when viewing a binary file', async () => {
+      await createComponent(
+        { isBinary: true },
+        {
+          simpleViewer: {
+            ...blobControlsDataMock.repository.blobs.nodes[0].simpleViewer,
+            fileType: 'podfile',
+          },
+        },
+        { blobOverflowMenu: true },
+      );
+
+      expect(findOverflowMenu().props('isBinary')).toBe(true);
+    });
+
+    it('copies to clipboard raw blob text, when receives copy event', async () => {
+      await createComponent({}, {}, { blobOverflowMenu: true });
+
+      jest.spyOn(navigator.clipboard, 'writeText');
+      findOverflowMenu().vm.$emit('copy');
+
+      expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Example raw text content');
+    });
+  });
 });
diff --git a/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js b/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js
index 949aa6c6614e6..b9c6b9b3eac92 100644
--- a/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js
+++ b/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js
@@ -1,6 +1,8 @@
 import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui';
-import BlobOverflowMenu from '~/repository/components/header_area/blob_overflow_menu.vue';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import BlobOverflowMenu from '~/repository/components/header_area/blob_overflow_menu.vue';
+import createRouter from '~/repository/router';
+import { refMock } from '../../mock_data';
 
 const Blob = {
   binary: false,
@@ -36,16 +38,25 @@ const mockEnvironmentPath = 'https://my.testing.environment';
 describe('Blob Overflow Menu', () => {
   let wrapper;
 
+  const projectPath = '/some/project';
+  const router = createRouter(projectPath, refMock);
+
+  router.replace({ name: 'blobPath', params: { path: '/some/file.js' } });
+
   const blobHash = 'foo-bar';
 
   function createComponent(propsData = {}, provided = {}) {
     wrapper = shallowMountExtended(BlobOverflowMenu, {
+      router,
       provide: {
         blobHash,
         ...provided,
       },
       propsData: {
         rawPath: Blob.rawPath,
+        richViewer: Blob.richViewer,
+        simpleViewer: Blob.simpleViewer,
+        isBinary: false,
         ...propsData,
       },
       stub: {
@@ -83,9 +94,12 @@ describe('Blob Overflow Menu', () => {
       });
 
       it('renders "Copy file contents" button as disabled if the viewer is Rich', () => {
-        createComponent({
-          activeViewer: 'rich',
-        });
+        // Create rich viewer element in DOM
+        const richViewer = document.createElement('div');
+        richViewer.className = 'blob-viewer';
+        richViewer.dataset.type = 'rich';
+        document.body.appendChild(richViewer);
+        createComponent();
 
         expect(findCopyFileContentItem().props('item')).toMatchObject({
           extraAttrs: { disabled: true },
@@ -94,7 +108,10 @@ describe('Blob Overflow Menu', () => {
 
       it('does not render the copy button if a rendering error is set', () => {
         createComponent({
-          hasRenderError: true,
+          richViewer: {
+            ...Blob.richViewer,
+            renderError: 'File too big',
+          },
         });
 
         expect(findDropdownItemWithText('Copy file contents')).toBeUndefined();
diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js
index 4368edb541f62..dc7d40ae78755 100644
--- a/spec/frontend/repository/mock_data.js
+++ b/spec/frontend/repository/mock_data.js
@@ -98,7 +98,27 @@ export const blobControlsDataMock = {
           blamePath: 'blame/file.js',
           permalinkPath: 'permalink/file.js',
           storedExternally: false,
-          externalStorage: '',
+          externalStorage: 'https://external-storage',
+          environmentFormattedExternalUrl: '',
+          environmentExternalUrlForRouteMap: '',
+          rawPath: 'https://testing.com/flightjs/flight/snippets/51/raw',
+          rawTextBlob: 'Example raw text content',
+          simpleViewer: {
+            collapsed: false,
+            loadingPartialName: 'loading',
+            renderError: null,
+            tooLarge: false,
+            type: 'simple',
+            fileType: 'rich',
+          },
+          richViewer: {
+            collapsed: false,
+            loadingPartialName: 'loading',
+            renderError: 'too big file',
+            tooLarge: false,
+            type: 'rich',
+            fileType: 'rich',
+          },
         },
       ],
     },
@@ -240,4 +260,5 @@ export const headerAppInjected = {
   downloadArtifacts: [
     'https://gitlab.com/example-group/example-project/-/jobs/artifacts/main/download?job=build',
   ],
+  isBinary: false,
 };
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index 0ef54fa605978..eb4ae483919db 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -596,6 +596,7 @@
     it 'returns data related to blob header' do
       expect(helper.vue_blob_header_app_data(project, blob, ref)).to include({
         blob_path: blob.path,
+        is_binary: blob.binary?,
         breadcrumbs: breadcrumb_data,
         escaped_ref: ref,
         history_link: project_commits_path(project, ref),
-- 
GitLab