Commit 80e658e7 authored by Jacques Erasmus's avatar Jacques Erasmus Committed by Kushal Pandya

Add source viewer component

parent 9d65b3fa
......@@ -4,7 +4,10 @@ export const loadViewer = (type) => {
return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue');
case 'text':
return gon.features.highlightJs
? () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue')
? () =>
import(
/* webpackChunkName: 'blob_text_viewer' */ '~/vue_shared/components/source_viewer.vue'
)
: null;
case 'download':
return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
......@@ -23,8 +26,7 @@ export const viewerProps = (type, blob) => {
return {
text: {
content: blob.rawTextBlob,
fileName: blob.name,
readOnly: true,
autoDetect: true, // We'll eventually disable autoDetect and pass the language explicitly to reduce the footprint (https://gitlab.com/gitlab-org/gitlab/-/issues/348145)
},
download: {
fileName: blob.name,
......
<script>
export default {
components: {
SourceEditor: () =>
import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'),
},
props: {
content: {
type: String,
required: true,
},
fileName: {
type: String,
required: true,
},
readOnly: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
<source-editor :value="content" :file-name="fileName" :editor-options="{ readOnly }" />
</template>
<script>
import { GlIcon, GlLink } from '@gitlab/ui';
export default {
components: {
GlIcon,
GlLink,
},
props: {
lines: {
type: Number,
required: true,
},
},
data() {
return {
currentlyHighlightedLine: null,
};
},
mounted() {
this.scrollToLine();
},
methods: {
scrollToLine(hash = window.location.hash) {
const lineToHighlight = hash && this.$el.querySelector(hash);
if (!lineToHighlight) {
return;
}
if (this.currentlyHighlightedLine) {
this.currentlyHighlightedLine.classList.remove('hll');
}
lineToHighlight.classList.add('hll');
this.currentlyHighlightedLine = lineToHighlight;
lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
},
},
};
</script>
<template>
<div class="line-numbers">
<gl-link
v-for="line in lines"
:id="`L${line}`"
:key="line"
class="diff-line-num"
:href="`#L${line}`"
:data-line-number="line"
@click="scrollToLine(`#L${line}`)"
>
<gl-icon :size="12" name="link" />
{{ line }}
</gl-link>
</div>
</template>
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
import LineNumbers from '~/vue_shared/components/line_numbers.vue';
export default {
components: {
LineNumbers,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
},
props: {
content: {
type: String,
required: true,
},
language: {
type: String,
required: false,
default: 'plaintext',
},
autoDetect: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
languageDefinition: null,
hljs: null,
};
},
computed: {
lineNumbers() {
return this.content.split('\n').length;
},
highlightedContent() {
let highlightedContent;
if (this.hljs) {
if (this.autoDetect) {
highlightedContent = this.hljs.highlightAuto(this.content).value;
} else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
}
}
return highlightedContent;
},
},
async mounted() {
this.hljs = await this.loadHighlightJS();
if (!this.autoDetect) {
this.languageDefinition = await this.loadLanguage();
}
},
methods: {
loadHighlightJS() {
// With auto-detect enabled we load all common languages else we load only the core (smallest footprint)
return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
},
async loadLanguage() {
let languageDefinition;
try {
languageDefinition = await import(`highlight.js/lib/languages/${this.language}`);
this.hljs.registerLanguage(this.language, languageDefinition.default);
} catch (message) {
this.$emit('error', message);
}
return languageDefinition;
},
},
userColorScheme: window.gon.user_color_scheme,
};
</script>
<template>
<div class="file-content code" :class="$options.userColorScheme">
<line-numbers :lines="lineNumbers" />
<pre
class="code gl-pl-3!"
><code v-safe-html="highlightedContent" class="gl-white-space-pre-wrap!"></code>
</pre>
</div>
</template>
......@@ -15,7 +15,7 @@ import ForkSuggestion from '~/repository/components/fork_suggestion.vue';
import { loadViewer, viewerProps } from '~/repository/components/blob_viewers';
import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue';
import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue';
import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue';
import SourceViewer from '~/vue_shared/components/source_viewer.vue';
import blobInfoQuery from '~/repository/queries/blob_info.query.graphql';
import { redirectTo } from '~/lib/utils/url_utility';
import { isLoggedIn } from '~/lib/utils/common_utils';
......@@ -215,7 +215,7 @@ describe('Blob content viewer component', () => {
viewer | loadViewerReturnValue | viewerPropsReturnValue
${'empty'} | ${EmptyViewer} | ${{}}
${'download'} | ${DownloadViewer} | ${{ filePath: '/some/file/path', fileName: 'test.js', fileSize: 100 }}
${'text'} | ${TextViewer} | ${{ content: 'test', fileName: 'test.js', readOnly: true }}
${'text'} | ${SourceViewer} | ${{ content: 'test', autoDetect: true }}
`(
'renders viewer component for $viewer files',
async ({ viewer, loadViewerReturnValue, viewerPropsReturnValue }) => {
......
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
describe('Text Viewer', () => {
let wrapper;
const propsData = {
content: 'Some content',
fileName: 'file_name.js',
readOnly: true,
};
const createComponent = () => {
wrapper = shallowMount(TextViewer, { propsData });
};
const findEditor = () => wrapper.findComponent(SourceEditor);
it('renders a Source Editor component', async () => {
createComponent();
await waitForPromises();
expect(findEditor().exists()).toBe(true);
expect(findEditor().props('value')).toBe(propsData.content);
expect(findEditor().props('fileName')).toBe(propsData.fileName);
expect(findEditor().props('editorOptions')).toEqual({ readOnly: propsData.readOnly });
});
});
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);
const findSecondLineNumber = () => findLineNumbers().at(1);
beforeEach(() => createComponent());
afterEach(() => wrapper.destroy());
describe('rendering', () => {
it('renders Line Numbers', () => {
expect(findLineNumbers().length).toBe(lines);
expect(findFirstLineNumber().attributes()).toMatchObject({
id: 'L1',
href: '#L1',
});
});
it('renders a link icon', () => {
expect(findGlIcon().props()).toMatchObject({
size: 12,
name: 'link',
});
});
});
describe('clicking a line number', () => {
let firstLineNumber;
let firstLineNumberElement;
beforeEach(() => {
firstLineNumber = findFirstLineNumber();
firstLineNumberElement = firstLineNumber.element;
jest.spyOn(firstLineNumberElement, 'scrollIntoView');
jest.spyOn(firstLineNumberElement.classList, 'add');
jest.spyOn(firstLineNumberElement.classList, 'remove');
firstLineNumber.vm.$emit('click');
});
it('adds the highlight (hll) class', () => {
expect(firstLineNumberElement.classList.add).toHaveBeenCalledWith('hll');
});
it('removes the highlight (hll) class from a previously highlighted line', () => {
findSecondLineNumber().vm.$emit('click');
expect(firstLineNumberElement.classList.remove).toHaveBeenCalledWith('hll');
});
it('scrolls the line into view', () => {
expect(firstLineNumberElement.scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
});
});
});
import hljs from 'highlight.js/lib/core';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SourceViewer from '~/vue_shared/components/source_viewer.vue';
import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('highlight.js/lib/core');
describe('Source Viewer component', () => {
let wrapper;
const content = `// Some source code`;
const highlightedContent = `<span data-testid='test-highlighted'>${content}</span>`;
const language = 'javascript';
hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
const createComponent = async (props = {}) => {
wrapper = shallowMountExtended(SourceViewer, { propsData: { content, language, ...props } });
await waitForPromises();
};
const findLineNumbers = () => wrapper.findComponent(LineNumbers);
const findHighlightedContent = () => wrapper.findByTestId('test-highlighted');
beforeEach(() => createComponent());
afterEach(() => wrapper.destroy());
describe('highlight.js', () => {
it('registers the language definition', async () => {
const languageDefinition = await import(`highlight.js/lib/languages/${language}`);
expect(hljs.registerLanguage).toHaveBeenCalledWith(language, languageDefinition.default);
});
it('highlights the content', () => {
expect(hljs.highlight).toHaveBeenCalledWith(content, { language });
});
describe('auto-detect enabled', () => {
beforeEach(() => createComponent({ autoDetect: true }));
it('highlights the content with auto-detection', () => {
expect(hljs.highlightAuto).toHaveBeenCalledWith(content);
});
});
});
describe('rendering', () => {
it('renders Line Numbers', () => {
expect(findLineNumbers().props('lines')).toBe(1);
});
it('renders the highlighted content', () => {
expect(findHighlightedContent().exists()).toBe(true);
});
});
});
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