summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue')
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue169
1 files changed, 118 insertions, 51 deletions
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 4a78cbacec0..edf2229a9a1 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,22 @@
<script>
import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui';
-import LineNumbers from '~/vue_shared/components/line_numbers.vue';
-import { sanitize } from '~/lib/dompurify';
-import { ROUGE_TO_HLJS_LANGUAGE_MAP } from './constants';
-import { wrapLines } from './utils';
-
-const LINE_SELECT_CLASS_NAME = 'hll';
+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';
+/*
+ * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
+ * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
+ *
+ * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
+ * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
+ * it does not trigger a repaint on a parent element that wraps all 1000 lines.
+ */
export default {
components: {
- LineNumbers,
GlLoadingIcon,
+ Chunk,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -27,46 +33,94 @@ export default {
content: this.blob.rawTextBlob,
language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
hljs: null,
+ firstChunk: null,
+ chunks: {},
+ isLoading: true,
+ isLineSelected: false,
+ lineHighlighter: null,
};
},
computed: {
+ splitContent() {
+ return this.content.split('\n');
+ },
lineNumbers() {
- return this.content.split('\n').length;
+ return this.splitContent.length;
},
- highlightedContent() {
- let highlightedContent;
- let { language } = this;
+ },
+ async created() {
+ this.generateFirstChunk();
+ this.hljs = await this.loadHighlightJS();
- if (this.hljs) {
- if (!language) {
- const hljsHighlightAuto = this.hljs.highlightAuto(this.content);
+ if (this.language) {
+ this.languageDefinition = await this.loadLanguage();
+ }
- highlightedContent = hljsHighlightAuto.value;
- language = hljsHighlightAuto.language;
- } else if (this.languageDefinition) {
- highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
- }
+ // Highlight the first chunk as soon as highlight.js is available
+ this.highlightChunk(null, true);
+
+ window.requestIdleCallback(async () => {
+ // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
+ this.generateRemainingChunks();
+ this.isLoading = false;
+ await this.$nextTick();
+ this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
+ });
+ },
+ methods: {
+ generateFirstChunk() {
+ const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
+ this.firstChunk = this.createChunk(lines);
+ },
+ generateRemainingChunks() {
+ const result = {};
+ for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) {
+ const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
+ const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK);
+ result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK);
}
- return wrapLines(highlightedContent, language);
+ this.chunks = result;
},
- },
- watch: {
- highlightedContent() {
- this.$nextTick(() => this.selectLine());
+ createChunk(lines, startingFrom = 0) {
+ return {
+ content: lines.join('\n'),
+ startingFrom,
+ totalLines: lines.length,
+ language: this.language,
+ isHighlighted: false,
+ };
},
- $route() {
+ highlightChunk(index, isFirstChunk) {
+ const chunk = isFirstChunk ? this.firstChunk : this.chunks[index];
+
+ if (chunk.isHighlighted) {
+ return;
+ }
+
+ const { highlightedContent, language } = this.highlight(chunk.content, this.language);
+
+ Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
+
this.selectLine();
+
+ this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
},
- },
- async mounted() {
- this.hljs = await this.loadHighlightJS();
+ highlight(content, language) {
+ let detectedLanguage = language;
+ let highlightedContent;
+ if (this.hljs) {
+ if (!detectedLanguage) {
+ const hljsHighlightAuto = this.hljs.highlightAuto(content);
+ highlightedContent = hljsHighlightAuto.value;
+ detectedLanguage = hljsHighlightAuto.language;
+ } else if (this.languageDefinition) {
+ highlightedContent = this.hljs.highlight(content, { language: this.language }).value;
+ }
+ }
- if (this.language) {
- this.languageDefinition = await this.loadLanguage();
- }
- },
- methods: {
+ return { highlightedContent, language: detectedLanguage };
+ },
loadHighlightJS() {
// If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
@@ -83,21 +137,14 @@ export default {
return languageDefinition;
},
- selectLine() {
- const hash = sanitize(this.$route.hash);
- const lineToSelect = hash && this.$el.querySelector(hash);
-
- if (!lineToSelect) {
+ async selectLine() {
+ if (this.isLineSelected || !this.lineHighlighter) {
return;
}
- if (this.$options.currentlySelectedLine) {
- this.$options.currentlySelectedLine.classList.remove(LINE_SELECT_CLASS_NAME);
- }
-
- lineToSelect.classList.add(LINE_SELECT_CLASS_NAME);
- this.$options.currentlySelectedLine = lineToSelect;
- lineToSelect.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ this.isLineSelected = true;
+ await this.$nextTick();
+ this.lineHighlighter.highlightHash(this.$route.hash);
},
},
userColorScheme: window.gon.user_color_scheme,
@@ -105,16 +152,36 @@ export default {
};
</script>
<template>
- <gl-loading-icon v-if="!highlightedContent" size="sm" class="gl-my-5" />
<div
- v-else
- class="file-content code js-syntax-highlight blob-content gl-display-flex"
+ 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"
>
- <line-numbers :lines="lineNumbers" />
- <pre class="code highlight gl-pb-0!"><code v-safe-html="highlightedContent"></code>
- </pre>
+ <chunk
+ v-if="firstChunk"
+ :lines="firstChunk.lines"
+ :total-lines="firstChunk.totalLines"
+ :content="firstChunk.content"
+ :starting-from="firstChunk.startingFrom"
+ :is-highlighted="firstChunk.isHighlighted"
+ :language="firstChunk.language"
+ />
+
+ <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
+ <chunk
+ v-for="(chunk, key, index) in chunks"
+ v-else
+ :key="key"
+ :lines="chunk.lines"
+ :content="chunk.content"
+ :total-lines="chunk.totalLines"
+ :starting-from="chunk.startingFrom"
+ :is-highlighted="chunk.isHighlighted"
+ :chunk-index="index"
+ :language="chunk.language"
+ @appear="highlightChunk"
+ />
</div>
</template>