diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js
index a1f59aa1b549782253df53796425769d28590d3b..a8932f8c73b7539d8eb4af4211719db5f524ae14 100644
--- a/app/assets/javascripts/blob/line_highlighter.js
+++ b/app/assets/javascripts/blob/line_highlighter.js
@@ -37,6 +37,7 @@ const LineHighlighter = function (options = {}) {
options.fileHolderSelector = options.fileHolderSelector || '.file-holder';
options.scrollFileHolder = options.scrollFileHolder || false;
options.hash = options.hash || window.location.hash;
+ options.scrollBehavior = options.scrollBehavior || 'smooth';
this.options = options;
this._hash = options.hash;
@@ -74,6 +75,7 @@ LineHighlighter.prototype.highlightHash = function (newHash) {
// Scroll to the first highlighted line on initial load
// Add an offset of -100 for some context
offset: -100,
+ behavior: this.options.scrollBehavior,
});
}
}
diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue
deleted file mode 100644
index 11caf3be00ad3491941e0f21e1a4939b8643c850..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/vue_shared/components/line_numbers.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
- {{ line }}
-
-
-
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
new file mode 100644
index 0000000000000000000000000000000000000000..db0ee0cf00eba4d08e53953ede091b0cbd1216fa
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -0,0 +1,103 @@
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ab989ae6f9eb835aa536183d5cd2b3c6c0d57e44
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+ {{ number }}
+
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 9efe0147c37f5c4bfe9cfdece3c6963328c90615..b5ce214e5774b54a5800834f6415a42dba763a1f 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -109,3 +109,5 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
xquery: 'xquery',
yaml: 'yaml',
};
+
+export const LINES_PER_CHUNK = 70;
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 4a78cbacec0e67a4b7f7a8a870bbd4b5350f2aa3..15411e8d17e00250d3396657f0126bdad375db10 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -1,16 +1,21 @@
-
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
deleted file mode 100644
index d726a8a55ffdb637f95813daed97c9f7fc579ae5..0000000000000000000000000000000000000000
--- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export const wrapLines = (content, language) => {
- const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_).
-
- return (
- content &&
- content
- .split('\n')
- .map((line, i) => {
- let formattedLine;
- const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`;
-
- if (line.includes('```bash
- * example (after): ```bash
- */
- formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `);
- } else {
- formattedLine = `${line}`;
- }
-
- return formattedLine;
- })
- .join('\n')
- );
-};
diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
index 11e2d24c36acc629ff4c265e4263af03901c7b0b..9b0edcd09d24f7310f301d0000e0423aa07e1162 100644
--- a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
+++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
@@ -39,7 +39,7 @@ def visit_blob(fragment = nil)
find('#L3').click
find("#L5").click
- expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5")))
+ expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5")))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
@@ -50,7 +50,7 @@ def visit_blob(fragment = nil)
find('#L3').click
find("#L5").click
- expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5")))
+ expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5")))
end
end
@@ -75,7 +75,7 @@ def visit_blob(fragment = nil)
find('#L3').click
find("#L5").click
- expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5")))
+ expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5")))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
@@ -86,7 +86,7 @@ def visit_blob(fragment = nil)
find('#L3').click
find("#L5").click
- expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5")))
+ expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5")))
end
end
end
diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js
deleted file mode 100644
index 38c2622686356e0828e420211cb8b048a048b471..0000000000000000000000000000000000000000
--- a/spec/frontend/vue_shared/components/line_numbers_spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlIcon, GlLink } from '@gitlab/ui';
-import LineNumbers from '~/vue_shared/components/line_numbers.vue';
-
-describe('Line Numbers component', () => {
- let wrapper;
- const lines = 10;
-
- const createComponent = () => {
- wrapper = shallowMount(LineNumbers, { propsData: { lines } });
- };
-
- const findGlIcon = () => wrapper.findComponent(GlIcon);
- const findLineNumbers = () => wrapper.findAllComponents(GlLink);
- const findFirstLineNumber = () => findLineNumbers().at(0);
-
- beforeEach(() => createComponent());
-
- afterEach(() => wrapper.destroy());
-
- describe('rendering', () => {
- it('renders Line Numbers', () => {
- expect(findLineNumbers().length).toBe(lines);
- expect(findFirstLineNumber().attributes()).toMatchObject({
- id: 'L1',
- to: '#LC1',
- });
- });
-
- it('renders a link icon', () => {
- expect(findGlIcon().props()).toMatchObject({
- size: 12,
- name: 'link',
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4aa0aea9605aa41a738828f8f95d41f3b3464a2d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
@@ -0,0 +1,47 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue';
+
+const DEFAULT_PROPS = {
+ number: 2,
+ content: '// Line content',
+ language: 'javascript',
+};
+
+describe('Chunk Line component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(ChunkLine, { propsData: { ...DEFAULT_PROPS, ...props } });
+ };
+
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findContent = () => wrapper.findByTestId('content');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => wrapper.destroy());
+
+ describe('rendering', () => {
+ it('renders a line number', () => {
+ expect(findLink().attributes()).toMatchObject({
+ 'data-line-number': `${DEFAULT_PROPS.number}`,
+ to: `#L${DEFAULT_PROPS.number}`,
+ id: `L${DEFAULT_PROPS.number}`,
+ });
+
+ expect(findLink().text()).toBe(DEFAULT_PROPS.number.toString());
+ });
+
+ it('renders content', () => {
+ expect(findContent().attributes()).toMatchObject({
+ id: `LC${DEFAULT_PROPS.number}`,
+ lang: DEFAULT_PROPS.language,
+ });
+
+ expect(findContent().text()).toBe(DEFAULT_PROPS.content);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..42c4f2eacb838fa2d01d65349cd8d9042a6ebd35
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
@@ -0,0 +1,82 @@
+import { GlIntersectionObserver } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
+import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue';
+
+const DEFAULT_PROPS = {
+ chunkIndex: 2,
+ isHighlighted: false,
+ content: '// Line 1 content \n // Line 2 content',
+ startingFrom: 140,
+ totalLines: 50,
+ language: 'javascript',
+};
+
+describe('Chunk component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(Chunk, { propsData: { ...DEFAULT_PROPS, ...props } });
+ };
+
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
+ const findChunkLines = () => wrapper.findAllComponents(ChunkLine);
+ const findLineNumbers = () => wrapper.findAllByTestId('line-number');
+ const findContent = () => wrapper.findByTestId('content');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => wrapper.destroy());
+
+ describe('Intersection observer', () => {
+ it('renders an Intersection observer component', () => {
+ expect(findIntersectionObserver().exists()).toBe(true);
+ });
+
+ it('emits an appear event when intersection-observer appears', () => {
+ findIntersectionObserver().vm.$emit('appear');
+
+ expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]);
+ });
+
+ it('does not emit an appear event is isHighlighted is true', () => {
+ createComponent({ isHighlighted: true });
+ findIntersectionObserver().vm.$emit('appear');
+
+ expect(wrapper.emitted('appear')).toEqual(undefined);
+ });
+ });
+
+ describe('rendering', () => {
+ it('does not render a Chunk Line component if isHighlighted is false', () => {
+ expect(findChunkLines().length).toBe(0);
+ });
+
+ it('renders simplified line numbers and content if isHighlighted is false', () => {
+ expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines);
+
+ expect(findLineNumbers().at(0).attributes()).toMatchObject({
+ 'data-line-number': `${DEFAULT_PROPS.startingFrom + 1}`,
+ href: `#L${DEFAULT_PROPS.startingFrom + 1}`,
+ id: `L${DEFAULT_PROPS.startingFrom + 1}`,
+ });
+
+ expect(findContent().text()).toBe(DEFAULT_PROPS.content);
+ });
+
+ it('renders Chunk Line components if isHighlighted is true', () => {
+ const splitContent = DEFAULT_PROPS.content.split('\n');
+ createComponent({ isHighlighted: true });
+
+ expect(findChunkLines().length).toBe(splitContent.length);
+
+ expect(findChunkLines().at(0).props()).toMatchObject({
+ number: DEFAULT_PROPS.startingFrom + 1,
+ content: splitContent[0],
+ language: DEFAULT_PROPS.language,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
index ab579945e22649f23a96997595b6ebc855a93eb3..5b787993492a9d1f5ca78e2a2bddb61382cf580c 100644
--- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
@@ -1,23 +1,35 @@
import hljs from 'highlight.js/lib/core';
-import { GlLoadingIcon } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
+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 LineNumbers from '~/vue_shared/components/line_numbers.vue';
import waitForPromises from 'helpers/wait_for_promises';
-import * as sourceViewerUtils from '~/vue_shared/components/source_viewer/utils';
+import LineHighlighter from '~/blob/line_highlighter';
+jest.mock('~/blob/line_highlighter');
jest.mock('highlight.js/lib/core');
Vue.use(VueRouter);
const router = new VueRouter();
+const generateContent = (content, totalLines = 1) => {
+ let generatedContent = '';
+ for (let i = 0; i < totalLines; i += 1) {
+ generatedContent += `Line: ${i + 1} = ${content}\n`;
+ }
+ return generatedContent;
+};
+
+const execImmediately = (callback) => callback();
+
describe('Source Viewer component', () => {
let wrapper;
const language = 'docker';
const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language];
- const content = `// Some source code`;
+ 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 highlightedContent = `${content}`;
@@ -29,15 +41,12 @@ describe('Source Viewer component', () => {
await waitForPromises();
};
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findLineNumbers = () => wrapper.findComponent(LineNumbers);
- const findHighlightedContent = () => wrapper.findByTestId('test-highlighted');
- const findFirstLine = () => wrapper.find('#LC1');
+ const findChunks = () => wrapper.findAllComponents(Chunk);
beforeEach(() => {
hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
- jest.spyOn(sourceViewerUtils, 'wrapLines');
+ jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
return createComponent();
});
@@ -45,6 +54,8 @@ describe('Source Viewer component', () => {
afterEach(() => wrapper.destroy());
describe('highlight.js', () => {
+ beforeEach(() => createComponent({ language: mappedLanguage }));
+
it('registers the language definition', async () => {
const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`);
@@ -54,72 +65,46 @@ describe('Source Viewer component', () => {
);
});
- it('highlights the content', () => {
- expect(hljs.highlight).toHaveBeenCalledWith(content, { language: mappedLanguage });
+ it('highlights the first chunk', () => {
+ expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage });
});
describe('auto-detects if a language cannot be loaded', () => {
beforeEach(() => createComponent({ language: 'some_unknown_language' }));
it('highlights the content with auto-detection', () => {
- expect(hljs.highlightAuto).toHaveBeenCalledWith(content);
+ expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim());
});
});
});
describe('rendering', () => {
- it('renders a loading icon if no highlighted content is available yet', async () => {
- hljs.highlight.mockImplementation(() => ({ value: null }));
- await createComponent();
-
- expect(findLoadingIcon().exists()).toBe(true);
- });
+ it('renders the first chunk', async () => {
+ const firstChunk = findChunks().at(0);
- it('calls the wrapLines helper method with highlightedContent and mappedLanguage', () => {
- expect(sourceViewerUtils.wrapLines).toHaveBeenCalledWith(highlightedContent, mappedLanguage);
- });
-
- it('renders Line Numbers', () => {
- expect(findLineNumbers().props('lines')).toBe(1);
- });
+ expect(firstChunk.props('content')).toContain(chunk1);
- it('renders the highlighted content', () => {
- expect(findHighlightedContent().exists()).toBe(true);
- });
- });
-
- describe('selecting a line', () => {
- let firstLine;
- let firstLineElement;
-
- beforeEach(() => {
- firstLine = findFirstLine();
- firstLineElement = firstLine.element;
-
- jest.spyOn(firstLineElement, 'scrollIntoView');
- jest.spyOn(firstLineElement.classList, 'add');
- jest.spyOn(firstLineElement.classList, 'remove');
+ expect(firstChunk.props()).toMatchObject({
+ totalLines: 70,
+ startingFrom: 0,
+ });
});
- it('adds the highlight (hll) class', async () => {
- wrapper.vm.$router.push('#LC1');
- await nextTick();
+ it('renders the second chunk', async () => {
+ const secondChunk = findChunks().at(1);
- expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll');
- });
+ expect(secondChunk.props('content')).toContain(chunk2.trim());
- it('removes the highlight (hll) class from a previously highlighted line', async () => {
- wrapper.vm.$router.push('#LC2');
- await nextTick();
-
- expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll');
+ expect(secondChunk.props()).toMatchObject({
+ totalLines: 70,
+ startingFrom: 70,
+ });
});
+ });
- it('scrolls the line into view', () => {
- expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({
- behavior: 'smooth',
- block: 'center',
- });
+ describe('LineHighlighter', () => {
+ it('instantiates the lineHighlighter class', async () => {
+ expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
});
});
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js
deleted file mode 100644
index 0631e7efd5418cb4e34f4d92b3be5e1602e9ae1b..0000000000000000000000000000000000000000
--- a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { wrapLines } from '~/vue_shared/components/source_viewer/utils';
-
-describe('Wrap lines', () => {
- it.each`
- content | language | output
- ${'line 1'} | ${'javascript'} | ${'line 1'}
- ${'line 1\nline 2'} | ${'html'} | ${`line 1\nline 2`}
- ${'line 1\nline 2'} | ${'html'} | ${`line 1\nline 2`}
- ${'```bash'} | ${'bash'} | ${'```bash'}
- ${'```bash'} | ${'valid-language1'} | ${'```bash'}
- ${'```bash'} | ${'valid_language2'} | ${'```bash'}
- `('returns lines wrapped in spans containing line numbers', ({ content, language, output }) => {
- expect(wrapLines(content, language)).toBe(output);
- });
-
- it.each`
- language
- ${'invalidLanguage>'}
- ${'"invalidLanguage"'}
- ${' {
- expect(wrapLines('```bash', language)).toBe(
- '```bash',
- );
- });
-});