diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue index d38b38947b6bd15cc5d9e59103b15957227ce153..5c77f087d63d344ce265caf267c136d6d6900677 100644 --- a/app/assets/javascripts/code_navigation/components/app.vue +++ b/app/assets/javascripts/code_navigation/components/app.vue @@ -7,6 +7,23 @@ export default { components: { Popover, }, + props: { + codeNavigationPath: { + type: String, + required: false, + default: null, + }, + blobPath: { + type: String, + required: false, + default: null, + }, + pathPrefix: { + type: String, + required: false, + default: null, + }, + }, computed: { ...mapState([ 'currentDefinition', @@ -16,6 +33,14 @@ export default { ]), }, mounted() { + if (this.codeNavigationPath && this.blobPath && this.pathPrefix) { + const initialData = { + blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }], + definitionPathPrefix: this.pathPrefix, + }; + this.setInitialData(initialData); + } + this.body = document.body; eventHub.$on('showBlobInteractionZones', this.showBlobInteractionZones); @@ -28,7 +53,7 @@ export default { this.removeGlobalEventListeners(); }, methods: { - ...mapActions(['fetchData', 'showDefinition', 'showBlobInteractionZones']), + ...mapActions(['fetchData', 'showDefinition', 'showBlobInteractionZones', 'setInitialData']), addGlobalEventListeners() { if (this.body) { this.body.addEventListener('click', this.showDefinition); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 2fc9a1114051b601b0a100b6b29f850eb843f031..740fdb8a96a61965e29d0a8ac6ffe91dc185b727 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Vuex from 'vuex'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import TableOfContents from '~/blob/components/table_contents.vue'; @@ -11,7 +12,9 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import '~/sourcegraph/load'; +import createStore from '~/code_navigation/store'; +Vue.use(Vuex); Vue.use(VueApollo); Vue.use(VueRouter); @@ -29,6 +32,7 @@ if (viewBlobEl) { // eslint-disable-next-line no-new new Vue({ el: viewBlobEl, + store: createStore(), router, apolloProvider, provide: { @@ -78,7 +82,7 @@ GpgBadges.fetch(); const codeNavEl = document.getElementById('js-code-navigation'); -if (codeNavEl) { +if (codeNavEl && !viewBlobEl) { const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset; // eslint-disable-next-line promise/catch-or-return diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 799c4affa527862556c5c081ed73d2a23d528caf..3fdc45b4e3de080ac079d5a6d9b1164521d69859 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -11,6 +11,7 @@ import { __ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import CodeIntelligence from '~/code_navigation/components/app.vue'; import getRefMixin from '../mixins/get_ref'; import blobInfoQuery from '../queries/blob_info.query.graphql'; import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE } from '../constants'; @@ -30,6 +31,7 @@ export default { GlButton, ForkSuggestion, WebIdeLink, + CodeIntelligence, }, mixins: [getRefMixin, glFeatureFlagMixin()], inject: { @@ -274,6 +276,12 @@ export default { :loading="isLoadingLegacyViewer" /> <component :is="blobViewer" v-else :blob="blobInfo" class="blob-viewer" /> + <code-intelligence + v-if="blobViewer || legacyViewerLoaded" + :code-navigation-path="blobInfo.codeNavigationPath" + :blob-path="blobInfo.path" + :path-prefix="blobInfo.projectBlobPathRoot" + /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index aa2f2fa8f11757b5f59ffe4ea2ce1ecb7e0c00a7..d71f23c57dded5e71322e07fe7c0671673591cbe 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -52,6 +52,8 @@ export const DEFAULT_BLOB_INFO = { ideEditPath: '', forkAndEditPath: '', ideForkAndEditPath: '', + codeNavigationPath: '', + projectBlobPathRoot: '', forkAndViewPath: '', storedExternally: false, externalStorage: '', diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 120c32caefd36dfdae63a7de4b4257b8d54dc8ed..b38a1cfdc7b6844e921c74887ccb1c5396e445b1 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,10 +1,12 @@ import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; +import Vuex from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import PerformancePlugin from '~/performance/vue_performance_plugin'; +import createStore from '~/code_navigation/store'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; @@ -19,6 +21,7 @@ import createRouter from './router'; import { updateFormAction } from './utils/dom'; import { setTitle } from './utils/title'; +Vue.use(Vuex); Vue.use(PerformancePlugin, { components: ['SimpleViewer', 'BlobContent'], }); @@ -200,6 +203,7 @@ export default function setupVueRepositoryList() { // eslint-disable-next-line no-new new Vue({ el, + store: createStore(), router, apolloProvider, render(h) { diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 14d7f903e757f97878c2764b18bc8409700896a4..389c3715586379d8a59af4339eab3885dceb0c5b 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -31,6 +31,8 @@ query getBlobInfo( ideEditPath forkAndEditPath ideForkAndEditPath + codeNavigationPath + projectBlobPathRoot forkAndViewPath environmentFormattedExternalUrl environmentExternalUrlForRouteMap diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb index 1c14cefd7d7924dc15540dcae79ec8fcd43812dd..eb481c6c91d7a507314f88b81b9130852b989e18 100644 --- a/app/graphql/types/repository/blob_type.rb +++ b/app/graphql/types/repository/blob_type.rb @@ -134,6 +134,12 @@ class BlobType < BaseObject null: true, calls_gitaly: true + field :code_navigation_path, GraphQL::Types::String, null: true, calls_gitaly: true, + description: 'Web path for code navigation.' + + field :project_blob_path_root, GraphQL::Types::String, null: true, + description: 'Web path for the root of the blob.' + def raw_text_blob object.data unless object.binary? end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index 70a1ed188f37750eb1d73b36deab33bcccdba4c1..c4a5bfe71e32409f7069a251898aaeec3f8a4080 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -131,6 +131,14 @@ def external_storage_url external_storage_url_or_path(url_helpers.project_raw_url(project, ref_qualified_path), project) end + def code_navigation_path + Gitlab::CodeNavigationPath.new(project, blob.commit_id).full_json_path_for(blob.path) + end + + def project_blob_path_root + project_blob_path(project, blob.commit_id) + end + private def url_helpers diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index aa6c4da6262811dcb00e56e53043eea5ecfe1d00..f8f0eb8b0f5eb34bf4f0a15c3df17fbe0a471808 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -15025,6 +15025,7 @@ Returns [`Tree`](#tree). | <a id="repositoryblobblamepath"></a>`blamePath` | [`String`](#string) | Web path to blob blame page. | | <a id="repositoryblobcancurrentuserpushtobranch"></a>`canCurrentUserPushToBranch` | [`Boolean`](#boolean) | Whether the current user can push to the branch. | | <a id="repositoryblobcanmodifyblob"></a>`canModifyBlob` | [`Boolean`](#boolean) | Whether the current user can modify the blob. | +| <a id="repositoryblobcodenavigationpath"></a>`codeNavigationPath` | [`String`](#string) | Web path for code navigation. | | <a id="repositoryblobcodeowners"></a>`codeOwners` | [`[UserCore!]`](#usercore) | List of code owners for the blob. | | <a id="repositoryblobeditblobpath"></a>`editBlobPath` | [`String`](#string) | Web path to edit the blob in the old-style editor. | | <a id="repositoryblobenvironmentexternalurlforroutemap"></a>`environmentExternalUrlForRouteMap` | [`String`](#string) | Web path to blob on an environment. | @@ -15048,6 +15049,7 @@ Returns [`Tree`](#tree). | <a id="repositoryblobpermalinkpath"></a>`permalinkPath` | [`String`](#string) | Web path to blob permalink. | | <a id="repositoryblobpipelineeditorpath"></a>`pipelineEditorPath` | [`String`](#string) | Web path to edit .gitlab-ci.yml file. | | <a id="repositoryblobplaindata"></a>`plainData` | [`String`](#string) | Blob plain highlighted data. | +| <a id="repositoryblobprojectblobpathroot"></a>`projectBlobPathRoot` | [`String`](#string) | Web path for the root of the blob. | | <a id="repositoryblobrawblob"></a>`rawBlob` | [`String`](#string) | Raw content of the blob. | | <a id="repositoryblobrawpath"></a>`rawPath` | [`String`](#string) | Web path to download the raw blob. | | <a id="repositoryblobrawsize"></a>`rawSize` | [`Int`](#int) | Size (in bytes) of the blob, or the blob target if stored externally. | diff --git a/ee/spec/frontend/repository/components/blob_content_viewer_spec.js b/ee/spec/frontend/repository/components/blob_content_viewer_spec.js index 6b0eb09d180bcafade06f4a87b79690b756c560f..90b59a7a6c868abaf24d3cb4e42ae3d76eb01636 100644 --- a/ee/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/ee/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Vuex from 'vuex'; import VueRouter from 'vue-router'; import VueApollo from 'vue-apollo'; import axios from 'axios'; @@ -28,6 +29,9 @@ let mockResolver; Vue.use(VueApollo); +const createMockStore = () => + new Vuex.Store({ actions: { fetchData: jest.fn, setInitialData: jest.fn() } }); + const createComponent = async (mockData = {}) => { const { blob = simpleViewerMock, @@ -58,6 +62,7 @@ const createComponent = async (mockData = {}) => { const fakeApollo = createMockApollo([[blobInfoQuery, mockResolver]]); wrapper = mountExtended(BlobContentViewer, { + store: createMockStore(), router, apolloProvider: fakeApollo, propsData: { diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js index 9306c15e6767c44e88b066b0d34fb8bad3408a87..0d7c0360e9bb128794c02f4efeec2b6c255eeeb1 100644 --- a/spec/frontend/code_navigation/components/app_spec.js +++ b/spec/frontend/code_navigation/components/app_spec.js @@ -5,13 +5,14 @@ import App from '~/code_navigation/components/app.vue'; import Popover from '~/code_navigation/components/popover.vue'; import createState from '~/code_navigation/store/state'; +const setInitialData = jest.fn(); const fetchData = jest.fn(); const showDefinition = jest.fn(); let wrapper; Vue.use(Vuex); -function factory(initialState = {}) { +function factory(initialState = {}, props = {}) { const store = new Vuex.Store({ state: { ...createState(), @@ -19,12 +20,13 @@ function factory(initialState = {}) { definitionPathPrefix: 'https://test.com/blob/main', }, actions: { + setInitialData, fetchData, showDefinition, }, }); - wrapper = shallowMount(App, { store }); + wrapper = shallowMount(App, { store, propsData: { ...props } }); } describe('Code navigation app component', () => { @@ -32,6 +34,19 @@ describe('Code navigation app component', () => { wrapper.destroy(); }); + it('sets initial data on mount if the correct props are passed', () => { + const codeNavigationPath = 'code/nav/path.js'; + const path = 'blob/path.js'; + const definitionPathPrefix = 'path/prefix'; + + factory({}, { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix }); + + expect(setInitialData).toHaveBeenCalledWith(expect.anything(), { + blobs: [{ codeNavigationPath, path }], + definitionPathPrefix, + }); + }); + it('fetches data on mount', () => { factory(); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 094892b87b21f76c6b2565e984f043ff5c9568db..54ec4e8e579ec0f37cfdb678d8d99f510d83628a 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -1,5 +1,6 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; import Vue, { nextTick } from 'vue'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -17,9 +18,11 @@ import DownloadViewer from '~/repository/components/blob_viewers/download_viewer import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; import blobInfoQuery from '~/repository/queries/blob_info.query.graphql'; +import CodeIntelligence from '~/code_navigation/components/app.vue'; import { redirectTo } from '~/lib/utils/url_utility'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import httpStatusCodes from '~/lib/utils/http_status'; import { simpleViewerMock, richViewerMock, @@ -38,6 +41,9 @@ let mockResolver; const mockAxios = new MockAdapter(axios); +const createMockStore = () => + new Vuex.Store({ actions: { fetchData: jest.fn, setInitialData: jest.fn() } }); + const createComponent = async (mockData = {}, mountFn = shallowMount) => { Vue.use(VueApollo); @@ -75,6 +81,7 @@ const createComponent = async (mockData = {}, mountFn = shallowMount) => { wrapper = extendedWrapper( mountFn(BlobContentViewer, { + store: createMockStore(), apolloProvider: fakeApollo, propsData: propsMock, mixins: [{ data: () => ({ ref: refMock }) }], @@ -104,6 +111,7 @@ describe('Blob content viewer component', () => { const findBlobContent = () => wrapper.findComponent(BlobContent); const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion); + const findCodeIntelligence = () => wrapper.findComponent(CodeIntelligence); beforeEach(() => { isLoggedIn.mockReturnValue(true); @@ -219,6 +227,26 @@ describe('Blob content viewer component', () => { loadViewer.mockRestore(); }); + it('renders a CodeIntelligence component with the correct props', async () => { + loadViewer.mockReturnValue(SourceViewer); + + await createComponent(); + + expect(findCodeIntelligence().props()).toMatchObject({ + codeNavigationPath: simpleViewerMock.codeNavigationPath, + blobPath: simpleViewerMock.path, + pathPrefix: simpleViewerMock.projectBlobPathRoot, + }); + }); + + it('does not load a CodeIntelligence component when no viewers are loaded', async () => { + const url = 'some_file.js?format=json&viewer=rich'; + mockAxios.onGet(url).replyOnce(httpStatusCodes.INTERNAL_SERVER_ERROR); + await createComponent({ blob: { ...richViewerMock, fileType: 'unknown' } }); + + expect(findCodeIntelligence().exists()).toBe(false); + }); + it('does not render a BlobContent component if a Blob viewer is available', async () => { loadViewer.mockReturnValue(() => true); await createComponent({ blob: richViewerMock }); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index ba517fbc20c713893bd5748ae863d420568b6650..b47aefd190513bb32561abad183d83d03e0631c3 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -13,6 +13,8 @@ export const simpleViewerMock = { forkAndEditPath: 'some_file.js/fork/edit', ideForkAndEditPath: 'some_file.js/fork/ide', forkAndViewPath: 'some_file.js/fork/view', + codeNavigationPath: '', + projectBlobPathRoot: '', environmentFormattedExternalUrl: '', environmentExternalUrlForRouteMap: '', canModifyBlob: true, diff --git a/spec/graphql/types/repository/blob_type_spec.rb b/spec/graphql/types/repository/blob_type_spec.rb index 66ab4ef7170353e8c6aff6a98e285cf426a54823..e721b8b5118493fe09b041e0201b0562db4ca30d 100644 --- a/spec/graphql/types/repository/blob_type_spec.rb +++ b/spec/graphql/types/repository/blob_type_spec.rb @@ -31,6 +31,8 @@ :permalink_path, :environment_formatted_external_url, :environment_external_url_for_route_map, + :code_navigation_path, + :project_blob_path_root, :code_owners, :simple_viewer, :rich_viewer, diff --git a/spec/presenters/blob_presenter_spec.rb b/spec/presenters/blob_presenter_spec.rb index 847668ffc52f157e81a72172576130d46de46aa7..21bb96c061b5b35444f1d6c7cd147c4dcfab8d46 100644 --- a/spec/presenters/blob_presenter_spec.rb +++ b/spec/presenters/blob_presenter_spec.rb @@ -154,6 +154,16 @@ end end + describe '#code_navigation_path' do + let(:code_navigation_path) { Gitlab::CodeNavigationPath.new(project, blob.commit_id).full_json_path_for(blob.path) } + + it { expect(presenter.code_navigation_path).to eq(code_navigation_path) } + end + + describe '#project_blob_path_root' do + it { expect(presenter.project_blob_path_root).to eq("/#{project.full_path}/-/blob/HEAD") } + end + context 'given a Gitlab::Graphql::Representation::TreeEntry' do let(:blob) { Gitlab::Graphql::Representation::TreeEntry.new(super(), repository) }