Commit abb4b08e authored by Phil Hughes's avatar Phil Hughes Committed by Nick Thomas

Code navigation frontend components

https://gitlab.com/gitlab-org/gitlab/issues/196514
parent 6dde4feb
......@@ -45,6 +45,7 @@ const Api = {
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
lsifPath: '/api/:version/projects/:id/commits/:commit_id/lsif/info',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
......@@ -457,6 +458,14 @@ const Api = {
return axios.get(url);
},
lsifData(projectPath, commitId, path) {
const url = Api.buildUrl(this.lsifPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':commit_id', commitId);
return axios.get(url, { params: { path } });
},
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
......
<script>
import { mapActions, mapState } from 'vuex';
import Popover from './popover.vue';
export default {
components: {
Popover,
},
computed: {
...mapState(['currentDefinition', 'currentDefinitionPosition']),
},
mounted() {
this.blobViewer = document.querySelector('.blob-viewer');
this.addGlobalEventListeners();
this.fetchData();
},
beforeDestroy() {
this.removeGlobalEventListeners();
},
methods: {
...mapActions(['fetchData', 'showDefinition']),
addGlobalEventListeners() {
if (this.blobViewer) {
this.blobViewer.addEventListener('click', this.showDefinition);
}
},
removeGlobalEventListeners() {
if (this.blobViewer) {
this.blobViewer.removeEventListener('click', this.showDefinition);
}
},
},
};
</script>
<template>
<popover
v-if="currentDefinition"
:position="currentDefinitionPosition"
:data="currentDefinition"
/>
</template>
<script>
import { GlButton } from '@gitlab/ui';
export default {
components: {
GlButton,
},
props: {
position: {
type: Object,
required: true,
},
data: {
type: Object,
required: true,
},
},
data() {
return {
offsetLeft: 0,
};
},
computed: {
positionStyles() {
return {
left: `${this.position.x - this.offsetLeft}px`,
top: `${this.position.y + this.position.height}px`,
};
},
},
watch: {
position: {
handler() {
this.$nextTick(() => this.updateOffsetLeft());
},
deep: true,
immediate: true,
},
},
methods: {
updateOffsetLeft() {
this.offsetLeft = Math.max(
0,
this.$el.offsetLeft + this.$el.offsetWidth - window.innerWidth + 20,
);
},
},
colorScheme: gon?.user_color_scheme,
};
</script>
<template>
<div
:style="positionStyles"
class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show"
>
<div :style="{ left: `${offsetLeft}px` }" class="arrow"></div>
<div v-for="(hover, index) in data.hover" :key="index" class="border-bottom">
<pre
v-if="hover.language"
ref="code-output"
:class="$options.colorScheme"
class="border-0 bg-transparent m-0 code highlight"
v-html="hover.value"
></pre>
<p v-else ref="doc-output" class="p-3 m-0">
{{ hover.value }}
</p>
</div>
<div v-if="data.definition_url" class="popover-body">
<gl-button :href="data.definition_url" target="_blank" class="w-100" variant="default">
{{ __('Go to definition') }}
</gl-button>
</div>
</div>
</template>
import Vue from 'vue';
import Vuex from 'vuex';
import store from './store';
import App from './components/app.vue';
Vue.use(Vuex);
export default () => {
const el = document.getElementById('js-code-navigation');
store.dispatch('setInitialData', el.dataset);
return new Vue({
el,
store,
render(h) {
return h(App);
},
});
};
import api from '~/api';
import { __ } from '~/locale';
import createFlash from '~/flash';
import * as types from './mutation_types';
import { getCurrentHoverElement, setCurrentHoverElement, addInteractionClass } from '../utils';
export default {
setInitialData({ commit }, data) {
commit(types.SET_INITIAL_DATA, data);
},
requestDataError({ commit }) {
commit(types.REQUEST_DATA_ERROR);
createFlash(__('An error occurred loading code navigation'));
},
fetchData({ commit, dispatch, state }) {
commit(types.REQUEST_DATA);
api
.lsifData(state.projectPath, state.commitId, state.path)
.then(({ data }) => {
const normalizedData = data.reduce((acc, d) => {
if (d.hover) {
acc[`${d.start_line}:${d.start_char}`] = d;
addInteractionClass(d);
}
return acc;
}, {});
commit(types.REQUEST_DATA_SUCCESS, normalizedData);
})
.catch(() => dispatch('requestDataError'));
},
showDefinition({ commit, state }, { target: el }) {
let definition;
let position;
if (!state.data) return;
const isCurrentElementPopoverOpen = el.classList.contains('hll');
if (getCurrentHoverElement()) {
getCurrentHoverElement().classList.remove('hll');
}
if (el.classList.contains('js-code-navigation') && !isCurrentElementPopoverOpen) {
const { lineIndex, charIndex } = el.dataset;
position = {
x: el.offsetLeft,
y: el.offsetTop,
height: el.offsetHeight,
};
definition = state.data[`${lineIndex}:${charIndex}`];
el.classList.add('hll');
setCurrentHoverElement(el);
}
commit(types.SET_CURRENT_DEFINITION, { definition, position });
},
};
import Vuex from 'vuex';
import createState from './state';
import actions from './actions';
import mutations from './mutations';
export default new Vuex.Store({
actions,
mutations,
state: createState(),
});
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const REQUEST_DATA = 'REQUEST_DATA';
export const REQUEST_DATA_SUCCESS = 'REQUEST_DATA_SUCCESS';
export const REQUEST_DATA_ERROR = 'REQUEST_DATA_ERROR';
export const SET_CURRENT_DEFINITION = 'SET_CURRENT_DEFINITION';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_DATA](state, { projectPath, commitId, blobPath }) {
state.projectPath = projectPath;
state.commitId = commitId;
state.blobPath = blobPath;
},
[types.REQUEST_DATA](state) {
state.loading = true;
},
[types.REQUEST_DATA_SUCCESS](state, data) {
state.loading = false;
state.data = data;
},
[types.REQUEST_DATA_ERROR](state) {
state.loading = false;
},
[types.SET_CURRENT_DEFINITION](state, { definition, position }) {
state.currentDefinition = definition;
state.currentDefinitionPosition = position;
},
};
export default () => ({
projectPath: null,
commitId: null,
blobPath: null,
loading: false,
data: null,
currentDefinition: null,
currentDefinitionPosition: null,
});
export const cachedData = new Map();
export const getCurrentHoverElement = () => cachedData.get('current');
export const setCurrentHoverElement = el => cachedData.set('current', el);
export const addInteractionClass = d => {
let charCount = 0;
const line = document.getElementById(`LC${d.start_line + 1}`);
const el = [...line.childNodes].find(({ textContent }) => {
if (charCount === d.start_char) return true;
charCount += textContent.length;
return false;
});
if (el) {
el.setAttribute('data-char-index', d.start_char);
el.setAttribute('data-line-index', d.start_line);
el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation');
}
};
......@@ -30,4 +30,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
GpgBadges.fetch();
if (gon.features?.codeNavigation) {
// eslint-disable-next-line promise/catch-or-return
import('~/code_navigation').then(m => m.default());
}
});
......@@ -499,3 +499,15 @@ span.idiff {
background-color: transparent;
border: transparent;
}
.code-navigation {
border-bottom: 1px $gray-darkest dashed;
&:hover {
border-bottom-color: $almost-black;
}
}
.code-navigation-popover {
max-width: 450px;
}
......@@ -29,6 +29,10 @@ class Projects::BlobController < Projects::ApplicationController
before_action :validate_diff_params, only: :diff
before_action :set_last_commit_sha, only: [:edit, :update]
before_action only: :show do
push_frontend_feature_flag(:code_navigation, @project)
end
def new
commit unless @repository.empty?
end
......
......@@ -706,6 +706,10 @@ module ProjectsHelper
Feature.enabled?(:vue_file_list, @project)
end
def native_code_navigation_enabled?(project)
Feature.enabled?(:code_navigation, project)
end
def show_visibility_confirm_modal?(project)
project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0
end
......
......@@ -9,6 +9,8 @@
= render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
- if native_code_navigation_enabled?(@project)
#js-code-navigation{ data: { commit_id: blob.commit_id, path: blob.path, project_path: @project.full_path } }
%article.file-holder
= render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
......@@ -1670,6 +1670,9 @@ msgstr ""
msgid "An error occurred fetching the dropdown data."
msgstr ""
msgid "An error occurred loading code navigation"
msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
......@@ -9297,6 +9300,9 @@ msgstr ""
msgid "Go to commits"
msgstr ""
msgid "Go to definition"
msgstr ""
msgid "Go to environments"
msgstr ""
......
......@@ -20,7 +20,7 @@ describe 'a maintainer edits files on a source-branch of an MR from a fork', :js
end
before do
stub_feature_flags(web_ide_default: false, single_mr_diff_view: false)
stub_feature_flags(web_ide_default: false, single_mr_diff_view: false, code_navigation: false)
target_project.add_maintainer(user)
sign_in(user)
......
......@@ -13,6 +13,10 @@ describe 'File blob', :js do
wait_for_requests
end
before do
stub_feature_flags(code_navigation: false)
end
context 'Ruby file' do
before do
visit_blob('files/ruby/popen.rb')
......
......@@ -69,6 +69,8 @@ describe 'Editing file blob', :js do
context 'from blob file path' do
before do
stub_feature_flags(code_navigation: false)
visit project_blob_path(project, tree_join(branch, file_path))
end
......
......@@ -8,6 +8,7 @@ describe 'User creates blob in new project', :js do
shared_examples 'creating a file' do
before do
stub_feature_flags(code_navigation: false)
sign_in(user)
visit project_path(project)
end
......
......@@ -14,7 +14,7 @@ describe 'Projects > Files > User creates files', :js do
let(:user) { create(:user) }
before do
stub_feature_flags(web_ide_default: false)
stub_feature_flags(web_ide_default: false, code_navigation: false)
project.add_maintainer(user)
sign_in(user)
......
......@@ -14,6 +14,8 @@ describe 'Projects > Files > User deletes files', :js do
let(:user) { create(:user) }
before do
stub_feature_flags(code_navigation: false)
sign_in(user)
end
......
......@@ -16,6 +16,8 @@ describe 'Projects > Files > User replaces files', :js do
let(:user) { create(:user) }
before do
stub_feature_flags(code_navigation: false)
sign_in(user)
end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Code navigation popover component renders popover 1`] = `
<div
class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show"
style="left: 0px; top: 0px;"
>
<div
class="arrow"
style="left: 0px;"
/>
<div
class="border-bottom"
>
<pre
class="border-0 bg-transparent m-0 code highlight"
>
console.log
</pre>
</div>
<div
class="popover-body"
>
<gl-button-stub
class="w-100"
href="http://test.com"
size="md"
target="_blank"
variant="default"
>
Go to definition
</gl-button-stub>
</div>
</div>
`;
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createState from '~/code_navigation/store/state';
import App from '~/code_navigation/components/app.vue';
import Popover from '~/code_navigation/components/popover.vue';
const localVue = createLocalVue();
const fetchData = jest.fn();
const showDefinition = jest.fn();
let wrapper;
localVue.use(Vuex);
function factory(initialState = {}) {
const store = new Vuex.Store({
state: {
...createState(),
...initialState,
},
actions: {
fetchData,
showDefinition,
},
});
wrapper = shallowMount(App, { store, localVue });
}
describe('Code navigation app component', () => {
afterEach(() => {
wrapper.destroy();
});
it('fetches data on mount', () => {
factory();
expect(fetchData).toHaveBeenCalled();
});
it('hides popover when no definition set', () => {
factory();
expect(wrapper.find(Popover).exists()).toBe(false);
});
it('renders popover when definition set', () => {
factory({
currentDefinition: { hover: 'console' },
currentDefinitionPosition: { x: 0 },
});
expect(wrapper.find(Popover).exists()).toBe(true);
});
it('calls showDefinition when clicking blob viewer', () => {
setFixtures('<div class="blob-viewer"></div>');
factory();
document.querySelector('.blob-viewer').click();
expect(showDefinition).toHaveBeenCalled();
});
});
import { shallowMount } from '@vue/test-utils';
import Popover from '~/code_navigation/components/popover.vue';
const MOCK_CODE_DATA = Object.freeze({
hover: [
{
language: 'javascript',
value: 'console.log',
},
],
definition_url: 'http://test.com',
});
const MOCK_DOCS_DATA = Object.freeze({
hover: [
{
language: null,
value: 'console.log',
},
],
definition_url: 'http://test.com',
});
let wrapper;
function factory(position, data) {
wrapper = shallowMount(Popover, { propsData: { position, data } });
}
describe('Code navigation popover component', () => {
afterEach(() => {
wrapper.destroy();
});
it('renders popover', () => {
factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA);
expect(wrapper.element).toMatchSnapshot();
});
describe('code output', () => {
it('renders code output', () => {
factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA);
expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(true);
expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(false);
});
});
describe('documentation output', () => {
it('renders code output', () => {
factory({ x: 0, y: 0, height: 0 }, MOCK_DOCS_DATA);
expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(false);
expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(true);
});
});
});
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import actions from '~/code_navigation/store/actions';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { setCurrentHoverElement, addInteractionClass } from '~/code_navigation/utils';
jest.mock('~/flash');
jest.mock('~/code_navigation/utils');
describe('Code navigation actions', () => {
describe('setInitialData', () => {
it('commits SET_INITIAL_DATA', done => {
testAction(
actions.setInitialData,
{ projectPath: 'test' },
{},
[{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }],
[],
done,
);
});
});
describe('requestDataError', () => {
it('commits REQUEST_DATA_ERROR', () =>
testAction(actions.requestDataError, null, {}, [{ type: 'REQUEST_DATA_ERROR' }], []));
it('creates a flash message', () =>
testAction(actions.requestDataError, null, {}, [{ type: 'REQUEST_DATA_ERROR' }], []).then(
() => {
expect(createFlash).toHaveBeenCalled();
},
));
});
describe('fetchData', () => {
let mock;
const state = {
projectPath: 'gitlab-org/gitlab',
commitId: '123',
blobPath: 'index',
};
const apiUrl = '/api/1/projects/gitlab-org%2Fgitlab/commits/123/lsif/info';
beforeEach(() => {
window.gon = { api_version: '1' };
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('success', () => {
beforeEach(() => {
mock.onGet(apiUrl).replyOnce(200, [
{
start_line: 0,
start_char: 0,
hover: { value: '123' },
},
{
start_line: 1,
start_char: 0,
hover: null,
},
]);
});
it('commits REQUEST_DATA_SUCCESS with normalized data', done => {
testAction(
actions.fetchData,
null,
state,
[
{ type: 'REQUEST_DATA' },
{
type: 'REQUEST_DATA_SUCCESS',
payload: { '0:0': { start_line: 0, start_char: 0, hover: { value: '123' } } },
},
],
[],
done,
);
});
it('calls addInteractionClass with data', done => {
testAction(
actions.fetchData,
null,
state,
[
{ type: 'REQUEST_DATA' },
{
type: 'REQUEST_DATA_SUCCESS',
payload: { '0:0': { start_line: 0, start_char: 0, hover: { value: '123' } } },
},
],
[],
)
.then(() => {
expect(addInteractionClass).toHaveBeenCalledWith({
start_line: 0,
start_char: 0,
hover: { value: '123' },
});
})
.then(done)
.catch(done.fail);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet(apiUrl).replyOnce(500);
});
it('dispatches requestDataError', done => {
testAction(
actions.fetchData,
null,
state,
[{ type: 'REQUEST_DATA' }],
[{ type: 'requestDataError' }],
done,
);
});
});
});
describe('showDefinition', () => {
let target;
beforeEach(() => {
target = document.createElement('div');
});
it('returns early when no data exists', done => {
testAction(actions.showDefinition, { target }, {}, [], [], done);
});
it('commits SET_CURRENT_DEFINITION when target is not code navitation element', done => {
testAction(
actions.showDefinition,
{ target },
{ data: {} },
[
{
type: 'SET_CURRENT_DEFINITION',
payload: { definition: undefined, position: undefined },
},
],
[],
done,
);
});
it('commits SET_CURRENT_DEFINITION with LSIF data', done => {
target.classList.add('js-code-navigation');
target.setAttribute('data-line-index', '0');
target.setAttribute('data-char-index', '0');
testAction(
actions.showDefinition,
{ target },
{ data: { '0:0': { hover: 'test' } } },
[
{
type: 'SET_CURRENT_DEFINITION',
payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
},
],
[],
done,
);
});
it('adds hll class to target element', () => {
target.classList.add('js-code-navigation');
target.setAttribute('data-line-index', '0');
target.setAttribute('data-char-index', '0');
return testAction(
actions.showDefinition,
{ target },
{ data: { '0:0': { hover: 'test' } } },
[
{
type: 'SET_CURRENT_DEFINITION',
payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
},
],
[],
).then(() => {
expect(target.classList).toContain('hll');
});
});
it('caches current target element', () => {
target.classList.add('js-code-navigation');
target.setAttribute('data-line-index', '0');
target.setAttribute('data-char-index', '0');
return testAction(
actions.showDefinition,
{ target },
{ data: { '0:0': { hover: 'test' } } },
[
{
type: 'SET_CURRENT_DEFINITION',
payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } },
},
],
[],
).then(() => {
expect(setCurrentHoverElement).toHaveBeenCalledWith(target);
});
});
});
});
import mutations from '~/code_navigation/store/mutations';
import createState from '~/code_navigation/store/state';
let state;
describe('Code navigation mutations', () => {
beforeEach(() => {
state = createState();
});
describe('SET_INITIAL_DATA', () => {
it('sets initial data', () => {
mutations.SET_INITIAL_DATA(state, {
projectPath: 'test',
commitId: '123',
blobPath: 'index.js',
});
expect(state.projectPath).toBe('test');
expect(state.commitId).toBe('123');
expect(state.blobPath).toBe('index.js');
});
});
describe('REQUEST_DATA', () => {
it('sets loading true', () => {
mutations.REQUEST_DATA(state);
expect(state.loading).toBe(true);
});
});
describe('REQUEST_DATA_SUCCESS', () => {
it('sets loading false', () => {
mutations.REQUEST_DATA_SUCCESS(state, ['test']);
expect(state.loading).toBe(false);
});
it('sets data', () => {
mutations.REQUEST_DATA_SUCCESS(state, ['test']);
expect(state.data).toEqual(['test']);
});
});
describe('REQUEST_DATA_ERROR', () => {
it('sets loading false', () => {
mutations.REQUEST_DATA_ERROR(state);
expect(state.loading).toBe(false);
});
});
describe('SET_CURRENT_DEFINITION', () => {
it('sets current definition and position', () => {
mutations.SET_CURRENT_DEFINITION(state, { definition: 'test', position: { x: 0 } });
expect(state.currentDefinition).toBe('test');
expect(state.currentDefinitionPosition).toEqual({ x: 0 });
});
});
});
import {
cachedData,
getCurrentHoverElement,
setCurrentHoverElement,
addInteractionClass,
} from '~/code_navigation/utils';
afterEach(() => {
if (cachedData.has('current')) {
cachedData.delete('current');
}
});
describe('getCurrentHoverElement', () => {
it.each`
value
${'test'}
${undefined}
`('it returns cached current key', ({ value }) => {
if (value) {
cachedData.set('current', value);
}
expect(getCurrentHoverElement()).toEqual(value);
});
});
describe('setCurrentHoverElement', () => {
it('sets cached current key', () => {
setCurrentHoverElement('test');
expect(getCurrentHoverElement()).toEqual('test');
});
});
describe('addInteractionClass', () => {
beforeEach(() => {
setFixtures(
'<div id="LC1"><span>console</span><span>.</span><span>log</span></div><div id="LC2"><span>function</span></div>',
);
});
it.each`
line | char | index
${0} | ${0} | ${0}
${0} | ${8} | ${2}
${1} | ${0} | ${0}
`(
'it sets code navigation attributes for line $line and character $char',
({ line, char, index }) => {
addInteractionClass({ start_line: line, start_char: char });
expect(document.querySelectorAll(`#LC${line + 1} span`)[index].classList).toContain(
'js-code-navigation',
);
},
);
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment