Commit a41494f5 authored by Illya Klymov's avatar Illya Klymov

Merge branch '345814-fix-code-intelligence' into 'master'

Ensure Code Intelligence works with Highlight.js

See merge request gitlab-org/gitlab!84369
parents 69821f27 9c7902f6
......@@ -23,6 +23,11 @@ export default {
required: false,
default: null,
},
wrapTextNodes: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState([
......@@ -37,6 +42,7 @@ export default {
const initialData = {
blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }],
definitionPathPrefix: this.pathPrefix,
wrapTextNodes: this.wrapTextNodes,
};
this.setInitialData(initialData);
}
......
......@@ -22,7 +22,7 @@ export default {
...d,
definitionLineNumber: parseInt(d.definition_path?.split('#L').pop() || 0, 10),
};
addInteractionClass(path, d);
addInteractionClass({ path, d, wrapTextNodes: state.wrapTextNodes });
}
return acc;
}, {});
......@@ -34,7 +34,9 @@ export default {
},
showBlobInteractionZones({ state }, path) {
if (state.data && state.data[path]) {
Object.values(state.data[path]).forEach((d) => addInteractionClass(path, d));
Object.values(state.data[path]).forEach((d) =>
addInteractionClass({ path, d, wrapTextNodes: state.wrapTextNodes }),
);
}
},
showDefinition({ commit, state }, { target: el }) {
......
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_DATA](state, { blobs, definitionPathPrefix }) {
[types.SET_INITIAL_DATA](state, { blobs, definitionPathPrefix, wrapTextNodes }) {
state.blobs = blobs;
state.definitionPathPrefix = definitionPathPrefix;
state.wrapTextNodes = wrapTextNodes;
},
[types.REQUEST_DATA](state) {
state.loading = true;
......
......@@ -2,6 +2,7 @@ export default () => ({
blobs: [],
loading: false,
data: null,
wrapTextNodes: false,
currentDefinition: null,
currentDefinitionPosition: null,
currentBlobPath: null,
......
const TEXT_NODE = 3;
const isTextNode = ({ nodeType }) => nodeType === TEXT_NODE;
const isBlank = (str) => !str || /^\s*$/.test(str);
const isMatch = (s1, s2) => !isBlank(s1) && s1.trim() === s2.trim();
const createSpan = (content) => {
const span = document.createElement('span');
span.innerText = content;
return span;
};
const wrapSpacesWithSpans = (text) => text.replace(/ /g, createSpan(' ').outerHTML);
const wrapTextWithSpan = (el, text) => {
if (isTextNode(el) && isMatch(el.textContent, text)) {
const newEl = createSpan(text.trim());
el.replaceWith(newEl);
}
};
const wrapNodes = (text) => {
const wrapper = createSpan();
wrapper.innerHTML = wrapSpacesWithSpans(text);
wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text));
return wrapper.childNodes;
};
export { wrapNodes, isTextNode };
import { wrapNodes, isTextNode } from './dom_utils';
export const cachedData = new Map();
export const getCurrentHoverElement = () => cachedData.get('current');
export const setCurrentHoverElement = (el) => cachedData.set('current', el);
export const addInteractionClass = (path, d) => {
export const addInteractionClass = ({ path, d, wrapTextNodes }) => {
const lineNumber = d.start_line + 1;
const lines = document
.querySelector(`[data-path="${path}"]`)
......@@ -12,13 +14,24 @@ export const addInteractionClass = (path, d) => {
lines.forEach((line) => {
let charCount = 0;
if (wrapTextNodes) {
line.childNodes.forEach((elm) => {
if (isTextNode(elm)) {
// Highlight.js does not wrap all text nodes by default
// We need all text nodes to be wrapped in order to append code nav attributes
elm.replaceWith(...wrapNodes(elm.textContent));
}
});
}
const el = [...line.childNodes].find(({ textContent }) => {
if (charCount === d.start_char) return true;
charCount += textContent.length;
return false;
});
if (el) {
if (el && !isTextNode(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');
......
......@@ -301,6 +301,7 @@ export default {
:code-navigation-path="blobInfo.codeNavigationPath"
:blob-path="blobInfo.path"
:path-prefix="blobInfo.projectBlobPathRoot"
:wrap-text-nodes="glFeatures.highlightJs"
/>
</div>
</div>
......
<script>
import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
import LineHighlighter from '~/blob/line_highlighter';
import eventHub from '~/notes/event_hub';
import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants';
import Chunk from './components/chunk.vue';
......@@ -102,6 +103,8 @@ export default {
Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
this.selectLine();
this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
},
highlight(content, language) {
let detectedLanguage = language;
......@@ -153,6 +156,7 @@ export default {
class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
:class="$options.userColorScheme"
data-type="simple"
:data-path="blob.path"
data-qa-selector="blob_viewer_file_content"
>
<chunk
......
......@@ -38,12 +38,17 @@ describe('Code navigation app component', () => {
const codeNavigationPath = 'code/nav/path.js';
const path = 'blob/path.js';
const definitionPathPrefix = 'path/prefix';
const wrapTextNodes = true;
factory({}, { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix });
factory(
{},
{ codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix, wrapTextNodes },
);
expect(setInitialData).toHaveBeenCalledWith(expect.anything(), {
blobs: [{ codeNavigationPath, path }],
definitionPathPrefix,
wrapTextNodes,
});
});
......
......@@ -7,13 +7,15 @@ import axios from '~/lib/utils/axios_utils';
jest.mock('~/code_navigation/utils');
describe('Code navigation actions', () => {
const wrapTextNodes = true;
describe('setInitialData', () => {
it('commits SET_INITIAL_DATA', (done) => {
testAction(
actions.setInitialData,
{ projectPath: 'test' },
{ projectPath: 'test', wrapTextNodes },
{},
[{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }],
[{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test', wrapTextNodes } }],
[],
done,
);
......@@ -30,7 +32,7 @@ describe('Code navigation actions', () => {
const codeNavigationPath =
'gitlab-org/gitlab-shell/-/jobs/1114/artifacts/raw/lsif/cmd/check/main.go.json';
const state = { blobs: [{ path: 'index.js', codeNavigationPath }] };
const state = { blobs: [{ path: 'index.js', codeNavigationPath }], wrapTextNodes };
beforeEach(() => {
window.gon = { api_version: '1' };
......@@ -109,10 +111,14 @@ describe('Code navigation actions', () => {
[],
)
.then(() => {
expect(addInteractionClass).toHaveBeenCalledWith('index.js', {
start_line: 0,
start_char: 0,
hover: { value: '123' },
expect(addInteractionClass).toHaveBeenCalledWith({
path: 'index.js',
d: {
start_line: 0,
start_char: 0,
hover: { value: '123' },
},
wrapTextNodes,
});
})
.then(done)
......@@ -144,14 +150,19 @@ describe('Code navigation actions', () => {
data: {
'index.js': { '0:0': 'test', '1:1': 'console.log' },
},
wrapTextNodes,
};
actions.showBlobInteractionZones({ state }, 'index.js');
expect(addInteractionClass).toHaveBeenCalled();
expect(addInteractionClass.mock.calls.length).toBe(2);
expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']);
expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']);
expect(addInteractionClass.mock.calls[0]).toEqual([
{ path: 'index.js', d: 'test', wrapTextNodes },
]);
expect(addInteractionClass.mock.calls[1]).toEqual([
{ path: 'index.js', d: 'console.log', wrapTextNodes },
]);
});
it('does not call addInteractionClass when no data exists', () => {
......
......@@ -13,10 +13,12 @@ describe('Code navigation mutations', () => {
mutations.SET_INITIAL_DATA(state, {
blobs: ['test'],
definitionPathPrefix: 'https://test.com/blob/main',
wrapTextNodes: true,
});
expect(state.blobs).toEqual(['test']);
expect(state.definitionPathPrefix).toBe('https://test.com/blob/main');
expect(state.wrapTextNodes).toBe(true);
});
});
......
......@@ -45,14 +45,42 @@ describe('addInteractionClass', () => {
${0} | ${0} | ${0}
${0} | ${8} | ${2}
${1} | ${0} | ${0}
${1} | ${0} | ${0}
`(
'it sets code navigation attributes for line $line and character $char',
({ line, char, index }) => {
addInteractionClass('index.js', { start_line: line, start_char: char });
addInteractionClass({ path: 'index.js', d: { start_line: line, start_char: char } });
expect(document.querySelectorAll(`#LC${line + 1} span`)[index].classList).toContain(
'js-code-navigation',
);
},
);
describe('wrapTextNodes', () => {
beforeEach(() => {
setFixtures(
'<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"> Text </div></div></div>',
);
});
const params = { path: 'index.js', d: { start_line: 0, start_char: 0 } };
const findAllSpans = () => document.querySelectorAll('#LC1 span');
it('does not wrap text nodes by default', () => {
addInteractionClass(params);
const spans = findAllSpans();
expect(spans.length).toBe(0);
});
it('wraps text nodes if wrapTextNodes is true', () => {
addInteractionClass({ ...params, wrapTextNodes: true });
const spans = findAllSpans();
expect(spans.length).toBe(3);
expect(spans[0].textContent).toBe(' ');
expect(spans[1].textContent).toBe('Text');
expect(spans[2].textContent).toBe(' ');
});
});
});
......@@ -258,6 +258,7 @@ describe('Blob content viewer component', () => {
codeNavigationPath: simpleViewerMock.codeNavigationPath,
blobPath: simpleViewerMock.path,
pathPrefix: simpleViewerMock.projectBlobPathRoot,
wrapTextNodes: true,
});
});
......
......@@ -7,6 +7,7 @@ import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants';
import waitForPromises from 'helpers/wait_for_promises';
import LineHighlighter from '~/blob/line_highlighter';
import eventHub from '~/notes/event_hub';
jest.mock('~/blob/line_highlighter');
jest.mock('highlight.js/lib/core');
......@@ -30,7 +31,8 @@ describe('Source Viewer component', () => {
const chunk1 = generateContent('// Some source code 1', 70);
const chunk2 = generateContent('// Some source code 2', 70);
const content = chunk1 + chunk2;
const DEFAULT_BLOB_DATA = { language, rawTextBlob: content };
const path = 'some/path.js';
const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path };
const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
const createComponent = async (blob = {}) => {
......@@ -47,6 +49,7 @@ describe('Source Viewer component', () => {
hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
jest.spyOn(eventHub, '$emit');
return createComponent();
});
......@@ -102,6 +105,11 @@ describe('Source Viewer component', () => {
});
});
it('emits showBlobInteractionZones on the eventHub when chunk appears', () => {
findChunks().at(0).vm.$emit('appear');
expect(eventHub.$emit).toBeCalledWith('showBlobInteractionZones', path);
});
describe('LineHighlighter', () => {
it('instantiates the lineHighlighter class', async () => {
expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
......
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