| /** | |
| * @license | |
| * Copyright 2015 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator'; | |
| import '../../plugins/gr-endpoint-param/gr-endpoint-param'; | |
| import '../../../styles/gr-a11y-styles'; | |
| import '../../../styles/shared-styles'; | |
| import '../../shared/gr-button/gr-button'; | |
| import '../../shared/gr-dropdown/gr-dropdown'; | |
| import '../../shared/gr-dropdown-list/gr-dropdown-list'; | |
| import '../../shared/gr-icon/gr-icon'; | |
| import '../../shared/gr-weblink/gr-weblink'; | |
| import '../../shared/revision-info/revision-info'; | |
| import '../gr-apply-fix-dialog/gr-apply-fix-dialog'; | |
| import '../gr-diff-host/gr-diff-host'; | |
| import '../gr-diff-mode-selector/gr-diff-mode-selector'; | |
| import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog'; | |
| import '../gr-patch-range-select/gr-patch-range-select'; | |
| import '../../change/gr-download-dialog/gr-download-dialog'; | |
| import '../../shared/gr-content-with-sidebar/gr-content-with-sidebar'; | |
| import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader'; | |
| import {getAppContext} from '../../../services/app-context'; | |
| import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util'; | |
| import { | |
| computeDisplayPath, | |
| computeTruncatedPath, | |
| isMagicPath, | |
| } from '../../../utils/path-list-util'; | |
| import {changeBaseURL, changeIsOpen} from '../../../utils/change-util'; | |
| import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host'; | |
| import { | |
| DropdownItem, | |
| GrDropdownList, | |
| } from '../../shared/gr-dropdown-list/gr-dropdown-list'; | |
| import {CommentAnchorTapEventDetail} from '../../shared/gr-comment/gr-comment'; | |
| import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api'; | |
| import { | |
| BasePatchSetNum, | |
| Comment, | |
| CommentMap, | |
| CommentThread, | |
| DropdownLink, | |
| EDIT, | |
| NumericChangeId, | |
| PARENT, | |
| PatchRange, | |
| PatchSetNumber, | |
| PreferencesInfo, | |
| RepoName, | |
| RevisionPatchSetNum, | |
| } from '../../../types/common'; | |
| import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff'; | |
| import {ParsedChangeInfo} from '../../../types/types'; | |
| import { | |
| FilesWebLinks, | |
| PatchRangeChangeEvent, | |
| } from '../gr-patch-range-select/gr-patch-range-select'; | |
| import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor'; | |
| import {CommentSide, DiffViewMode, Side} from '../../../constants/constants'; | |
| import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog'; | |
| import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events'; | |
| import {fire, fireAlert} from '../../../utils/event-util'; | |
| import {assertIsDefined} from '../../../utils/common-util'; | |
| import {whenVisible} from '../../../utils/dom-util'; | |
| import {CursorMoveResult} from '../../../api/core'; | |
| import {throttleWrap} from '../../../utils/async-util'; | |
| import {filter, map, switchMap, take} from 'rxjs/operators'; | |
| import {combineLatest} from 'rxjs'; | |
| import { | |
| Shortcut, | |
| ShortcutSection, | |
| shortcutsServiceToken, | |
| } from '../../../services/shortcuts/shortcuts-service'; | |
| import { | |
| DisplayLine, | |
| FileRange, | |
| LineSelectedEventDetail, | |
| } from '../../../api/diff'; | |
| import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog'; | |
| import {commentsModelToken} from '../../../models/comments/comments-model'; | |
| import {changeModelToken} from '../../../models/change/change-model'; | |
| import {resolve} from '../../../models/dependency'; | |
| import {css, html, LitElement, nothing, PropertyValues} from 'lit'; | |
| import {ShortcutController} from '../../lit/shortcut-controller'; | |
| import {subscribe} from '../../lit/subscription-controller'; | |
| import {customElement, property, query, state} from 'lit/decorators.js'; | |
| import {a11yStyles} from '../../../styles/gr-a11y-styles'; | |
| import {sharedStyles} from '../../../styles/shared-styles'; | |
| import {ifDefined} from 'lit/directives/if-defined.js'; | |
| import {ref} from 'lit/directives/ref.js'; | |
| import {classMap} from 'lit/directives/class-map.js'; | |
| import {when} from 'lit/directives/when.js'; | |
| import {keyed} from 'lit/directives/keyed.js'; | |
| import { | |
| ChangeChildView, | |
| changeViewModelToken, | |
| } from '../../../models/views/change'; | |
| import {userModelToken} from '../../../models/user/user-model'; | |
| import {modalStyles} from '../../../styles/gr-modal-styles'; | |
| import {GrDiffPreferencesDialog} from '../gr-diff-preferences-dialog/gr-diff-preferences-dialog'; | |
| import { | |
| FileNameToNormalizedFileInfoMap, | |
| filesModelToken, | |
| } from '../../../models/change/files-model'; | |
| import {isImageDiff, isMarkdownDiff} from '../../../utils/diff-util'; | |
| import '../gr-diff-markdown-viewer/gr-diff-markdown-viewer'; | |
| import type {GrDiffMarkdownViewer} from '../gr-diff-markdown-viewer/gr-diff-markdown-viewer'; | |
| import {formStyles} from '../../../styles/form-styles'; | |
| import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list'; | |
| import {configModelToken} from '../../../models/config/config-model'; | |
| import {trimWithEllipsis} from '../../../utils/string-util'; | |
| import '@material/web/checkbox/checkbox'; | |
| import {MdCheckbox} from '@material/web/checkbox/checkbox'; | |
| import {materialStyles} from '../../../styles/gr-material-styles'; | |
| const LOADING_BLAME = 'Loading blame information. This may take a while ...'; | |
| const LOADED_BLAME = 'Blame loaded'; | |
| // Time in which pressing n key again after the toast navigates to next file | |
| const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000; | |
| // Files larger than this cannot be downloaded. | |
| const FILE_DOWNLOAD_LIMIT_BYTES = 50 * 1000 * 1000; | |
| // visible for testing | |
| export interface Files { | |
| /** All file paths sorted by `specialFilePathCompare`. */ | |
| sortedPaths: string[]; | |
| changeFilesByPath: FileNameToNormalizedFileInfoMap; | |
| } | |
| @customElement('gr-diff-view') | |
| export class GrDiffView extends LitElement { | |
| private readonly getPluginLoader = resolve(this, pluginLoaderToken); | |
| /** | |
| * Fired when user tries to navigate away while comments are pending save. | |
| * | |
| * @event show-alert | |
| */ | |
| @query('#diffHost') | |
| diffHost?: GrDiffHost; | |
| @query('#markdownViewer') | |
| markdownViewer?: GrDiffMarkdownViewer; | |
| @state() | |
| reviewed = false; | |
| @state() | |
| showRichMarkdown = false; | |
| @query('#downloadModal') | |
| downloadModal?: HTMLDialogElement; | |
| @query('#downloadDialog') | |
| downloadDialog?: GrDownloadDialog; | |
| @query('#dropdown') | |
| dropdown?: GrDropdownList; | |
| @query('#applyFixDialog') | |
| applyFixDialog?: GrApplyFixDialog; | |
| @query('#diffPreferencesDialog') | |
| diffPreferencesDialog?: GrDiffPreferencesDialog; | |
| // Private but used in tests. | |
| @state() | |
| get patchRange(): PatchRange | undefined { | |
| if (!this.patchNum) return undefined; | |
| return { | |
| patchNum: this.patchNum, | |
| basePatchNum: this.basePatchNum, | |
| }; | |
| } | |
| // Private but used in tests. | |
| get threadsForFile(): CommentThread[] { | |
| if (!this.changeComments || !this.path || !this.patchRange) return []; | |
| const file = this.files?.changeFilesByPath?.[this.path]; | |
| return this.changeComments.getThreadsBySideForFile( | |
| {path: this.path, basePath: file?.old_path}, | |
| this.patchRange | |
| ); | |
| } | |
| // Private but used in tests. | |
| @state() | |
| patchNum?: RevisionPatchSetNum; | |
| // Private but used in tests. | |
| @state() | |
| basePatchNum: BasePatchSetNum = PARENT; | |
| // Private but used in tests. | |
| @state() | |
| change?: ParsedChangeInfo; | |
| @state() | |
| latestPatchNum?: PatchSetNumber; | |
| // Private but used in tests. | |
| @state() | |
| changeComments?: ChangeComments; | |
| // Private but used in tests. | |
| @state() | |
| changeNum?: NumericChangeId; | |
| // Private but used in tests. | |
| @state() | |
| diff?: DiffInfo; | |
| // Private but used in tests. | |
| @state() | |
| files: Files = {sortedPaths: [], changeFilesByPath: {}}; | |
| @state() path?: string; | |
| @state() file?: NormalizedFileInfo; | |
| // Private but used in tests. | |
| @state() shownSidebar?: string; | |
| /** Allows us to react when the user switches to the DIFF view. */ | |
| // Private but used in tests. | |
| @state() isActiveChildView = false; | |
| // Whether to allow the "Show Blame button" | |
| @state() | |
| allowBlame = false; | |
| // Private but used in tests. | |
| @state() | |
| loggedIn = false; | |
| @property({type: Object}) | |
| prefs?: DiffPreferencesInfo; | |
| // Private but used in tests. | |
| @state() | |
| userPrefs?: PreferencesInfo; | |
| @state() | |
| private editWeblinks?: WebLinkInfo[]; | |
| @state() | |
| private filesWeblinks?: FilesWebLinks; | |
| // Private but used in tests. | |
| @state() | |
| isBlameLoaded?: boolean; | |
| @state() | |
| private isBlameLoading = false; | |
| /** Directly reflects the view model property `diffView.lineNum`. */ | |
| // Private but used in tests. | |
| @state() | |
| focusLineNum?: number; | |
| /** Directly reflects the view model property `diffView.endLineNum`. */ | |
| // Private but used in tests. | |
| @state() | |
| focusEndLineNum?: number; | |
| /** Directly reflects the view model property `diffView.leftSide`. */ | |
| @state() | |
| leftSide = false; | |
| @state() | |
| commentsForPath: Comment[] = []; | |
| @state() | |
| isShowingEntireFile = false; | |
| // visible for testing | |
| reviewedFiles = new Set<string>(); | |
| private readonly reporting = getAppContext().reportingService; | |
| private readonly getUserModel = resolve(this, userModelToken); | |
| private readonly getChangeModel = resolve(this, changeModelToken); | |
| private readonly getCommentsModel = resolve(this, commentsModelToken); | |
| private readonly getFilesModel = resolve(this, filesModelToken); | |
| private readonly getShortcutsService = resolve(this, shortcutsServiceToken); | |
| private readonly getViewModel = resolve(this, changeViewModelToken); | |
| private readonly getConfigModel = resolve(this, configModelToken); | |
| private throttledToggleFileReviewed?: (e: KeyboardEvent) => void; | |
| // Show the popup for navigating to next file keyboard shortcut only once | |
| private hasShownNavigateToFileToast = new Map<string, boolean>(); | |
| @state() | |
| cursor?: GrDiffCursor; | |
| private readonly shortcutsController = new ShortcutController(this); | |
| private stickyHeaderEl?: HTMLElement; | |
| private readonly headerResizeObserver = new ResizeObserver(entries => { | |
| for (const entry of entries) { | |
| const height = entry.borderBoxSize[0].blockSize; | |
| document.documentElement.style.setProperty( | |
| '--diff-header-height', | |
| `${height}px` | |
| ); | |
| } | |
| }); | |
| constructor() { | |
| super(); | |
| this.setupKeyboardShortcuts(); | |
| this.setupSubscriptions(); | |
| subscribe( | |
| this, | |
| () => this.getFilesModel().filesIncludingUnmodified$, | |
| files => { | |
| const filesByPath: FileNameToNormalizedFileInfoMap = {}; | |
| for (const f of files) filesByPath[f.__path] = f; | |
| this.files = { | |
| sortedPaths: files.map(f => f.__path), | |
| changeFilesByPath: filesByPath, | |
| }; | |
| } | |
| ); | |
| } | |
| private setupKeyboardShortcuts() { | |
| const listen = (shortcut: Shortcut, fn: (e: KeyboardEvent) => void) => { | |
| this.shortcutsController.addAbstract(shortcut, fn); | |
| }; | |
| listen(Shortcut.LEFT_PANE, _ => this.cursor?.moveLeft()); | |
| listen(Shortcut.RIGHT_PANE, _ => this.cursor?.moveRight()); | |
| listen(Shortcut.NEXT_LINE, _ => this.handleNextLine()); | |
| listen(Shortcut.PREV_LINE, _ => this.handlePrevLine()); | |
| listen(Shortcut.VISIBLE_LINE, _ => this.cursor?.moveToVisibleArea()); | |
| listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ => | |
| this.moveToFileWithComment(1) | |
| ); | |
| listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ => | |
| this.moveToFileWithComment(-1) | |
| ); | |
| listen(Shortcut.NEW_COMMENT, _ => this.handleNewComment()); | |
| listen(Shortcut.SAVE_COMMENT, _ => {}); | |
| listen(Shortcut.NEXT_FILE, _ => this.handleNextFile()); | |
| listen(Shortcut.PREV_FILE, _ => this.handlePrevFile()); | |
| listen(Shortcut.NEXT_CHUNK, _ => this.handleNextChunk()); | |
| listen(Shortcut.PREV_CHUNK, _ => this.handlePrevChunk()); | |
| listen(Shortcut.NEXT_COMMENT_THREAD, _ => this.handleNextCommentThread()); | |
| listen(Shortcut.PREV_COMMENT_THREAD, _ => this.handlePrevCommentThread()); | |
| listen(Shortcut.OPEN_REPLY_DIALOG, _ => this.handleOpenReplyDialog()); | |
| listen(Shortcut.TOGGLE_LEFT_PANE, _ => this.handleToggleLeftPane()); | |
| listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ => this.handleOpenDownloadDialog()); | |
| listen(Shortcut.UP_TO_CHANGE, _ => | |
| this.getChangeModel().navigateToChange() | |
| ); | |
| listen(Shortcut.OPEN_DIFF_PREFS, _ => this.handleCommaKey()); | |
| listen(Shortcut.TOGGLE_DIFF_MODE, _ => this.handleToggleDiffMode()); | |
| listen(Shortcut.TOGGLE_FILE_REVIEWED, e => { | |
| if (this.throttledToggleFileReviewed) { | |
| this.throttledToggleFileReviewed(e); | |
| } | |
| }); | |
| listen(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, _ => | |
| this.handleToggleAllDiffContext() | |
| ); | |
| listen(Shortcut.TOGGLE_HIDE_CHECK_CODE_POINTERS, _ => | |
| this.handleToggleHideCheckCodePointers() | |
| ); | |
| listen(Shortcut.NEXT_UNREVIEWED_FILE, _ => this.handleNextUnreviewedFile()); | |
| listen(Shortcut.TOGGLE_BLAME, _ => this.toggleBlame()); | |
| listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS_AND_CODE_POINTERS, _ => | |
| this.handleToggleHideAllCommentsAndCodePointers() | |
| ); | |
| listen(Shortcut.OPEN_FILE_LIST, _ => this.handleOpenFileList()); | |
| listen(Shortcut.DIFF_AGAINST_BASE, _ => this.handleDiffAgainstBase()); | |
| listen(Shortcut.DIFF_AGAINST_LATEST, _ => this.handleDiffAgainstLatest()); | |
| listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ => | |
| this.handleDiffBaseAgainstLeft() | |
| ); | |
| listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ => | |
| this.handleDiffRightAgainstLatest() | |
| ); | |
| listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ => | |
| this.handleDiffBaseAgainstLatest() | |
| ); | |
| listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}); // docOnly | |
| listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}); // docOnly | |
| } | |
| private setupSubscriptions() { | |
| subscribe( | |
| this, | |
| () => this.getUserModel().loggedIn$, | |
| loggedIn => { | |
| this.loggedIn = loggedIn; | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getCommentsModel().changeComments$, | |
| changeComments => { | |
| this.changeComments = changeComments; | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getUserModel().preferences$, | |
| preferences => { | |
| this.userPrefs = preferences; | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getUserModel().diffPreferences$, | |
| diffPreferences => { | |
| this.prefs = diffPreferences; | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getChangeModel().change$, | |
| change => { | |
| // The diff view is tied to a specific change number, so don't update | |
| // change to undefined. | |
| if (change) this.change = change; | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getChangeModel().latestPatchNum$, | |
| latestPatchNum => (this.latestPatchNum = latestPatchNum) | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getChangeModel().reviewedFiles$, | |
| reviewedFiles => { | |
| this.reviewedFiles = new Set(reviewedFiles) ?? new Set(); | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getViewModel().changeNum$, | |
| changeNum => { | |
| if (!changeNum || this.changeNum === changeNum) return; | |
| // We are only setting the changeNum of the diff view once. | |
| // Everything in the diff view is tied to the change. It seems better to | |
| // force the re-creation of the diff view when the change number changes. | |
| // The parent element will make sure that a new change view is created | |
| // when the change number changes (using the `keyed` directive). | |
| if (!this.changeNum) this.changeNum = changeNum; | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getViewModel().childView$, | |
| childView => (this.isActiveChildView = childView === ChangeChildView.DIFF) | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getViewModel().diffPath$, | |
| path => (this.path = path) | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getFilesModel().file$(this.getViewModel().diffPath$), | |
| file => (this.file = file) | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getViewModel().diffLine$, | |
| line => (this.focusLineNum = line) | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getViewModel().diffEndLine$, | |
| endLine => { | |
| this.focusEndLineNum = endLine; | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getViewModel().diffLeftSide$, | |
| leftSide => (this.leftSide = leftSide) | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getViewModel().patchNum$, | |
| patchNum => (this.patchNum = patchNum) | |
| ); | |
| // Note that this is the change model, not the view model, so that the | |
| // `default_base_for_merges` preference is applied to diff URLs that do not | |
| // specify a base, just like it is applied in the change view. An explicitly | |
| // chosen auto-merge base is encoded as `0` in the URL, so it survives this. | |
| subscribe( | |
| this, | |
| () => this.getChangeModel().basePatchNum$, | |
| basePatchNum => (this.basePatchNum = basePatchNum ?? PARENT) | |
| ); | |
| subscribe( | |
| this, | |
| () => | |
| combineLatest([ | |
| this.getViewModel().diffPath$, | |
| this.getChangeModel().reviewedFiles$, | |
| ]), | |
| ([path, files]) => { | |
| this.reviewed = !!path && !!files && files.includes(path); | |
| } | |
| ); | |
| subscribe( | |
| this, | |
| () => this.getConfigModel().serverConfig$, | |
| serverConfig => | |
| (this.allowBlame = serverConfig?.change.allow_blame ?? false) | |
| ); | |
| // When user initially loads the diff view, we want to automatically mark | |
| // the file as reviewed if they have it enabled. We can't observe these | |
| // properties since the method will be called anytime a property updates | |
| // but we only want to call this on the initial load. | |
| subscribe( | |
| this, | |
| () => | |
| this.getViewModel().diffPath$.pipe( | |
| filter(diffPath => !!diffPath), | |
| switchMap(() => | |
| combineLatest([ | |
| this.getChangeModel().patchNum$, | |
| this.getViewModel().childView$, | |
| this.getUserModel().diffPreferences$, | |
| this.getChangeModel().reviewedFiles$, | |
| ]).pipe( | |
| filter( | |
| ([patchNum, childView, diffPrefs, reviewedFiles]) => | |
| !!patchNum && | |
| childView === ChangeChildView.DIFF && | |
| !!diffPrefs && | |
| !!reviewedFiles | |
| ), | |
| take(1) | |
| ) | |
| ) | |
| ), | |
| ([patchNum, _routerView, diffPrefs]) => { | |
| // `patchNum` must be defined, because of the `!!patchNum` filter above. | |
| assertIsDefined(patchNum, 'patchNum'); | |
| this.setReviewedStatus(patchNum, diffPrefs); | |
| } | |
| ); | |
| } | |
| static override get styles() { | |
| return [ | |
| formStyles, | |
| a11yStyles, | |
| sharedStyles, | |
| modalStyles, | |
| materialStyles, | |
| css` | |
| :host { | |
| display: block; | |
| background-color: var(--view-background-color); | |
| --sidebar-top: calc( | |
| var(--main-header-height) + var(--diff-header-height, 80px) | |
| ); | |
| } | |
| .hidden { | |
| display: none; | |
| } | |
| .headerLeft { | |
| display: flex; | |
| align-items: center; | |
| min-width: 0; | |
| } | |
| gr-patch-range-select { | |
| display: block; | |
| } | |
| gr-diff { | |
| border: none; | |
| } | |
| .stickyHeader { | |
| background-color: var(--view-background-color); | |
| position: sticky; | |
| top: var(--main-header-height); | |
| /* sidebar should outrank <footer> in GrAppElement */ | |
| z-index: 110; | |
| box-shadow: var(--elevation-level-1); | |
| /* This is just for giving the box-shadow some space. */ | |
| margin-bottom: 2px; | |
| } | |
| header, | |
| .subHeader { | |
| align-items: center; | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| header { | |
| padding: var(--spacing-s) var(--spacing-xl); | |
| border-bottom: 1px solid var(--border-color); | |
| } | |
| .changeNumberColon { | |
| color: transparent; | |
| } | |
| .headerSubject { | |
| margin-left: var(--spacing-s); | |
| margin-right: var(--spacing-m); | |
| font-weight: var(--font-weight-medium); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| flex-shrink: 100; | |
| } | |
| .patchRangeLeft { | |
| align-items: center; | |
| display: flex; | |
| } | |
| .navLink:not([href]) { | |
| color: var(--deemphasized-text-color); | |
| } | |
| .navLinks { | |
| align-items: center; | |
| display: flex; | |
| white-space: nowrap; | |
| } | |
| .navLink { | |
| padding: 0 var(--spacing-xs); | |
| } | |
| .reviewed { | |
| margin: 0 var(--spacing-xs); | |
| flex-shrink: 0; | |
| } | |
| .jumpToFileContainer { | |
| display: inline-block; | |
| word-break: break-all; | |
| min-width: 0; | |
| overflow: hidden; | |
| } | |
| .mobile { | |
| display: none; | |
| } | |
| gr-button { | |
| padding: var(--spacing-s) 0; | |
| text-decoration: none; | |
| } | |
| .loading { | |
| color: var(--deemphasized-text-color); | |
| font-family: var(--header-font-family); | |
| font-size: var(--font-size-h1); | |
| font-weight: var(--font-weight-h1); | |
| line-height: var(--line-height-h1); | |
| height: 100%; | |
| padding: var(--spacing-l); | |
| text-align: center; | |
| } | |
| .subHeader { | |
| background-color: var(--background-color-secondary); | |
| flex-wrap: wrap; | |
| padding: 0 var(--spacing-l); | |
| } | |
| .prefsButton { | |
| text-align: right; | |
| } | |
| .editMode .hideOnEdit { | |
| display: none; | |
| } | |
| .fileNum { | |
| display: none; | |
| } | |
| .blameLoader, | |
| .fileNum.show, | |
| .download, | |
| .preferences, | |
| .rightControls { | |
| align-items: center; | |
| display: flex; | |
| } | |
| .diffModeSelector, | |
| .editButton { | |
| align-items: center; | |
| display: flex; | |
| } | |
| .diffModeSelector span, | |
| .editButton span { | |
| margin-right: var(--spacing-xs); | |
| } | |
| .diffModeSelector.hide, | |
| .separator.hide { | |
| display: none; | |
| } | |
| .editButtona a { | |
| text-decoration: none; | |
| } | |
| .checkboxDiv { | |
| display: flex; | |
| } | |
| gr-dropdown-list { | |
| --gr-dropdown-copy-clipboard-margin-left: var(--spacing-s); | |
| } | |
| @media screen and (max-width: 50em) { | |
| header { | |
| padding: var(--spacing-s) var(--spacing-l); | |
| } | |
| .dash { | |
| display: none; | |
| } | |
| .desktop { | |
| display: none; | |
| } | |
| .fileNav { | |
| align-items: flex-start; | |
| display: flex; | |
| margin: 0 var(--spacing-xs); | |
| } | |
| .fullFileName { | |
| display: block; | |
| font-style: italic; | |
| min-width: 50%; | |
| padding: 0 var(--spacing-xxs); | |
| text-align: center; | |
| width: 100%; | |
| word-wrap: break-word; | |
| } | |
| .mobileNavLink { | |
| color: var(--primary-text-color); | |
| font-family: var(--header-font-family); | |
| font-size: var(--font-size-h2); | |
| font-weight: var(--font-weight-h2); | |
| line-height: var(--line-height-h2); | |
| text-decoration: none; | |
| } | |
| .mobileNavLink:not([href]) { | |
| color: var(--deemphasized-text-color); | |
| } | |
| /* prettier formatter removes semi-colons after css mixins. */ | |
| /* prettier-ignore */ | |
| gr-dropdown-list { | |
| width: 100%; | |
| } | |
| } | |
| :host(.hideComments) { | |
| --gr-comment-thread-display: none; | |
| } | |
| :host(.hideCheckCodePointers) { | |
| --gr-check-code-pointers-display: none; | |
| } | |
| .sidebarTriggerContainer { | |
| display: inline-block; | |
| margin-right: var(--spacing-m); | |
| } | |
| .sidebarContents { | |
| background: var(--background-color-secondary); | |
| box-sizing: border-box; | |
| height: 100%; | |
| } | |
| md-checkbox { | |
| --md-checkbox-container-size: 15px; | |
| --md-checkbox-icon-size: 15px; | |
| } | |
| .expensiveDiff { | |
| color: var(--warning-foreground); | |
| font-weight: var(--font-weight-bold); | |
| padding: var(--spacing-m) var(--spacing-xl); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: var(--spacing-s); | |
| } | |
| gr-endpoint-decorator:has([replace-content]) gr-diff-host { | |
| display: none; | |
| } | |
| `, | |
| ]; | |
| } | |
| override connectedCallback() { | |
| super.connectedCallback(); | |
| if (this.stickyHeaderEl) { | |
| this.headerResizeObserver.observe(this.stickyHeaderEl); | |
| } | |
| this.throttledToggleFileReviewed = throttleWrap(_ => | |
| this.handleToggleFileReviewed() | |
| ); | |
| this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e)); | |
| this.cursor = new GrDiffCursor(); | |
| if (this.diffHost) this.reInitCursor(); | |
| this.getUserModel() | |
| .preferences$.pipe( | |
| map(p => p.diff_page_sidebar), | |
| take(1) | |
| ) | |
| .toPromise() | |
| .then(initialSidebar => { | |
| if (initialSidebar === 'NONE' || initialSidebar === undefined) { | |
| this.shownSidebar = undefined; | |
| } else { | |
| this.shownSidebar = initialSidebar.substring('plugin-'.length); | |
| } | |
| }); | |
| } | |
| override disconnectedCallback() { | |
| this.cursor?.dispose(); | |
| this.headerResizeObserver.disconnect(); | |
| document.documentElement.style.setProperty('--diff-header-height', '0px'); | |
| super.disconnectedCallback(); | |
| } | |
| private reInitCursor() { | |
| if (!this.diffHost) return; | |
| this.cursor?.replaceDiffs([this.diffHost]); | |
| this.cursor?.reInitCursor(); | |
| } | |
| protected override updated(changedProperties: PropertyValues): void { | |
| super.updated(changedProperties); | |
| if ( | |
| changedProperties.has('change') || | |
| changedProperties.has('path') || | |
| changedProperties.has('patchNum') || | |
| changedProperties.has('basePatchNum') | |
| ) { | |
| this.reloadDiff(); | |
| } else if ( | |
| changedProperties.has('isActiveChildView') && | |
| this.isActiveChildView | |
| ) { | |
| this.initializePositions(); | |
| } | |
| if ( | |
| changedProperties.has('focusLineNum') || | |
| changedProperties.has('focusEndLineNum') || | |
| changedProperties.has('leftSide') | |
| ) { | |
| this.initCursor(); | |
| } | |
| if (changedProperties.has('showRichMarkdown') && !this.showRichMarkdown) { | |
| this.reInitCursor(); | |
| } | |
| if ( | |
| changedProperties.has('change') || | |
| changedProperties.has('changeComments') || | |
| changedProperties.has('path') || | |
| changedProperties.has('patchNum') || | |
| changedProperties.has('basePatchNum') || | |
| changedProperties.has('files') || | |
| changedProperties.has('showRichMarkdown') | |
| ) { | |
| if (this.change && this.changeComments && this.path && this.patchRange) { | |
| assertIsDefined(this.diffHost, 'diffHost'); | |
| const file = this.files?.changeFilesByPath?.[this.path]; | |
| if (!this.isShowingRichMarkdown()) { | |
| this.diffHost.disabledThreads = false; | |
| this.diffHost.updateComplete.then(() => { | |
| assertIsDefined(this.path); | |
| assertIsDefined(this.patchRange); | |
| assertIsDefined(this.diffHost); | |
| assertIsDefined(this.changeComments); | |
| this.diffHost.threads = this.changeComments.getThreadsBySideForFile( | |
| {path: this.path, basePath: file?.old_path}, | |
| this.patchRange | |
| ); | |
| }); | |
| } else { | |
| this.diffHost.disabledThreads = true; | |
| this.diffHost.threads = []; | |
| } | |
| } | |
| } | |
| if ( | |
| (changedProperties.has('change') || | |
| changedProperties.has('changeComments') || | |
| changedProperties.has('path') || | |
| changedProperties.has('patchRange')) && | |
| this.changeComments !== undefined && | |
| this.path !== undefined && | |
| this.patchRange !== undefined | |
| ) { | |
| this.commentsForPath = this.changeComments.getCommentsForPath( | |
| this.path, | |
| this.patchRange | |
| ); | |
| } | |
| } | |
| override render() { | |
| if (!this.isActiveChildView) return nothing; | |
| if (!this.patchNum || !this.changeNum || !this.change || !this.path) { | |
| return html`<div class="loading">Loading...</div>`; | |
| } | |
| const file = this.getFileRange(); | |
| return html` | |
| ${this.renderStickyHeader()} | |
| <h2 class="assistive-tech-only">Diff view</h2> | |
| ${when( | |
| this.file?.diffs_too_expensive_to_compute, | |
| () => html` | |
| <div class="expensiveDiff"> | |
| <gr-icon icon="warning"></gr-icon> | |
| Diff too expensive to compute. | |
| <gr-button link @click=${this.handleOpenDownloadDialog}> | |
| Please download locally to review | |
| </gr-button> | |
| </div> | |
| ` | |
| )} | |
| <gr-content-with-sidebar | |
| .side=${this.getSidebarSide()} | |
| .hideSide=${!this.shownSidebar} | |
| > | |
| <div | |
| slot="main" | |
| class=${classMap({ | |
| diffContainer: true, | |
| sidebarOpen: !!this.shownSidebar, | |
| hidden: !!this.file?.diffs_too_expensive_to_compute, | |
| })} | |
| > | |
| ${when( | |
| this.isShowingRichMarkdown(), | |
| () => html` | |
| <gr-diff-markdown-viewer | |
| id="markdownViewer" | |
| .diff=${this.diff} | |
| .path=${this.path} | |
| .patchRange=${this.patchRange} | |
| .threads=${this.threadsForFile} | |
| .loggedIn=${this.loggedIn} | |
| ></gr-diff-markdown-viewer> | |
| ` | |
| )} | |
| <gr-endpoint-decorator | |
| name="diff-content" | |
| ?hidden=${this.isShowingRichMarkdown()} | |
| > | |
| <gr-diff-host | |
| id="diffHost" | |
| ?hidden=${this.isShowingRichMarkdown()} | |
| ?disabledThreads=${this.isShowingRichMarkdown()} | |
| .changeNum=${this.changeNum} | |
| .change=${this.change} | |
| .patchRange=${this.patchRange} | |
| .file=${file} | |
| .lineOfInterest=${this.getLineOfInterest()} | |
| .path=${this.path} | |
| .projectName=${this.change?.project} | |
| @is-blame-loaded-changed=${this.onIsBlameLoadedChanged} | |
| @comment-anchor-tap=${this.onCommentAnchorTap} | |
| @line-selected=${this.onLineSelected} | |
| @diff-changed=${this.onDiffChanged} | |
| @edit-weblinks-changed=${this.onEditWeblinksChanged} | |
| @files-weblinks-changed=${this.onFilesWeblinksChanged} | |
| @render=${this.reInitCursor} | |
| > | |
| </gr-diff-host> | |
| </gr-endpoint-decorator> | |
| </div> | |
| <div slot="side">${this.renderSidebarContent()}</div> | |
| </gr-content-with-sidebar> | |
| ${this.renderDialogs()} | |
| `; | |
| } | |
| private onStickyHeaderCreated(el?: Element) { | |
| this.stickyHeaderEl = el as HTMLElement | undefined; | |
| if (el) { | |
| this.headerResizeObserver.observe(el); | |
| } | |
| } | |
| private renderStickyHeader() { | |
| return html` <div | |
| class="stickyHeader ${this.patchNum === EDIT ? 'editMode' : ''}" | |
| ${ref(this.onStickyHeaderCreated)} | |
| > | |
| <h1 class="assistive-tech-only"> | |
| Diff of ${this.path ? computeTruncatedPath(this.path) : ''} | |
| </h1> | |
| <header>${this.renderHeader()}</header> | |
| <div class="subHeader"> | |
| ${this.renderPatchRangeLeft()} ${this.renderRightControls()} | |
| </div> | |
| <div class="fileNav mobile"> | |
| <a class="mobileNavLink" href=${ifDefined(this.computeNavLinkURL(-1))} | |
| ><</a | |
| > | |
| <div class="fullFileName mobile">${computeDisplayPath(this.path)}</div> | |
| <a class="mobileNavLink" href=${ifDefined(this.computeNavLinkURL(1))} | |
| >></a | |
| > | |
| </div> | |
| </div>`; | |
| } | |
| private renderHeader() { | |
| const formattedFiles = this.formatFilesForDropdown(); | |
| const fileNum = this.computeFileNum(formattedFiles); | |
| const fileNumClass = this.computeFileNumClass(fileNum, formattedFiles); | |
| return html` <div class="headerLeft"> | |
| <div> | |
| <a href=${ifDefined(this.getChangeModel().changeUrl())} | |
| >${this.changeNum}</a | |
| ><span class="changeNumberColon">:</span> | |
| </div> | |
| <span class="headerSubject" | |
| >${trimWithEllipsis(this.change?.subject, 80)}</span | |
| > | |
| <div class="checkboxDiv"> | |
| <md-checkbox | |
| id="reviewed" | |
| class="reviewed hideOnEdit" | |
| ?hidden=${!this.loggedIn} | |
| title="Toggle reviewed status of file" | |
| aria-label="file reviewed" | |
| ?checked=${this.reviewed} | |
| @change=${this.handleReviewedChange} | |
| ></md-checkbox> | |
| </div> | |
| <div class="jumpToFileContainer"> | |
| <gr-dropdown-list | |
| id="dropdown" | |
| .value=${this.path ?? ''} | |
| .items=${formattedFiles} | |
| show-copy-for-trigger-text | |
| @value-change=${this.handleFileChange} | |
| ></gr-dropdown-list> | |
| </div> | |
| </div> | |
| <div class="navLinks desktop"> | |
| <span class="fileNum ${ifDefined(fileNumClass)}"> | |
| File ${fileNum} of ${formattedFiles.length} | |
| <span class="separator"></span> | |
| </span> | |
| <a | |
| class="navLink" | |
| title=${this.createTitle( | |
| Shortcut.PREV_FILE, | |
| ShortcutSection.NAVIGATION | |
| )} | |
| href=${ifDefined(this.computeNavLinkURL(-1))} | |
| >Prev</a | |
| > | |
| <span class="separator"></span> | |
| <a | |
| class="navLink" | |
| title=${this.createTitle( | |
| Shortcut.UP_TO_CHANGE, | |
| ShortcutSection.NAVIGATION | |
| )} | |
| href=${ifDefined(this.getChangeModel().changeUrl())} | |
| >Up</a | |
| > | |
| <span class="separator"></span> | |
| <a | |
| class="navLink" | |
| title=${this.createTitle( | |
| Shortcut.NEXT_FILE, | |
| ShortcutSection.NAVIGATION | |
| )} | |
| href=${ifDefined(this.computeNavLinkURL(1))} | |
| >Next</a | |
| > | |
| </div>`; | |
| } | |
| private renderSidebarTriggers() { | |
| return html` | |
| <div class="sidebarTriggerContainer"> | |
| <gr-endpoint-decorator name="sidebarTrigger"> | |
| <gr-endpoint-param | |
| name="onTrigger" | |
| .value=${(pluginName: string) => { | |
| const closeSidebar = this.shownSidebar === pluginName; | |
| this.shownSidebar = closeSidebar ? undefined : pluginName; | |
| this.getUserModel().updatePreferences({ | |
| diff_page_sidebar: closeSidebar | |
| ? 'NONE' | |
| : `plugin-${pluginName}`, | |
| }); | |
| }} | |
| ></gr-endpoint-param> | |
| <!-- params cannot start falsy, so the value must be wrapped --> | |
| <gr-endpoint-param | |
| name="openSidebar" | |
| .value=${{name: this.shownSidebar}} | |
| ></gr-endpoint-param> | |
| </gr-endpoint-decorator> | |
| </div> | |
| `; | |
| } | |
| private getSidebarSide(): 'left' | 'right' { | |
| if (!this.shownSidebar) { | |
| return 'left'; | |
| } | |
| const details = this.getPluginLoader().pluginEndPoints.getDetails( | |
| `sidebarContent-${this.shownSidebar}` | |
| ); | |
| for (const info of details) { | |
| if (info.moduleName) { | |
| const customElement = customElements.get(info.moduleName) as | |
| | {sidebarPosition?: string} | |
| | undefined; | |
| // Bracket notation prevents Closure Compiler from mangling the property name | |
| // when plugins are minified. | |
| if (customElement?.['sidebarPosition'] === 'right') { | |
| return 'right'; | |
| } | |
| } | |
| } | |
| return 'left'; | |
| } | |
| private renderSidebarContent() { | |
| return html` | |
| ${when(this.shownSidebar !== undefined, () => | |
| keyed( | |
| this.shownSidebar, | |
| html` | |
| <div class="sidebarContents"> | |
| <gr-endpoint-decorator | |
| name=${`sidebarContent-${this.shownSidebar}`} | |
| > | |
| <gr-endpoint-param | |
| name="change" | |
| .value=${this.change} | |
| ></gr-endpoint-param> | |
| <gr-endpoint-param | |
| name="path" | |
| .value=${this.path} | |
| ></gr-endpoint-param> | |
| <!-- current diff path and, in case of rename, previous path --> | |
| <gr-endpoint-param | |
| name="fileRange" | |
| .value=${this.getFileRange()} | |
| ></gr-endpoint-param> | |
| <gr-endpoint-param | |
| name="basePatchNum" | |
| .value=${this.basePatchNum} | |
| ></gr-endpoint-param> | |
| <gr-endpoint-param | |
| name="patchNum" | |
| .value=${this.patchNum} | |
| ></gr-endpoint-param> | |
| <gr-endpoint-param | |
| name="content" | |
| .value=${this.diff} | |
| ></gr-endpoint-param> | |
| <gr-endpoint-param | |
| name="cursor" | |
| .value=${this.cursor} | |
| ></gr-endpoint-param> | |
| <gr-endpoint-param | |
| name="diff" | |
| .value=${this.diffHost?.diffElement} | |
| ></gr-endpoint-param> | |
| <gr-endpoint-param | |
| name="comments" | |
| .value=${this.commentsForPath} | |
| ></gr-endpoint-param> | |
| <gr-endpoint-param | |
| name="onClose" | |
| .value=${(pluginName: string) => { | |
| // Only close the sidebar if that particular sidebar is | |
| // still open. An async onClose callback should not close a | |
| // different sidebar. | |
| if (this.shownSidebar !== pluginName) { | |
| return; | |
| } | |
| this.shownSidebar = undefined; | |
| this.getUserModel().updatePreferences({ | |
| diff_page_sidebar: 'NONE', | |
| }); | |
| }} | |
| > | |
| </gr-endpoint-param> | |
| </gr-endpoint-decorator> | |
| </div> | |
| ` | |
| ) | |
| )} | |
| `; | |
| } | |
| private renderPatchRangeLeft() { | |
| return html` <div class="patchRangeLeft"> | |
| <gr-patch-range-select | |
| id="rangeSelect" | |
| .filesWeblinks=${this.filesWeblinks} | |
| .path=${this.path} | |
| @patch-range-change=${this.handlePatchChange} | |
| > | |
| </gr-patch-range-select> | |
| <span class="download desktop"> | |
| <span class="separator"></span> | |
| <gr-dropdown | |
| link="" | |
| down-arrow="" | |
| .items=${this.computeDownloadDropdownLinks()} | |
| .disabledIds=${this.isTooLargeForDownload() | |
| ? ['left-content', 'right-content'] | |
| : []} | |
| horizontal-align="left" | |
| > | |
| <span class="downloadTitle"> Download </span> | |
| </gr-dropdown> | |
| </span> | |
| </div>`; | |
| } | |
| private renderRightControls() { | |
| const diffModeSelectorClass = !this.diff || this.diff.binary ? 'hide' : ''; | |
| return html` <div class="rightControls"> | |
| <gr-endpoint-decorator | |
| name="diff-header-controls" | |
| ></gr-endpoint-decorator> | |
| ${this.renderSidebarTriggers()} ${this.renderShowEntireFileButton()} | |
| ${this.renderBlameButton()} ${this.renderRichMarkdownToggle()} | |
| ${when( | |
| this.computeCanEdit(), | |
| () => html` | |
| <span class="separator"></span> | |
| <span class="editButton"> | |
| <gr-button | |
| link="" | |
| title="Edit current file" | |
| @click=${this.goToEditFile} | |
| >Edit</gr-button | |
| > | |
| </span> | |
| ` | |
| )} | |
| ${when( | |
| this.computeShowEditLinks(), | |
| () => html` | |
| <span class="separator"></span> | |
| ${this.editWeblinks!.map( | |
| weblink => html`<gr-weblink .info=${weblink}></gr-weblink>` | |
| )} | |
| ` | |
| )} | |
| ${when( | |
| this.loggedIn && this.prefs, | |
| () => html` | |
| <span class="separator"></span> | |
| <div class="diffModeSelector ${diffModeSelectorClass}"> | |
| <span>Diff view:</span> | |
| <gr-diff-mode-selector | |
| id="modeSelect" | |
| .saveOnChange=${this.loggedIn} | |
| show-tooltip-below | |
| ></gr-diff-mode-selector> | |
| </div> | |
| <span id="diffPrefsContainer"> | |
| <span class="preferences desktop"> | |
| <gr-tooltip-content | |
| has-tooltip="" | |
| position-below="" | |
| title="Diff preferences" | |
| > | |
| <gr-button | |
| link="" | |
| class="prefsButton" | |
| @click=${(e: Event) => this.handlePrefsTap(e)} | |
| ><gr-icon icon="settings" filled></gr-icon | |
| ></gr-button> | |
| </gr-tooltip-content> | |
| </span> | |
| </span> | |
| ` | |
| )} | |
| <gr-endpoint-decorator name="annotation-toggler"> | |
| <span hidden="" id="annotation-span"> | |
| <label id="annotation-label" for="annotation-checkbox"></label> | |
| <md-checkbox | |
| id="annotation-checkbox" | |
| touch-target="none" | |
| disabled | |
| aria-labelledby="annotation-label" | |
| ></md-checkbox> | |
| </span> | |
| </gr-endpoint-decorator> | |
| </div>`; | |
| } | |
| private renderShowEntireFileButton() { | |
| if (isImageDiff(this.diff) || isMagicPath(this.path)) return; | |
| return html`<gr-button | |
| link="" | |
| id="toggleEntireFile" | |
| title=${this.createTitle( | |
| Shortcut.TOGGLE_ALL_DIFF_CONTEXT, | |
| ShortcutSection.DIFFS | |
| )} | |
| @click=${this.handleToggleAllDiffContext} | |
| >${this.isShowingEntireFile | |
| ? 'Hide Unchanged Lines' | |
| : 'Show Entire File'}</gr-button | |
| >`; | |
| } | |
| private renderBlameButton() { | |
| if (!this.allowBlame) return; | |
| const showBlameLoader = !isMagicPath(this.path) && !isImageDiff(this.diff); | |
| if (!showBlameLoader) return; | |
| let blameToggleLabel = 'Loading blame ...'; | |
| if (!this.isBlameLoading) { | |
| blameToggleLabel = this.isBlameLoaded ? 'Hide Blame' : 'Show Blame'; | |
| } | |
| return html`<span class="separator"></span | |
| ><span class="blameLoader"> | |
| <gr-button | |
| link="" | |
| id="toggleBlame" | |
| title=${this.createTitle( | |
| Shortcut.TOGGLE_BLAME, | |
| ShortcutSection.DIFFS | |
| )} | |
| ?disabled=${this.isBlameLoading} | |
| @click=${this.toggleBlame} | |
| >${blameToggleLabel}</gr-button | |
| > | |
| </span>`; | |
| } | |
| private renderRichMarkdownToggle() { | |
| if (!isMarkdownDiff(this.path, this.diff)) return nothing; | |
| return html`<span class="separator"></span | |
| ><span class="richMarkdownToggle"> | |
| <gr-tooltip-content | |
| has-tooltip="" | |
| position-below="" | |
| title=${this.showRichMarkdown | |
| ? 'Switch to source diff' | |
| : 'Switch to rich rendered Markdown diff'} | |
| > | |
| <gr-button | |
| link="" | |
| id="toggleRichMarkdown" | |
| @click=${this.toggleRichMarkdown} | |
| > | |
| <gr-icon | |
| icon=${this.showRichMarkdown ? 'code' : 'preview'} | |
| filled="" | |
| ></gr-icon> | |
| ${this.showRichMarkdown ? 'Source diff' : 'Rich diff'} | |
| </gr-button> | |
| </gr-tooltip-content> | |
| </span>`; | |
| } | |
| async toggleRichMarkdown() { | |
| if (this.isShowingRichMarkdown()) { | |
| await this.markdownViewer?.autoSaveDrafts(); | |
| } else { | |
| await this.diffHost?.autoSaveDrafts(); | |
| } | |
| this.showRichMarkdown = !this.showRichMarkdown; | |
| } | |
| private isShowingRichMarkdown(): boolean { | |
| return this.showRichMarkdown && isMarkdownDiff(this.path, this.diff); | |
| } | |
| private renderDialogs() { | |
| return html` | |
| <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog> | |
| <gr-diff-preferences-dialog id="diffPreferencesDialog"> | |
| </gr-diff-preferences-dialog> | |
| <dialog id="downloadModal" tabindex="-1"> | |
| <gr-download-dialog | |
| id="downloadDialog" | |
| @close=${this.handleDownloadDialogClose} | |
| ></gr-download-dialog> | |
| </dialog> | |
| `; | |
| } | |
| /** | |
| * Set initial review status of the file. | |
| * automatically mark the file as reviewed if manual review is not set. | |
| */ | |
| setReviewedStatus( | |
| patchNum: RevisionPatchSetNum, | |
| diffPrefs: DiffPreferencesInfo | |
| ) { | |
| if (!this.loggedIn) return; | |
| if (!diffPrefs.manual_review) { | |
| this.setReviewed(true, patchNum); | |
| } | |
| } | |
| /** | |
| * Returns the current file path and, if it was renamed in this change, the | |
| * previous file path. | |
| */ | |
| private getFileRange() { | |
| if (!this.files || !this.path) return; | |
| const fileInfo = this.files.changeFilesByPath[this.path]; | |
| const fileRange: FileRange = {path: this.path}; | |
| if (fileInfo?.old_path) { | |
| fileRange.basePath = fileInfo.old_path; | |
| } | |
| return fileRange; | |
| } | |
| private handleReviewedChange(e: Event) { | |
| const input = e.target as MdCheckbox; | |
| this.setReviewed(input.checked ?? false); | |
| } | |
| // Private but used in tests. | |
| setReviewed( | |
| reviewed: boolean, | |
| patchNum: RevisionPatchSetNum | undefined = this.patchNum | |
| ) { | |
| if (this.patchNum === EDIT) return; | |
| if (!patchNum || !this.path || !this.changeNum) return; | |
| // if file is already reviewed then do not make a saveReview request | |
| if (this.reviewedFiles.has(this.path) && reviewed) return; | |
| // optimistic update | |
| this.reviewed = reviewed; | |
| this.getChangeModel().setReviewedFilesStatus( | |
| this.changeNum, | |
| patchNum, | |
| this.path, | |
| reviewed | |
| ); | |
| } | |
| // Private but used in tests. | |
| handleToggleFileReviewed() { | |
| this.setReviewed(!this.reviewed); | |
| } | |
| private handlePrevLine() { | |
| assertIsDefined(this.diffHost, 'diffHost'); | |
| this.cursor?.moveUp(); | |
| } | |
| private onOpenFixPreview(e: OpenFixPreviewEvent) { | |
| assertIsDefined(this.applyFixDialog, 'applyFixDialog'); | |
| this.applyFixDialog.open(e); | |
| } | |
| private onIsBlameLoadedChanged(e: ValueChangedEvent<boolean>) { | |
| this.isBlameLoaded = e.detail.value; | |
| } | |
| private onDiffChanged(e: ValueChangedEvent<DiffInfo>) { | |
| this.diff = e.detail.value; | |
| } | |
| private onEditWeblinksChanged( | |
| e: ValueChangedEvent<WebLinkInfo[] | undefined> | |
| ) { | |
| this.editWeblinks = e.detail.value; | |
| } | |
| private onFilesWeblinksChanged( | |
| e: ValueChangedEvent<FilesWebLinks | undefined> | |
| ) { | |
| this.filesWeblinks = e.detail.value; | |
| } | |
| private handleNextLine() { | |
| assertIsDefined(this.diffHost, 'diffHost'); | |
| this.cursor?.moveDown(); | |
| } | |
| // Private but used in tests. | |
| moveToFileWithComment(direction: -1 | 1) { | |
| const path = this.findFileWithComment(direction); | |
| if (!path) { | |
| this.getChangeModel().navigateToChange(); | |
| } else { | |
| this.getChangeModel().navigateToDiff({path}); | |
| } | |
| } | |
| private handleNewComment() { | |
| this.classList.remove('hideComments'); | |
| this.classList.remove('hideCheckCodePointers'); | |
| if (this.isShowingRichMarkdown()) { | |
| this.markdownViewer?.createCommentFromSelectionOrHover(); | |
| return; | |
| } | |
| this.cursor?.createCommentInPlace(); | |
| } | |
| private handlePrevFile() { | |
| if (!this.path) return; | |
| if (!this.files?.sortedPaths) return; | |
| this.navToFile(this.files.sortedPaths, -1); | |
| } | |
| private handleNextFile() { | |
| if (!this.path) return; | |
| if (!this.files?.sortedPaths) return; | |
| this.navToFile(this.files.sortedPaths, 1); | |
| } | |
| private handleNextChunk() { | |
| const result = this.cursor?.moveToNextChunk(); | |
| if (result === CursorMoveResult.CLIPPED && this.cursor?.isAtEnd()) { | |
| this.showToastAndNavigateFile('next', 'n'); | |
| } | |
| } | |
| private handleNextCommentThread() { | |
| const result = this.cursor?.moveToNextCommentThread(); | |
| if (result === CursorMoveResult.CLIPPED) { | |
| this.navigateToNextFileWithCommentThread(); | |
| } | |
| } | |
| private lastDisplayedNavigateToFileToast: Map<string, number> = new Map(); | |
| private showToastAndNavigateFile(direction: string, shortcut: string) { | |
| /* | |
| * If user presses p/n on the first/last diff chunk, show a toast informing | |
| * user that pressing it again will navigate them to previous/next | |
| * unreviewedfile if click happens within the time limit | |
| */ | |
| if ( | |
| this.lastDisplayedNavigateToFileToast.get(direction) && | |
| Date.now() - this.lastDisplayedNavigateToFileToast.get(direction)! <= | |
| NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS | |
| ) { | |
| // reset for next file | |
| this.lastDisplayedNavigateToFileToast.delete(direction); | |
| this.navigateToUnreviewedFile(direction); | |
| } else { | |
| this.lastDisplayedNavigateToFileToast.set(direction, Date.now()); | |
| if (this.hasShownNavigateToFileToast.get(direction)) { | |
| // Popup for "direction" has already been shown once | |
| return; | |
| } | |
| fireAlert( | |
| this, | |
| `Press ${shortcut} again to navigate to ${direction} unreviewed file` | |
| ); | |
| this.hasShownNavigateToFileToast.set(direction, true); | |
| } | |
| } | |
| private navigateToUnreviewedFile(direction: string) { | |
| if (!this.path) return; | |
| if (!this.files?.sortedPaths) return; | |
| if (!this.reviewedFiles) return; | |
| // Ensure that the currently viewed file always appears in unreviewedFiles | |
| // so we resolve the right "next" file. | |
| const unreviewedFiles = this.files.sortedPaths.filter( | |
| file => file === this.path || !this.reviewedFiles.has(file) | |
| ); | |
| this.navToFile(unreviewedFiles, direction === 'next' ? 1 : -1); | |
| } | |
| private handlePrevChunk() { | |
| this.cursor?.moveToPreviousChunk(); | |
| if (this.cursor?.isAtStart()) { | |
| this.showToastAndNavigateFile('previous', 'p'); | |
| } | |
| } | |
| private handlePrevCommentThread() { | |
| this.cursor?.moveToPreviousCommentThread(); | |
| } | |
| // Similar to gr-change-view.handleOpenReplyDialog | |
| private handleOpenReplyDialog() { | |
| if (!this.loggedIn) { | |
| fire(this, 'show-auth-required', {}); | |
| return; | |
| } | |
| this.getChangeModel().navigateToChange(true); | |
| } | |
| private handleToggleLeftPane() { | |
| assertIsDefined(this.diffHost, 'diffHost'); | |
| this.diffHost.toggleLeftDiff(); | |
| } | |
| // private but used in tests | |
| handleOpenDownloadDialog() { | |
| assertIsDefined(this.downloadModal, 'downloadModal'); | |
| this.downloadModal.showModal(); | |
| whenVisible(this.downloadModal, () => { | |
| assertIsDefined(this.downloadModal, 'downloadModal'); | |
| assertIsDefined(this.downloadDialog, 'downloadDialog'); | |
| this.downloadDialog.focus(); | |
| }); | |
| } | |
| private handleDownloadDialogClose() { | |
| assertIsDefined(this.downloadModal, 'downloadModal'); | |
| this.downloadModal.close(); | |
| } | |
| private handleCommaKey() { | |
| if (!this.loggedIn) return; | |
| assertIsDefined(this.diffPreferencesDialog, 'diffPreferencesDialog'); | |
| this.diffPreferencesDialog.open(); | |
| } | |
| // Private but used in tests. | |
| handleToggleDiffMode() { | |
| if (!this.userPrefs) return; | |
| if (this.userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) { | |
| this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED}); | |
| } else { | |
| this.getUserModel().updatePreferences({ | |
| diff_view: DiffViewMode.SIDE_BY_SIDE, | |
| }); | |
| } | |
| } | |
| // Private but used in tests. | |
| navToFile( | |
| fileList: string[], | |
| direction: -1 | 1, | |
| navigateToFirstComment?: boolean | |
| ) { | |
| const newPath = this.getNavLinkPath(fileList, direction); | |
| if (!newPath) return; | |
| if (!this.patchRange) return; | |
| if (newPath.up) { | |
| this.getChangeModel().navigateToChange(); | |
| return; | |
| } | |
| if (!newPath.path) return; | |
| let lineNum; | |
| if (navigateToFirstComment) | |
| lineNum = this.changeComments?.getCommentsForPath( | |
| newPath.path, | |
| this.patchRange | |
| )?.[0].line; | |
| this.getChangeModel().navigateToDiff({path: newPath.path, lineNum}); | |
| } | |
| /** | |
| * @param direction Either 1 (next file) or -1 (prev file). | |
| * @return The next URL when proceeding in the specified | |
| * direction. | |
| */ | |
| private computeNavLinkURL(direction?: -1 | 1) { | |
| if (!this.change) return; | |
| if (!this.path) return; | |
| if (!this.files?.sortedPaths) return; | |
| if (!direction) return; | |
| const newPath = this.getNavLinkPath(this.files.sortedPaths, direction); | |
| if (!newPath) return; | |
| if (newPath.up) return this.getChangeModel().changeUrl(); | |
| if (!newPath.path) return; | |
| if (!this.patchNum) return; | |
| return this.getViewModel().diffUrl({ | |
| diffView: {path: newPath.path}, | |
| patchNum: this.patchNum, | |
| }); | |
| } | |
| private goToEditFile() { | |
| assertIsDefined(this.path, 'path'); | |
| const lineNumber = this.cursor?.getTargetLineNumber(); | |
| const lineNum = typeof lineNumber === 'number' ? lineNumber : undefined; | |
| this.getChangeModel().navigateToEdit({path: this.path, lineNum}); | |
| } | |
| /** | |
| * Gives an object representing the target of navigating either left or | |
| * right through the change. The resulting object will have one of the | |
| * following forms: | |
| * * {path: "<target file path>"} - When another file path should be the | |
| * result of the navigation. | |
| * * {up: true} - When the result of navigating should go back to the | |
| * change view. | |
| * * null - When no navigation is possible for the given direction. | |
| * | |
| * @param path The path of the current file being shown. | |
| * @param fileList The list of files in this change and | |
| * patch range. | |
| * @param direction Either 1 (next file) or -1 (prev file). | |
| */ | |
| private getNavLinkPath(fileList: string[], direction: -1 | 1) { | |
| if (!this.path || !fileList || fileList.length === 0) { | |
| return null; | |
| } | |
| let idx = fileList.indexOf(this.path); | |
| if (idx === -1) { | |
| const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1]; | |
| return {path: file}; | |
| } | |
| idx += direction; | |
| // Redirect to the change view if noUp isn't truthy and idx falls | |
| // outside the bounds of [0, fileList.length). | |
| if (idx < 0 || idx > fileList.length - 1) { | |
| return {up: true}; | |
| } | |
| return {path: fileList[idx]}; | |
| } | |
| private updateUrlToDiffUrl( | |
| lineNum?: number, | |
| leftSide?: boolean, | |
| endLineNum?: number | |
| ) { | |
| if (!this.path || !this.patchNum) return; | |
| const url = this.getViewModel().diffUrl({ | |
| diffView: { | |
| path: this.path, | |
| lineNum, | |
| leftSide, | |
| endLineNum, | |
| }, | |
| patchNum: this.patchNum, | |
| }); | |
| history.replaceState(null, '', url); | |
| } | |
| async reloadDiff() { | |
| if (!this.diffHost) return; | |
| await this.diffHost.reload(true); | |
| this.reporting.diffViewDisplayed(); | |
| if (this.isBlameLoaded) this.loadBlame(); | |
| this.isShowingEntireFile = this.diffHost?.isShowFullContext() ?? false; | |
| } | |
| /** | |
| * (Re-initialize) the diff view without actually reloading the diff. The | |
| * typical user journey is that the user comes back from the change page. | |
| */ | |
| initializePositions() { | |
| // The diff view is kept in the background once created. If the user | |
| // scrolls in the change page, the scrolling is reflected in the diff view | |
| // as well, which means the diff is scrolled to a random position based | |
| // on how much the change view was scrolled. | |
| // Hence, reset the scroll position here. | |
| document.documentElement.scrollTop = 0; | |
| this.initCursor(); | |
| this.reInitCursor(); | |
| this.diffHost?.initLayers(); | |
| this.classList.remove('hideComments'); | |
| this.classList.remove('hideCheckCodePointers'); | |
| } | |
| /** | |
| * If the params specify a diff address then configure the diff cursor. | |
| * Private but used in tests. | |
| */ | |
| initCursor() { | |
| if (!this.focusLineNum) { | |
| return; | |
| } | |
| if (!this.cursor) { | |
| return; | |
| } | |
| this.cursor.side = this.leftSide ? Side.LEFT : Side.RIGHT; | |
| this.cursor.initialLineNumber = this.focusLineNum; | |
| this.cursor.initialEndLineNumber = this.focusEndLineNum; | |
| if (this.diffHost?.diffElement && !this.diffHost.diffElement.loading) { | |
| this.cursor.moveToLineNumber( | |
| this.focusLineNum, | |
| this.cursor.side, | |
| this.path, | |
| true, | |
| this.focusEndLineNum | |
| ); | |
| } | |
| } | |
| // Private but used in tests. | |
| getLineOfInterest(): DisplayLine | undefined { | |
| // If there is a line number specified, pass it along to the diff so that | |
| // it will not get collapsed. | |
| if (!this.focusLineNum) return undefined; | |
| return { | |
| lineNum: this.focusLineNum, | |
| side: this.leftSide ? Side.LEFT : Side.RIGHT, | |
| }; | |
| } | |
| // Private but used in tests | |
| formatFilesForDropdown(): DropdownItem[] { | |
| if (!this.files) return []; | |
| if (!this.patchRange) return []; | |
| if (!this.changeComments) return []; | |
| const dropdownContent: DropdownItem[] = []; | |
| for (const path of this.files.sortedPaths) { | |
| const file = this.files.changeFilesByPath[path]; | |
| dropdownContent.push({ | |
| text: computeDisplayPath(path), | |
| mobileText: computeTruncatedPath(path), | |
| value: path, | |
| bottomText: this.changeComments.computeCommentsString( | |
| this.patchRange, | |
| path, | |
| file, | |
| /* includeUnmodified= */ true | |
| ), | |
| file, | |
| }); | |
| } | |
| return dropdownContent; | |
| } | |
| // Private but used in tests. | |
| handleFileChange(e: ValueChangedEvent<string>) { | |
| const path: string = e.detail.value; | |
| if (path === this.path) return; | |
| this.getChangeModel().navigateToDiff({path}); | |
| } | |
| // Private but used in tests. | |
| handlePatchChange(e: PatchRangeChangeEvent) { | |
| if (!this.path) return; | |
| if (!this.patchNum) return; | |
| const {basePatchNum, patchNum} = e.detail; | |
| if (basePatchNum === this.basePatchNum && patchNum === this.patchNum) { | |
| return; | |
| } | |
| this.getChangeModel().navigateToDiff( | |
| {path: this.path}, | |
| patchNum, | |
| basePatchNum | |
| ); | |
| } | |
| // Private but used in tests. | |
| handlePrefsTap(e: Event) { | |
| e.preventDefault(); | |
| assertIsDefined(this.diffPreferencesDialog, 'diffPreferencesDialog'); | |
| this.diffPreferencesDialog.open(); | |
| } | |
| // Private but used in tests. | |
| onCommentAnchorTap(e: CustomEvent<CommentAnchorTapEventDetail>) { | |
| const lineNumber = e.detail.number; | |
| if (!Number.isInteger(lineNumber)) return; | |
| this.updateUrlToDiffUrl( | |
| lineNumber as number, | |
| e.detail.side === CommentSide.PARENT | |
| ); | |
| } | |
| // Private but used in tests. | |
| onLineSelected(e: CustomEvent<LineSelectedEventDetail>) { | |
| const lineNumber = e.detail.number; | |
| if (!Number.isInteger(lineNumber)) return; | |
| this.updateUrlToDiffUrl(lineNumber as number, e.detail.side === Side.LEFT); | |
| } | |
| private isTooLargeForDownload() { | |
| return (this.file?.size ?? 0) > FILE_DOWNLOAD_LIMIT_BYTES; | |
| } | |
| // Private but used in tests. | |
| computeDownloadDropdownLinks(): DropdownLink[] { | |
| if (!this.change?.project) return []; | |
| if (!this.changeNum) return []; | |
| if (!this.patchRange) return []; | |
| if (!this.path) return []; | |
| const links: DropdownLink[] = [ | |
| { | |
| url: this.computeDownloadPatchLink( | |
| this.change.project, | |
| this.changeNum, | |
| this.patchRange, | |
| this.path | |
| ), | |
| name: 'Patch', | |
| }, | |
| ]; | |
| if (this.isTooLargeForDownload()) { | |
| links.push({ | |
| id: 'left-content', | |
| name: 'Left Content (Too Large)', | |
| }); | |
| links.push({ | |
| id: 'right-content', | |
| name: 'Right Content (Too Large)', | |
| }); | |
| } else { | |
| if (this.diff && this.diff.meta_a) { | |
| let leftPath = this.path; | |
| if (this.diff.change_type === 'RENAMED') { | |
| leftPath = this.diff.meta_a.name; | |
| } | |
| links.push({ | |
| url: this.computeDownloadFileLink( | |
| this.change.project, | |
| this.changeNum, | |
| this.patchRange, | |
| leftPath, | |
| true | |
| ), | |
| name: 'Left Content', | |
| }); | |
| } | |
| if (this.diff && this.diff.meta_b) { | |
| links.push({ | |
| url: this.computeDownloadFileLink( | |
| this.change.project, | |
| this.changeNum, | |
| this.patchRange, | |
| this.path, | |
| false | |
| ), | |
| name: 'Right Content', | |
| }); | |
| } | |
| } | |
| return links; | |
| } | |
| // TODO: Move to view-model or router. | |
| // Private but used in tests. | |
| computeDownloadFileLink( | |
| repo: RepoName, | |
| changeNum: NumericChangeId, | |
| patchRange: PatchRange, | |
| path: string, | |
| isBase?: boolean | |
| ) { | |
| let patchNum = patchRange.patchNum; | |
| let parent: number | undefined = undefined; | |
| if (isBase) { | |
| if (isMergeParent(patchRange.basePatchNum)) { | |
| parent = getParentIndex(patchRange.basePatchNum); | |
| } else if (patchRange.basePatchNum === PARENT) { | |
| parent = 1; | |
| } else { | |
| patchNum = patchRange.basePatchNum as PatchSetNumber; | |
| } | |
| } | |
| let url = | |
| changeBaseURL(repo, changeNum, patchNum) + | |
| `/files/${encodeURIComponent(path)}/download`; | |
| if (parent) url += `?parent=${parent}`; | |
| return url; | |
| } | |
| // TODO: Move to view-model or router. | |
| // Private but used in tests. | |
| computeDownloadPatchLink( | |
| repo: RepoName, | |
| changeNum: NumericChangeId, | |
| patchRange: PatchRange, | |
| path: string | |
| ) { | |
| let url = changeBaseURL(repo, changeNum, patchRange.patchNum); | |
| url += '/patch?zip&path=' + encodeURIComponent(path); | |
| return url; | |
| } | |
| // Private but used in tests. | |
| findFileWithComment(direction: -1 | 1): string | undefined { | |
| const fileList = this.files?.sortedPaths; | |
| const commentMap: CommentMap = | |
| this.changeComments?.getPaths(this.patchRange) ?? {}; | |
| if (!fileList || fileList.length === 0) return undefined; | |
| if (!this.path) return undefined; | |
| const pathIndex = fileList.indexOf(this.path); | |
| const stopIndex = direction === 1 ? fileList.length : -1; | |
| for (let i = pathIndex + direction; i !== stopIndex; i += direction) { | |
| if (commentMap[fileList[i]]) return fileList[i]; | |
| } | |
| return undefined; | |
| } | |
| // Private but used in tests. | |
| loadBlame() { | |
| this.isBlameLoading = true; | |
| fireAlert(this, LOADING_BLAME); | |
| assertIsDefined(this.diffHost, 'diffHost'); | |
| this.diffHost | |
| .loadBlame() | |
| .then(() => { | |
| this.isBlameLoading = false; | |
| fireAlert(this, LOADED_BLAME); | |
| }) | |
| .catch(() => { | |
| this.isBlameLoading = false; | |
| }); | |
| } | |
| /** | |
| * Load and display blame information if it has not already been loaded. | |
| * Otherwise hide it. | |
| */ | |
| private toggleBlame() { | |
| if (!this.allowBlame) return; | |
| assertIsDefined(this.diffHost, 'diffHost'); | |
| if (this.isBlameLoaded) { | |
| this.diffHost.clearBlame(); | |
| } else { | |
| this.loadBlame(); | |
| } | |
| } | |
| // Private but used in tests. | |
| handleToggleHideAllCommentsAndCodePointers() { | |
| const hideComments = this.classList.toggle('hideComments'); | |
| /* Hide the check results along with the comments | |
| when the user presses the keyboard shortcut 'h'. */ | |
| if (hideComments) { | |
| this.classList.add('hideCheckCodePointers'); | |
| } else { | |
| this.classList.remove('hideCheckCodePointers'); | |
| } | |
| } | |
| // Private but used in tests. | |
| handleToggleHideCheckCodePointers() { | |
| this.classList.toggle('hideCheckCodePointers'); | |
| } | |
| private handleOpenFileList() { | |
| assertIsDefined(this.dropdown, 'dropdown'); | |
| this.dropdown.open(); | |
| } | |
| // Private but used in tests. | |
| handleDiffAgainstBase() { | |
| if (!this.isActiveChildView) return; | |
| assertIsDefined(this.path, 'path'); | |
| assertIsDefined(this.patchNum, 'patchNum'); | |
| if (this.basePatchNum === PARENT) { | |
| fireAlert(this, 'Base is already selected.'); | |
| return; | |
| } | |
| this.getChangeModel().navigateToDiff( | |
| {path: this.path}, | |
| this.patchNum, | |
| PARENT | |
| ); | |
| } | |
| // Private but used in tests. | |
| handleDiffBaseAgainstLeft() { | |
| if (!this.isActiveChildView) return; | |
| assertIsDefined(this.path, 'path'); | |
| assertIsDefined(this.patchNum, 'patchNum'); | |
| if (this.basePatchNum === PARENT) { | |
| fireAlert(this, 'Left is already base.'); | |
| return; | |
| } | |
| this.getChangeModel().navigateToDiff( | |
| {path: this.path}, | |
| this.basePatchNum as RevisionPatchSetNum, | |
| PARENT | |
| ); | |
| } | |
| // Private but used in tests. | |
| handleDiffAgainstLatest() { | |
| if (!this.isActiveChildView) return; | |
| assertIsDefined(this.path, 'path'); | |
| assertIsDefined(this.patchNum, 'patchNum'); | |
| if (this.patchNum === this.latestPatchNum) { | |
| fireAlert(this, 'Latest is already selected.'); | |
| return; | |
| } | |
| this.getChangeModel().navigateToDiff( | |
| {path: this.path}, | |
| this.latestPatchNum, | |
| this.basePatchNum | |
| ); | |
| } | |
| // Private but used in tests. | |
| handleDiffRightAgainstLatest() { | |
| if (!this.isActiveChildView) return; | |
| assertIsDefined(this.path, 'path'); | |
| assertIsDefined(this.patchNum, 'patchNum'); | |
| if (this.patchNum === this.latestPatchNum) { | |
| fireAlert(this, 'Right is already latest.'); | |
| return; | |
| } | |
| this.getChangeModel().navigateToDiff( | |
| {path: this.path}, | |
| this.latestPatchNum, | |
| this.patchNum as BasePatchSetNum | |
| ); | |
| } | |
| // Private but used in tests. | |
| handleDiffBaseAgainstLatest() { | |
| if (!this.isActiveChildView) return; | |
| assertIsDefined(this.path, 'path'); | |
| assertIsDefined(this.patchNum, 'patchNum'); | |
| if (this.patchNum === this.latestPatchNum && this.basePatchNum === PARENT) { | |
| fireAlert(this, 'Already diffing base against latest.'); | |
| return; | |
| } | |
| this.getChangeModel().navigateToDiff( | |
| {path: this.path}, | |
| this.latestPatchNum, | |
| PARENT | |
| ); | |
| } | |
| // Private but used in tests. | |
| computeFileNum(files: DropdownItem[]) { | |
| if (!this.path || !files) return undefined; | |
| return files.findIndex(({value}) => value === this.path) + 1; | |
| } | |
| // Private but used in tests. | |
| computeFileNumClass(fileNum?: number, files?: DropdownItem[]) { | |
| if (files && fileNum && fileNum > 0) { | |
| return 'show'; | |
| } | |
| return ''; | |
| } | |
| private handleToggleAllDiffContext() { | |
| assertIsDefined(this.diffHost, 'diffHost'); | |
| this.isShowingEntireFile = !this.isShowingEntireFile; | |
| this.diffHost.toggleAllContext(); | |
| } | |
| private handleNextUnreviewedFile() { | |
| this.setReviewed(true); | |
| this.navigateToUnreviewedFile('next'); | |
| } | |
| private navigateToNextFileWithCommentThread() { | |
| if (!this.path) return; | |
| if (!this.files?.sortedPaths) return; | |
| const range = this.patchRange; | |
| if (!range) return; | |
| if (!this.change) return; | |
| const hasComment = (path: string) => | |
| this.changeComments?.getCommentsForPath(path, range)?.length ?? 0 > 0; | |
| const filesWithComments = this.files.sortedPaths.filter( | |
| file => file === this.path || hasComment(file) | |
| ); | |
| this.navToFile(filesWithComments, 1, true); | |
| } | |
| private computeCanEdit() { | |
| return ( | |
| !!this.change && | |
| !!this.loggedIn && | |
| changeIsOpen(this.change) && | |
| !this.computeShowEditLinks() | |
| ); | |
| } | |
| private computeShowEditLinks() { | |
| return !!this.editWeblinks && this.editWeblinks.length > 0; | |
| } | |
| createTitle(shortcutName: Shortcut, section: ShortcutSection) { | |
| return this.getShortcutsService().createTitle(shortcutName, section); | |
| } | |
| } | |
| declare global { | |
| interface HTMLElementTagNameMap { | |
| 'gr-diff-view': GrDiffView; | |
| } | |
| } |