Commit c191dfbb authored by Jacques's avatar Jacques

Ensure Code Intelligence works with Highlight.js

Ensures Code Intelligence works with Highlight.js
parent a1d2b629
...@@ -23,6 +23,11 @@ export default { ...@@ -23,6 +23,11 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
wrapTextNodes: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
...mapState([ ...mapState([
...@@ -37,6 +42,7 @@ export default { ...@@ -37,6 +42,7 @@ export default {
const initialData = { const initialData = {
blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }], blobs: [{ path: this.blobPath, codeNavigationPath: this.codeNavigationPath }],
definitionPathPrefix: this.pathPrefix, definitionPathPrefix: this.pathPrefix,
wrapTextNodes: this.wrapTextNodes,
}; };
this.setInitialData(initialData); this.setInitialData(initialData);
} }
......
...@@ -22,7 +22,7 @@ export default { ...@@ -22,7 +22,7 @@ export default {
...d, ...d,
definitionLineNumber: parseInt(d.definition_path?.split('#L').pop() || 0, 10), definitionLineNumber: parseInt(d.definition_path?.split('#L').pop() || 0, 10),
}; };
addInteractionClass(path, d); addInteractionClass(path, d, state.wrapTextNodes);
} }
return acc; return acc;
}, {}); }, {});
...@@ -34,7 +34,9 @@ export default { ...@@ -34,7 +34,9 @@ export default {
}, },
showBlobInteractionZones({ state }, path) { showBlobInteractionZones({ state }, path) {
if (state.data && state.data[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, state.wrapTextNodes),
);
} }
}, },
showDefinition({ commit, state }, { target: el }) { showDefinition({ commit, state }, { target: el }) {
......
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.SET_INITIAL_DATA](state, { blobs, definitionPathPrefix }) { [types.SET_INITIAL_DATA](state, { blobs, definitionPathPrefix, wrapTextNodes }) {
state.blobs = blobs; state.blobs = blobs;
state.definitionPathPrefix = definitionPathPrefix; state.definitionPathPrefix = definitionPathPrefix;
state.wrapTextNodes = wrapTextNodes;
}, },
[types.REQUEST_DATA](state) { [types.REQUEST_DATA](state) {
state.loading = true; state.loading = true;
......
...@@ -2,6 +2,7 @@ export default () => ({ ...@@ -2,6 +2,7 @@ export default () => ({
blobs: [], blobs: [],
loading: false, loading: false,
data: null, data: null,
wrapTextNodes: false,
currentDefinition: null, currentDefinition: null,
currentDefinitionPosition: null, currentDefinitionPosition: null,
currentBlobPath: 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 cachedData = new Map();
export const getCurrentHoverElement = () => cachedData.get('current'); export const getCurrentHoverElement = () => cachedData.get('current');
export const setCurrentHoverElement = (el) => cachedData.set('current', el); 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 lineNumber = d.start_line + 1;
const lines = document const lines = document
.querySelector(`[data-path="${path}"]`) .querySelector(`[data-path="${path}"]`)
...@@ -12,13 +14,24 @@ export const addInteractionClass = (path, d) => { ...@@ -12,13 +14,24 @@ export const addInteractionClass = (path, d) => {
lines.forEach((line) => { lines.forEach((line) => {
let charCount = 0; 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 }) => { const el = [...line.childNodes].find(({ textContent }) => {
if (charCount === d.start_char) return true; if (charCount === d.start_char) return true;
charCount += textContent.length; charCount += textContent.length;
return false; return false;
}); });
if (el) { if (el && !isTextNode(el)) {
el.setAttribute('data-char-index', d.start_char); el.setAttribute('data-char-index', d.start_char);
el.setAttribute('data-line-index', d.start_line); el.setAttribute('data-line-index', d.start_line);
el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation'); el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation');
......
...@@ -301,6 +301,7 @@ export default { ...@@ -301,6 +301,7 @@ export default {
:code-navigation-path="blobInfo.codeNavigationPath" :code-navigation-path="blobInfo.codeNavigationPath"
:blob-path="blobInfo.path" :blob-path="blobInfo.path"
:path-prefix="blobInfo.projectBlobPathRoot" :path-prefix="blobInfo.projectBlobPathRoot"
:wrap-text-nodes="glFeatures.highlightJs"
/> />
</div> </div>
</div> </div>
......
<script> <script>
import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui'; import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
import LineHighlighter from '~/blob/line_highlighter'; import LineHighlighter from '~/blob/line_highlighter';
import eventHub from '~/notes/event_hub';
import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants'; import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants';
import Chunk from './components/chunk.vue'; import Chunk from './components/chunk.vue';
...@@ -102,6 +103,8 @@ export default { ...@@ -102,6 +103,8 @@ export default {
Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
this.selectLine(); this.selectLine();
this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
}, },
highlight(content, language) { highlight(content, language) {
let detectedLanguage = language; let detectedLanguage = language;
...@@ -153,6 +156,7 @@ export default { ...@@ -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="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
:class="$options.userColorScheme" :class="$options.userColorScheme"
data-type="simple" data-type="simple"
:data-path="blob.path"
data-qa-selector="blob_viewer_file_content" data-qa-selector="blob_viewer_file_content"
> >
<chunk <chunk
......
...@@ -506,6 +506,10 @@ span.idiff { ...@@ -506,6 +506,10 @@ span.idiff {
text-indent: -$grid-size * 2; text-indent: -$grid-size * 2;
} }
.blob-viewer .code-navigation-popover .code {
text-indent: $grid-size * 2;
}
.tree-item-link { .tree-item-link {
&:not(.is-submodule) { &:not(.is-submodule) {
span { span {
......
...@@ -38,12 +38,17 @@ describe('Code navigation app component', () => { ...@@ -38,12 +38,17 @@ describe('Code navigation app component', () => {
const codeNavigationPath = 'code/nav/path.js'; const codeNavigationPath = 'code/nav/path.js';
const path = 'blob/path.js'; const path = 'blob/path.js';
const definitionPathPrefix = 'path/prefix'; 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(), { expect(setInitialData).toHaveBeenCalledWith(expect.anything(), {
blobs: [{ codeNavigationPath, path }], blobs: [{ codeNavigationPath, path }],
definitionPathPrefix, definitionPathPrefix,
wrapTextNodes,
}); });
}); });
......
...@@ -7,13 +7,15 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -7,13 +7,15 @@ import axios from '~/lib/utils/axios_utils';
jest.mock('~/code_navigation/utils'); jest.mock('~/code_navigation/utils');
describe('Code navigation actions', () => { describe('Code navigation actions', () => {
const wrapTextNodes = true;
describe('setInitialData', () => { describe('setInitialData', () => {
it('commits SET_INITIAL_DATA', (done) => { it('commits SET_INITIAL_DATA', (done) => {
testAction( testAction(
actions.setInitialData, actions.setInitialData,
{ projectPath: 'test' }, { projectPath: 'test', wrapTextNodes },
{}, {},
[{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }], [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test', wrapTextNodes } }],
[], [],
done, done,
); );
...@@ -30,7 +32,7 @@ describe('Code navigation actions', () => { ...@@ -30,7 +32,7 @@ describe('Code navigation actions', () => {
const codeNavigationPath = const codeNavigationPath =
'gitlab-org/gitlab-shell/-/jobs/1114/artifacts/raw/lsif/cmd/check/main.go.json'; '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(() => { beforeEach(() => {
window.gon = { api_version: '1' }; window.gon = { api_version: '1' };
...@@ -109,11 +111,15 @@ describe('Code navigation actions', () => { ...@@ -109,11 +111,15 @@ describe('Code navigation actions', () => {
[], [],
) )
.then(() => { .then(() => {
expect(addInteractionClass).toHaveBeenCalledWith('index.js', { expect(addInteractionClass).toHaveBeenCalledWith(
'index.js',
{
start_line: 0, start_line: 0,
start_char: 0, start_char: 0,
hover: { value: '123' }, hover: { value: '123' },
}); },
wrapTextNodes,
);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
...@@ -144,14 +150,15 @@ describe('Code navigation actions', () => { ...@@ -144,14 +150,15 @@ describe('Code navigation actions', () => {
data: { data: {
'index.js': { '0:0': 'test', '1:1': 'console.log' }, 'index.js': { '0:0': 'test', '1:1': 'console.log' },
}, },
wrapTextNodes,
}; };
actions.showBlobInteractionZones({ state }, 'index.js'); actions.showBlobInteractionZones({ state }, 'index.js');
expect(addInteractionClass).toHaveBeenCalled(); expect(addInteractionClass).toHaveBeenCalled();
expect(addInteractionClass.mock.calls.length).toBe(2); expect(addInteractionClass.mock.calls.length).toBe(2);
expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']); expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test', wrapTextNodes]);
expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']); expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log', wrapTextNodes]);
}); });
it('does not call addInteractionClass when no data exists', () => { it('does not call addInteractionClass when no data exists', () => {
......
...@@ -13,10 +13,12 @@ describe('Code navigation mutations', () => { ...@@ -13,10 +13,12 @@ describe('Code navigation mutations', () => {
mutations.SET_INITIAL_DATA(state, { mutations.SET_INITIAL_DATA(state, {
blobs: ['test'], blobs: ['test'],
definitionPathPrefix: 'https://test.com/blob/main', definitionPathPrefix: 'https://test.com/blob/main',
wrapTextNodes: true,
}); });
expect(state.blobs).toEqual(['test']); expect(state.blobs).toEqual(['test']);
expect(state.definitionPathPrefix).toBe('https://test.com/blob/main'); expect(state.definitionPathPrefix).toBe('https://test.com/blob/main');
expect(state.wrapTextNodes).toBe(true);
}); });
}); });
......
...@@ -45,6 +45,7 @@ describe('addInteractionClass', () => { ...@@ -45,6 +45,7 @@ describe('addInteractionClass', () => {
${0} | ${0} | ${0} ${0} | ${0} | ${0}
${0} | ${8} | ${2} ${0} | ${8} | ${2}
${1} | ${0} | ${0} ${1} | ${0} | ${0}
${1} | ${0} | ${0}
`( `(
'it sets code navigation attributes for line $line and character $char', 'it sets code navigation attributes for line $line and character $char',
({ line, char, index }) => { ({ line, char, index }) => {
...@@ -55,4 +56,19 @@ describe('addInteractionClass', () => { ...@@ -55,4 +56,19 @@ describe('addInteractionClass', () => {
); );
}, },
); );
it('wraps text nodes and spaces', () => {
setFixtures(
'<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"> Text </div></div></div>',
);
addInteractionClass('index.js', { start_line: 0, start_char: 0 }, true);
const spans = document.querySelectorAll(`#LC1 span`);
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', () => { ...@@ -258,6 +258,7 @@ describe('Blob content viewer component', () => {
codeNavigationPath: simpleViewerMock.codeNavigationPath, codeNavigationPath: simpleViewerMock.codeNavigationPath,
blobPath: simpleViewerMock.path, blobPath: simpleViewerMock.path,
pathPrefix: simpleViewerMock.projectBlobPathRoot, pathPrefix: simpleViewerMock.projectBlobPathRoot,
wrapTextNodes: true,
}); });
}); });
......
...@@ -7,6 +7,7 @@ import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; ...@@ -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 { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import LineHighlighter from '~/blob/line_highlighter'; import LineHighlighter from '~/blob/line_highlighter';
import eventHub from '~/notes/event_hub';
jest.mock('~/blob/line_highlighter'); jest.mock('~/blob/line_highlighter');
jest.mock('highlight.js/lib/core'); jest.mock('highlight.js/lib/core');
...@@ -30,7 +31,8 @@ describe('Source Viewer component', () => { ...@@ -30,7 +31,8 @@ describe('Source Viewer component', () => {
const chunk1 = generateContent('// Some source code 1', 70); const chunk1 = generateContent('// Some source code 1', 70);
const chunk2 = generateContent('// Some source code 2', 70); const chunk2 = generateContent('// Some source code 2', 70);
const content = chunk1 + chunk2; 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 highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
const createComponent = async (blob = {}) => { const createComponent = async (blob = {}) => {
...@@ -47,6 +49,7 @@ describe('Source Viewer component', () => { ...@@ -47,6 +49,7 @@ describe('Source Viewer component', () => {
hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
jest.spyOn(eventHub, '$emit');
return createComponent(); return createComponent();
}); });
...@@ -102,6 +105,11 @@ describe('Source Viewer component', () => { ...@@ -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', () => { describe('LineHighlighter', () => {
it('instantiates the lineHighlighter class', async () => { it('instantiates the lineHighlighter class', async () => {
expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); 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