diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index e45f9a10294c006b7db3b8a5ae33721e167d417f..269546fa9ebe77f27c61ca653849680f0c895c52 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -11,9 +11,13 @@ import initBlob from '~/pages/projects/init_blob'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; +import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker'; import '~/sourcegraph/load'; import createStore from '~/code_navigation/store'; +console.time('HighlightWorker lifecycle duration'); +const highlightWorker = new HighlightWorker(); + Vue.use(Vuex); Vue.use(VueApollo); Vue.use(VueRouter); @@ -38,6 +42,7 @@ if (viewBlobEl) { provide: { targetBranch, originalBranch, + highlightWorker, }, render(createElement) { return createElement(BlobContentViewer, { diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 101625a4b724dcdf48e126ea0149e76d48405199..3b49a1dc302054ee53b9b64d6674d4e240e85e21 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -15,6 +15,7 @@ import CodeIntelligence from '~/code_navigation/components/app.vue'; import LineHighlighter from '~/blob/line_highlighter'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import { addBlameLink } from '~/blob/blob_blame_link'; +import handleHighlightMixin from '~/repository/mixins/handle_highlight'; import projectInfoQuery from '../queries/project_info.query.graphql'; import getRefMixin from '../mixins/get_ref'; import userInfoQuery from '../queries/user_info.query.graphql'; @@ -35,7 +36,7 @@ export default { WebIdeLink, CodeIntelligence, }, - mixins: [getRefMixin, glFeatureFlagMixin()], + mixins: [getRefMixin, glFeatureFlagMixin(), handleHighlightMixin], inject: { originalBranch: { default: '', @@ -79,7 +80,9 @@ export default { shouldFetchRawText: Boolean(this.glFeatures.highlightJs), }; }, - result() { + result({ data }) { + this.initHighlightWorker(data.project?.repository?.blobs?.nodes[0] || {}); + const urlHash = getLocationHash(); const plain = this.$route?.query?.plain; @@ -384,7 +387,14 @@ export default { :loading="isLoadingLegacyViewer" :data-loading="isRenderingLegacyTextViewer" /> - + import('./image_viewer.vue'), video: () => import('./video_viewer.vue'), empty: () => import('./empty_viewer.vue'), - text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'), + text: () => import('~/vue_shared/components/source_viewer/source_viewer_new.vue'), pdf: () => import('./pdf_viewer.vue'), lfs: () => import('./lfs_viewer.vue'), audio: () => import('./audio_viewer.vue'), diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 494e270a66c91b5f05823f609a57d04e07dc68af..c5014f39029b3a494efd32fd0181791b3f8fb981 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -8,6 +8,7 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import createStore from '~/code_navigation/store'; import RefSelector from '~/ref/components/ref_selector.vue'; +import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; @@ -265,6 +266,9 @@ export default function setupVueRepositoryList() { store: createStore(), router, apolloProvider, + provide() { + return { highlightWorker: new HighlightWorker() }; + }, render(h) { return h(App); }, diff --git a/app/assets/javascripts/repository/mixins/handle_highlight.js b/app/assets/javascripts/repository/mixins/handle_highlight.js new file mode 100644 index 0000000000000000000000000000000000000000..dcf3f0235307a0fd2a5baf80258a03ad6e4142b8 --- /dev/null +++ b/app/assets/javascripts/repository/mixins/handle_highlight.js @@ -0,0 +1,81 @@ +import { + LEGACY_FALLBACKS, + EVENT_ACTION, + EVENT_LABEL_FALLBACK, + LINES_PER_CHUNK, +} from '~/vue_shared/components/source_viewer/constants'; +import { splitIntoChunks } from '~/vue_shared/components/source_viewer/workers/highlight_utils'; +import LineHighlighter from '~/blob/line_highlighter'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import Tracking from '~/tracking'; +import { TEXT_FILE_TYPE } from '../constants'; + +export default { + mixins: [Tracking.mixin()], + inject: { + highlightWorker: { default: null }, + }, + data() { + return { + chunks: [], + }; + }, + methods: { + trackEvent(label, language) { + this.track(EVENT_ACTION, { label, property: language }); + }, + unsupportedLanguage(language) { + const supportedLanguages = Object.keys(languageLoader); + const unsupportedLanguage = !supportedLanguages.includes(language); + + return LEGACY_FALLBACKS.includes(language) || unsupportedLanguage; + }, + handleUnsupportedLanguage(language) { + this.trackEvent(EVENT_LABEL_FALLBACK, language); + this.onError(); + }, + initHighlightWorker({ rawTextBlob, language, simpleViewer }) { + if (simpleViewer?.fileType !== TEXT_FILE_TYPE) return; + + if (this.unsupportedLanguage(language)) { + this.handleUnsupportedLanguage(language); + return; + } + + const firstSeventyLines = rawTextBlob.split(/\r?\n/).slice(0, LINES_PER_CHUNK).join('\n'); + + this.chunks = splitIntoChunks(language, firstSeventyLines); // Render the first 70 lines (raw text) ASAP, this improves perceived performance and LCP. + this.highlightWorker.onmessage = this.handleWorkerMessage; + + this.instructWorker(firstSeventyLines, language); // Instruct the worker to highlight the first 70 lines ASAP, this improves perceived performance. + this.instructWorker(rawTextBlob, language); // Then start highlighting all lines in the background. + }, + handleWorkerMessage({ data }) { + console.timeEnd('HighlightWorker lifecycle duration'); + this.chunks = data; + this.highlightHash(); + }, + instructWorker(content, language) { + this.highlightWorker.postMessage({ content, language }); + }, + async highlightHash() { + const { hash } = this.$route; + if (!hash) return; + + // Make the chunk containing the line number visible + const lineNumber = hash.substring(hash.indexOf('L') + 1).split('-')[0]; + const chunkToHighlight = this.chunks.find( + (chunk) => + chunk.startingFrom <= lineNumber && chunk.startingFrom + chunk.totalLines >= lineNumber, + ); + + if (chunkToHighlight) { + chunkToHighlight.isHighlighted = true; + } + + await this.$nextTick(); + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue new file mode 100644 index 0000000000000000000000000000000000000000..a86e1e69738e0c0e2ab01f642be967ee2d585fed --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue @@ -0,0 +1,153 @@ + + diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js index d694adf7147789e0f595b3894b24164c8e529feb..90f20a98446494403a0bb1fafc144a9ff36de451 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js @@ -1,6 +1,7 @@ import wrapChildNodes from './wrap_child_nodes'; import linkDependencies from './link_dependencies'; import wrapBidiChars from './wrap_bidi_chars'; +import wrapLineNumbers from './wrap_line_numbers'; export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; @@ -14,6 +15,7 @@ export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight'; export const registerPlugins = (hljs, fileType, rawContent) => { hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapChildNodes }); hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapBidiChars }); + hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapLineNumbers }); hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: (result) => linkDependencies(result, fileType, rawContent), }); diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_line_numbers.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_line_numbers.js new file mode 100644 index 0000000000000000000000000000000000000000..3bc0d6b0ebbdd9ef4a3b86f5a2b9fa41768aa62b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_line_numbers.js @@ -0,0 +1,19 @@ +/** + * Highlight.js plugin for wrapping line numbers for things like line highlighting to work. + * + * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst + * + * @param {Object} Result - an object that represents the highlighted result from Highlight.js + */ + +function wrapLineNumber(content, number) { + return `
${content}
`; +} + +export default (result) => { + // eslint-disable-next-line no-param-reassign + result.value = result.value + .split(/\r?\n/) + .map((content, index) => wrapLineNumber(content, index + 1)) + .join('\n'); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue new file mode 100644 index 0000000000000000000000000000000000000000..1f07a4dd4684f526c15394e41bba8ba2483a0934 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue @@ -0,0 +1,58 @@ + + + diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js index 0da57f9e6fa4175721a553574e367347345f9b1a..e36bcaf576020439e56760d080653247ab98b6e4 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js @@ -1,15 +1,47 @@ -import hljs from 'highlight.js/lib/core'; -import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import hljs from 'highlight.js'; import { registerPlugins } from '../plugins/index'; +import { LINES_PER_CHUNK, ROUGE_TO_HLJS_LANGUAGE_MAP } from '../constants'; -const initHighlightJs = async (fileType, content, language) => { - const languageDefinition = await languageLoader[language](); - +const initHighlightJs = (fileType, content) => { registerPlugins(hljs, fileType, content); - hljs.registerLanguage(language, languageDefinition.default); }; -export const highlight = (fileType, content, language) => { - initHighlightJs(fileType, content, language); - return hljs.highlight(content, { language }).value; +const splitByLineBreaks = (content = '') => content.split(/\r?\n/); + +const createChunk = (language, rawChunkLines, highlightedChunkLines = [], startingFrom = 0) => ({ + highlightedContent: highlightedChunkLines.join('\n'), + rawContent: rawChunkLines.join('\n'), + startingFrom, + totalLines: rawChunkLines.length, + language, +}); + +const splitIntoChunks = (language, rawContent, highlightedContent) => { + const result = []; + const splitRawContent = splitByLineBreaks(rawContent); + const splitHighlightedContent = splitByLineBreaks(highlightedContent); + + for (let i = 0; i < splitRawContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const highlightedChunk = splitHighlightedContent.slice(i, i + LINES_PER_CHUNK); + const rawChunk = splitRawContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = createChunk(language, rawChunk, highlightedChunk, i); + } + + return result; +}; + +const highlight = (fileType, rawContent, lang) => { + const language = ROUGE_TO_HLJS_LANGUAGE_MAP[lang.toLowerCase()]; + let result; + + if (language) { + initHighlightJs(fileType, rawContent, language); + const highlightedContent = hljs.highlight(rawContent, { language }).value; + result = splitIntoChunks(language, rawContent, highlightedContent); + } + + return result; }; + +export { highlight, splitIntoChunks }; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js similarity index 100% rename from app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight.js rename to app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_worker.js