Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-21 18:08:37 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-21 18:08:37 +0300
commit84b4743475246e91dc78c3f25f9b335c40be84cd (patch)
treebd65fa4db67a129f7e7f992a27a926a87970a0ed /app/assets/javascripts/vue_shared/components/source_viewer
parent8f2f35ad2e5027a0aedc56dd239ffe38fcc3f09f (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/source_viewer')
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue103
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue165
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js28
5 files changed, 263 insertions, 79 deletions
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 00000000000..db0ee0cf00e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui';
+import ChunkLine from './chunk_line.vue';
+
+/*
+ * We only highlight the chunk that is currently visible to the user.
+ * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
+ *
+ * Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
+ * so by making text transparent and rendering raw (non-highlighted) text,
+ * the browser spends less resources on painting content that is not immediately relevant.
+ *
+ * Why use transparent text as opposed to hiding content entirely?
+ * 1. If content is hidden entirely, native find text (⌘ + F) won't work.
+ * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
+ */
+export default {
+ components: {
+ ChunkLine,
+ GlIntersectionObserver,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ chunkIndex: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ isHighlighted: {
+ type: Boolean,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ startingFrom: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ totalLines: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ language: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ lines() {
+ return this.content.split('\n');
+ },
+ },
+ methods: {
+ handleChunkAppear() {
+ if (!this.isHighlighted) {
+ this.$emit('appear', this.chunkIndex);
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-intersection-observer @appear="handleChunkAppear">
+ <div v-if="isHighlighted">
+ <chunk-line
+ v-for="(line, index) in lines"
+ :key="index"
+ :number="startingFrom + index + 1"
+ :content="line"
+ :language="language"
+ />
+ </div>
+ <div v-else class="gl-display-flex">
+ <div class="gl-display-flex gl-flex-direction-column">
+ <a
+ v-for="(n, index) in totalLines"
+ :id="`L${startingFrom + index + 1}`"
+ :key="index"
+ class="gl-ml-5 gl-text-transparent"
+ :href="`#L${startingFrom + index + 1}`"
+ :data-line-number="startingFrom + index + 1"
+ data-testid="line-number"
+ >
+ {{ startingFrom + index + 1 }}
+ </a>
+ </div>
+ <div
+ v-safe-html="content"
+ class="gl-white-space-pre-wrap! gl-text-transparent"
+ data-testid="content"
+ ></div>
+ </div>
+ </gl-intersection-observer>
+ </div>
+</template>
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 00000000000..ab989ae6f9e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ number: {
+ type: Number,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ language: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex">
+ <div class="line-numbers gl-pt-0! gl-pb-0! gl-absolute gl-z-index-3">
+ <gl-link
+ :id="`L${number}`"
+ class="file-line-num diff-line-num gl-user-select-none"
+ :to="`#L${number}`"
+ :data-line-number="number"
+ >
+ {{ number }}
+ </gl-link>
+ </div>
+
+ <pre
+ class="code highlight gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11!"
+ ><code><span :id="`LC${number}`" v-safe-html="content" :lang="language" class="line" data-testid="content"></span></code></pre>
+ </div>
+</template>
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 9efe0147c37..b5ce214e577 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 4a78cbacec0..15411e8d17e 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 @@
<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 { 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 +32,92 @@ 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();
},
- },
- 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 +134,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 +149,35 @@ 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-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>
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 d726a8a55ff..00000000000
--- 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('<span class="hljs') && !line.includes('</span>')) {
- /**
- * In some cases highlight.js will wrap multiple lines in a span, in these cases we want to append the line number to the existing span
- *
- * example (before): <span class="hljs-code">```bash
- * example (after): <span id="LC67" class="hljs-code">```bash
- */
- formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `);
- } else {
- formattedLine = `<span ${attributes} class="line">${line}</span>`;
- }
-
- return formattedLine;
- })
- .join('\n')
- );
-};