gitlab-ce/app/assets/javascripts/repository/components/blob_content_viewer.vue

422 lines
14 KiB
Vue

<script>
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { visitUrl, getLocationHash } from '~/lib/utils/url_utility';
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 highlightMixin from '~/repository/mixins/highlight_mixin';
import projectInfoQuery from '../queries/project_info.query.graphql';
import getRefMixin from '../mixins/get_ref';
import { getRefType } from '../utils/ref_type';
import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE, LEGACY_FILE_TYPES } from '../constants';
import BlobButtonGroup from './blob_button_group.vue';
import ForkSuggestion from './fork_suggestion.vue';
import { loadViewer } from './blob_viewers';
export default {
components: {
BlobHeader,
BlobButtonGroup,
BlobContent,
GlLoadingIcon,
GlButton,
ForkSuggestion,
CodeIntelligence,
AiGenie: () => import('ee_component/ai/components/ai_genie.vue'),
},
mixins: [getRefMixin, highlightMixin, glFeatureFlagMixin()],
inject: {
originalBranch: {
default: '',
},
explainCodeAvailable: { default: false },
},
apollo: {
// eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties
projectInfo: {
query: projectInfoQuery,
variables() {
return {
projectPath: this.projectPath,
};
},
error() {
this.displayError();
},
update({ project }) {
this.pathLocks = project.pathLocks || DEFAULT_BLOB_INFO.pathLocks;
this.userPermissions = project.userPermissions;
},
},
project: {
query: blobInfoQuery,
variables() {
const queryVariables = {
projectPath: this.projectPath,
filePath: [this.path],
ref: this.currentRef,
refType: getRefType(this.refType),
shouldFetchRawText: true,
};
return queryVariables;
},
result({ data }) {
const repository = data.project?.repository || {};
this.blobInfo = repository.blobs?.nodes[0] || {};
this.isEmptyRepository = repository.empty;
this.projectId = data.project?.id;
const usePlain = this.$route?.query?.plain === '1'; // When the 'plain' URL param is present, its value determines which viewer to render
const urlHash = getLocationHash(); // If there is a code line hash in the URL we render with the simple viewer
const useSimpleViewer = usePlain || urlHash?.startsWith('L') || !this.hasRichViewer;
if (this.isTooLarge) return;
this.initHighlightWorker(this.blobInfo, this.isUsingLfs);
this.switchViewer(useSimpleViewer ? SIMPLE_BLOB_VIEWER : RICH_BLOB_VIEWER); // By default, if present, use the rich viewer to render
},
error() {
this.displayError();
},
},
},
provide() {
return { blobHash: uniqueId() };
},
props: {
path: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
refType: {
type: String,
required: false,
default: null,
},
},
data() {
return {
forkTarget: null,
legacyRichViewer: null,
legacySimpleViewer: null,
isBinary: false,
isLoadingLegacyViewer: false,
isRenderingLegacyTextViewer: false,
activeViewerType: SIMPLE_BLOB_VIEWER,
project: DEFAULT_BLOB_INFO.project,
currentUser: DEFAULT_BLOB_INFO.currentUser,
useFallback: false,
pathLocks: DEFAULT_BLOB_INFO.pathLocks,
userPermissions: DEFAULT_BLOB_INFO.userPermissions,
blobInfo: {},
isEmptyRepository: false,
projectId: null,
showBlame: this.$route?.query?.blame === '1',
};
},
computed: {
isLoggedIn() {
return isLoggedIn();
},
isLoading() {
return this.$apollo.queries.project.loading;
},
isBinaryFileType() {
return this.isBinary || this.blobInfo.simpleViewer?.fileType !== TEXT_FILE_TYPE;
},
currentRef() {
return this.originalBranch || this.ref;
},
viewer() {
const { richViewer, simpleViewer } = this.blobInfo;
return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer;
},
hasRichViewer() {
return Boolean(this.blobInfo.richViewer);
},
hasRenderError() {
return Boolean(this.viewer.renderError);
},
isTooLarge() {
const { tooLarge, renderError } = this.viewer || {};
return tooLarge || renderError === 'collapsed';
},
blobViewer() {
const { fileType } = this.viewer;
const { isTooLarge } = this;
return this.shouldLoadLegacyViewer ? null : loadViewer(fileType, this.isUsingLfs, isTooLarge);
},
shouldLoadLegacyViewer() {
return LEGACY_FILE_TYPES.includes(this.blobInfo.fileType) || this.useFallback;
},
legacyViewerLoaded() {
return (
(this.activeViewerType === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) ||
(this.activeViewerType === RICH_BLOB_VIEWER && this.legacyRichViewer)
);
},
canLock() {
const { pushCode, downloadCode } = this.userPermissions;
const currentUsername = window.gon?.current_username;
if (this.pathLockedByUser && this.pathLockedByUser.username !== currentUsername) {
return false;
}
return pushCode && downloadCode;
},
pathLockedByUser() {
const pathLock = this.pathLocks?.nodes.find((node) => node.path === this.path);
return pathLock ? pathLock.user : null;
},
canFork() {
const { createMergeRequestIn, forkProject } = this.userPermissions;
return this.isLoggedIn && !this.isUsingLfs && createMergeRequestIn && forkProject;
},
showSingleFileEditorForkSuggestion() {
const { canModifyBlob } = this.blobInfo;
return this.canFork && !canModifyBlob;
},
showWebIdeForkSuggestion() {
const { canModifyBlobWithWebIde } = this.blobInfo;
return this.canFork && !canModifyBlobWithWebIde;
},
showForkSuggestion() {
return this.showSingleFileEditorForkSuggestion || this.showWebIdeForkSuggestion;
},
forkPath() {
const forkPaths = {
ide: this.blobInfo.ideForkAndEditPath,
simple: this.blobInfo.forkAndEditPath,
view: this.blobInfo.forkAndViewPath,
};
return forkPaths[this.forkTarget];
},
isUsingLfs() {
return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE;
},
shouldRenderAiGenie() {
return this.explainCodeAvailable && this.activeViewerType === 'simple' && !this.isTooLarge;
},
},
watch: {
// Watch the URL 'plain' query value to know if the viewer needs changing.
// This is the case when the user switches the viewer and then goes back through the history
'$route.query.plain': {
handler(plainValue) {
const useSimpleViewer = plainValue === '1' || !this.hasRichViewer;
this.switchViewer(useSimpleViewer ? SIMPLE_BLOB_VIEWER : RICH_BLOB_VIEWER);
},
},
},
methods: {
onError() {
this.useFallback = true;
this.loadLegacyViewer();
},
loadLegacyViewer() {
if (this.legacyViewerLoaded) {
return;
}
const type = this.activeViewerType;
this.isLoadingLegacyViewer = true;
const newUrl = new URL(this.blobInfo.webPath, window.location.origin);
newUrl.searchParams.set('format', 'json');
newUrl.searchParams.set('viewer', type);
axios
.get(newUrl.pathname + newUrl.search)
.then(async ({ data: { html, binary } }) => {
this.isRenderingLegacyTextViewer = true;
if (type === SIMPLE_BLOB_VIEWER) {
this.legacySimpleViewer = html;
} else {
this.legacyRichViewer = html;
}
this.isBinary = binary;
this.isLoadingLegacyViewer = false;
window.requestIdleCallback(() => {
this.isRenderingLegacyTextViewer = false;
if (type === SIMPLE_BLOB_VIEWER) {
new LineHighlighter(); // eslint-disable-line no-new
}
});
await this.$nextTick();
handleLocationHash(); // Ensures that we scroll to the hash when async content is loaded
})
.catch(() => this.displayError());
},
displayError() {
createAlert({ message: __('An error occurred while loading the file. Please try again.') });
},
switchViewer(newViewer) {
this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
if (!this.blobViewer) {
this.loadLegacyViewer();
}
},
handleViewerChanged(newViewer) {
this.switchViewer(newViewer);
const plain = newViewer === SIMPLE_BLOB_VIEWER ? '1' : '0';
if (this.$route?.query?.plain === plain) return;
this.$router.push({ path: this.$route.path, query: { ...this.$route.query, plain } });
},
isIdeTarget(target) {
return target === 'ide';
},
forkSuggestionForSelectedEditor(target) {
return this.isIdeTarget(target)
? this.showWebIdeForkSuggestion
: this.showSingleFileEditorForkSuggestion;
},
editBlob(target) {
const { ideEditPath, editBlobPath } = this.blobInfo;
const isIdeTarget = this.isIdeTarget(target);
const showForkSuggestionForSelectedEditor = this.forkSuggestionForSelectedEditor(target);
if (showForkSuggestionForSelectedEditor) {
this.setForkTarget(target);
} else {
visitUrl(isIdeTarget ? ideEditPath : editBlobPath);
}
},
setForkTarget(target) {
this.forkTarget = target;
},
onCopy() {
navigator.clipboard.writeText(this.blobInfo.rawTextBlob);
},
handleToggleBlame() {
this.switchViewer(SIMPLE_BLOB_VIEWER);
if (this.$route?.query?.plain === '0') {
// If the user is not viewing plain code and clicks the blame button, we always want to show blame info
// For instance, when viewing the rendered version of a Markdown file
this.showBlame = true;
} else {
this.showBlame = !this.showBlame;
}
const blame = this.showBlame === true ? '1' : '0';
if (this.$route?.query?.blame === blame) return;
this.$router.push({ path: this.$route.path, query: { ...this.$route.query, blame } });
},
},
};
</script>
<template>
<div class="gl-relative">
<gl-loading-icon v-if="isLoading" size="sm" />
<div v-if="blobInfo && !isLoading" id="fileHolder" class="file-holder">
<blob-header
is-blob-page
:blob="blobInfo"
:hide-viewer-switcher="isBinaryFileType || isUsingLfs"
:is-binary="isBinaryFileType"
:active-viewer-type="viewer.type"
:has-render-error="hasRenderError"
:show-path="false"
:override-copy="true"
:show-fork-suggestion="showSingleFileEditorForkSuggestion"
:show-web-ide-fork-suggestion="showWebIdeForkSuggestion"
:show-blame-toggle="glFeatures.inlineBlame"
:project-path="projectPath"
:project-id="projectId"
@viewer-changed="handleViewerChanged"
@copy="onCopy"
@edit="editBlob"
@error="displayError"
@blame="handleToggleBlame"
>
<template #actions>
<blob-button-group
v-if="isLoggedIn && !blobInfo.archived"
:path="path"
:name="blobInfo.name"
:replace-path="blobInfo.replacePath"
:delete-path="blobInfo.webPath"
:can-push-code="userPermissions.pushCode"
:can-push-to-branch="blobInfo.canCurrentUserPushToBranch"
:empty-repo="isEmptyRepository"
:project-path="projectPath"
:is-locked="Boolean(pathLockedByUser)"
:can-lock="canLock"
:show-fork-suggestion="showSingleFileEditorForkSuggestion"
:is-using-lfs="isUsingLfs"
@fork="setForkTarget('view')"
/>
</template>
</blob-header>
<fork-suggestion
v-if="forkTarget && showForkSuggestion"
:fork-path="forkPath"
@cancel="setForkTarget(null)"
/>
<blob-content
v-if="!blobViewer"
class="js-syntax-highlight"
:rich-viewer="legacyRichViewer"
:blob="blobInfo"
:content="legacySimpleViewer"
:is-raw-content="true"
:active-viewer="viewer"
:show-blame="showBlame && glFeatures.inlineBlame"
:current-ref="currentRef"
:loading="isLoadingLegacyViewer"
:project-path="projectPath"
:data-loading="isRenderingLegacyTextViewer"
/>
<component
:is="blobViewer"
v-else
:blob="blobInfo"
:chunks="chunks"
:show-blame="showBlame && glFeatures.inlineBlame"
:project-path="projectPath"
:current-ref="currentRef"
class="blob-viewer"
@error="onError"
/>
<code-intelligence
v-if="blobViewer || legacyViewerLoaded"
:code-navigation-path="blobInfo.codeNavigationPath"
:blob-path="blobInfo.path"
:path-prefix="blobInfo.projectBlobPathRoot"
:wrap-text-nodes="true"
/>
</div>
<ai-genie
v-if="shouldRenderAiGenie"
container-selector=".file-content"
:file-path="path"
class="gl-ml-7"
/>
</div>
</template>