| /** |
| * @license |
| * Copyright 2015 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '@polymer/iron-dropdown/iron-dropdown'; |
| import '@polymer/iron-input/iron-input'; |
| 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-select/gr-select'; |
| 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 {getAppContext} from '../../../services/app-context'; |
| import {isMergeParent, getParentIndex} 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, |
| EDIT, |
| NumericChangeId, |
| PARENT, |
| PatchRange, |
| PatchSetNumber, |
| PreferencesInfo, |
| RepoName, |
| RevisionPatchSetNum, |
| Comment, |
| CommentMap, |
| DropdownLink, |
| } 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 {fireAlert, fire} from '../../../utils/event-util'; |
| import {assertIsDefined, queryAndAssert} from '../../../utils/common-util'; |
| import {whenVisible} from '../../../utils/dom-util'; |
| import {CursorMoveResult} from '../../../api/core'; |
| import {throttleWrap} from '../../../utils/async-util'; |
| import {filter, take, switchMap, map} 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 {when} from 'lit/directives/when.js'; |
| import {styleMap} from 'lit/directives/style-map.js'; |
| import { |
| ChangeChildView, |
| changeViewModelToken, |
| } from '../../../models/views/change'; |
| import {userModelToken} from '../../../models/user/user-model'; |
| import {modalStyles} from '../../../styles/gr-modal-styles'; |
| import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs'; |
| import {GrDiffPreferencesDialog} from '../gr-diff-preferences-dialog/gr-diff-preferences-dialog'; |
| import { |
| FileNameToNormalizedFileInfoMap, |
| filesModelToken, |
| } from '../../../models/change/files-model'; |
| import {isImageDiff} from '../../../utils/diff-util'; |
| import {formStyles} from '../../../styles/form-styles'; |
| import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list'; |
| import {configModelToken} from '../../../models/config/config-model'; |
| |
| 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 { |
| /** |
| * Fired when user tries to navigate away while comments are pending save. |
| * |
| * @event show-alert |
| */ |
| @query('#diffHost') |
| diffHost?: GrDiffHost; |
| |
| @state() |
| reviewed = false; |
| |
| @query('#downloadModal') |
| downloadModal?: HTMLDialogElement; |
| |
| @query('#downloadDialog') |
| downloadDialog?: GrDownloadDialog; |
| |
| @query('#dropdown') |
| dropdown?: GrDropdownList; |
| |
| @query('#applyFixDialog') |
| applyFixDialog?: GrApplyFixDialog; |
| |
| @query('#diffPreferencesDialog') |
| diffPreferencesDialog?: GrDiffPreferencesDialog; |
| |
| @query('.sidebarAnchor') |
| sidebarAnchor?: HTMLDivElement; |
| |
| @state() private sidebarHeight = 0; |
| |
| // 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. |
| @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; |
| |
| @state() private 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.leftSide`. */ |
| @state() |
| leftSide = false; |
| |
| @state() |
| commentsForPath: Comment[] = []; |
| |
| // 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; |
| |
| @state() |
| cursor?: GrDiffCursor; |
| |
| private readonly shortcutsController = new ShortcutController(this); |
| |
| 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.NEXT_UNREVIEWED_FILE, _ => this.handleNextUnreviewedFile()); |
| listen(Shortcut.TOGGLE_BLAME, _ => this.toggleBlame()); |
| listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ => |
| this.handleToggleHideAllCommentThreads() |
| ); |
| 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().diffLeftSide$, |
| leftSide => (this.leftSide = leftSide) |
| ); |
| subscribe( |
| this, |
| () => this.getViewModel().patchNum$, |
| patchNum => (this.patchNum = patchNum) |
| ); |
| subscribe( |
| this, |
| () => this.getViewModel().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, |
| css` |
| :host { |
| display: block; |
| background-color: var(--view-background-color); |
| --sidebar-width: 300px; |
| } |
| .hidden { |
| display: none; |
| } |
| gr-patch-range-select { |
| display: block; |
| } |
| gr-diff { |
| border: none; |
| } |
| .stickyHeader { |
| background-color: var(--view-background-color); |
| position: sticky; |
| top: 0; |
| /* 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-right: var(--spacing-m); |
| font-weight: var(--font-weight-bold); |
| } |
| .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 { |
| display: inline-block; |
| margin: 0 var(--spacing-xs); |
| vertical-align: top; |
| position: relative; |
| top: 8px; |
| } |
| .jumpToFileContainer { |
| display: inline-block; |
| word-break: break-all; |
| } |
| .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; |
| } |
| .blameLoader, |
| .fileNum { |
| display: none; |
| } |
| .blameLoader.show, |
| .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; |
| } |
| @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; |
| } |
| .reviewed { |
| vertical-align: -1px; |
| } |
| .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); |
| } |
| .jumpToFileContainer { |
| display: block; |
| width: 100%; |
| word-break: break-all; |
| } |
| /* prettier formatter removes semi-colons after css mixins. */ |
| /* prettier-ignore */ |
| gr-dropdown-list { |
| width: 100%; |
| --gr-select-style-width: 100%; |
| --gr-select-style-display: block; |
| --native-select-style-width: 100%; |
| } |
| } |
| :host(.hideComments) { |
| --gr-comment-thread-display: none; |
| } |
| .diffContainer.sidebarOpen { |
| margin-left: var(--sidebar-width); |
| } |
| .sidebarTriggerContainer { |
| display: inline-block; |
| margin-right: var(--spacing-m); |
| } |
| .sidebarAnchor { |
| height: 0; |
| width: 0; |
| overflow: visible; |
| } |
| .sidebarContents { |
| background: var(--background-color-secondary); |
| width: var(--sidebar-width); |
| border: var(--spacing-xxs) solid var(--border-color); |
| border-left: 0; |
| overflow: auto; |
| } |
| `, |
| ]; |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| this.throttledToggleFileReviewed = throttleWrap(_ => |
| this.handleToggleFileReviewed() |
| ); |
| this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e)); |
| this.cursor = new GrDiffCursor(); |
| if (this.diffHost) this.reInitCursor(); |
| window.addEventListener('scroll', this.updateSidebarHeight); |
| window.addEventListener('resize', this.updateSidebarHeight); |
| 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(); |
| window.removeEventListener('scroll', this.updateSidebarHeight); |
| window.removeEventListener('resize', this.updateSidebarHeight); |
| super.disconnectedCallback(); |
| } |
| |
| private reInitCursor() { |
| if (!this.diffHost) return; |
| this.cursor?.replaceDiffs([this.diffHost]); |
| this.cursor?.reInitCursor(); |
| } |
| |
| private readonly updateSidebarHeight = () => { |
| if (this.sidebarAnchor) { |
| this.sidebarHeight = |
| window.innerHeight - this.sidebarAnchor.getBoundingClientRect().bottom; |
| } |
| }; |
| |
| 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('leftSide') |
| ) { |
| this.initCursor(); |
| } |
| if ( |
| changedProperties.has('change') || |
| changedProperties.has('changeComments') || |
| changedProperties.has('path') || |
| changedProperties.has('patchNum') || |
| changedProperties.has('basePatchNum') || |
| changedProperties.has('files') |
| ) { |
| if (this.change && this.changeComments && this.path && this.patchRange) { |
| assertIsDefined(this.diffHost, 'diffHost'); |
| const file = this.files?.changeFilesByPath?.[this.path]; |
| 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 |
| ); |
| }); |
| } |
| } |
| 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 |
| ); |
| } |
| this.updateSidebarHeight(); |
| } |
| |
| 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> |
| <div class="diffContainer ${this.shownSidebar && 'sidebarOpen'}"> |
| <gr-diff-host |
| id="diffHost" |
| .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> |
| </div> |
| ${this.renderDialogs()} |
| `; |
| } |
| |
| private renderStickyHeader() { |
| return html` <div |
| class="stickyHeader ${this.patchNum === EDIT ? 'editMode' : ''}" |
| > |
| <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> |
| ${this.renderSidebarContent()} |
| </div>`; |
| } |
| |
| private renderHeader() { |
| const formattedFiles = this.formatFilesForDropdown(); |
| const fileNum = this.computeFileNum(formattedFiles); |
| const fileNumClass = this.computeFileNumClass(fileNum, formattedFiles); |
| return html` <div> |
| <a href=${ifDefined(this.getChangeModel().changeUrl())} |
| >${this.changeNum}</a |
| ><span class="changeNumberColon">:</span> |
| <span class="headerSubject">${this.change?.subject}</span> |
| <input |
| id="reviewed" |
| class="reviewed hideOnEdit" |
| type="checkbox" |
| ?hidden=${!this.loggedIn} |
| title="Toggle reviewed status of file" |
| aria-label="file reviewed" |
| .checked=${this.reviewed} |
| @change=${this.handleReviewedChange} |
| /> |
| <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 renderSidebarContent() { |
| // Always renders the 0x0px .sidebarAnchor div for scroll measurements. |
| return html` |
| <div class="sidebarAnchor"> |
| ${when( |
| this.shownSidebar !== undefined, |
| () => html` |
| <div |
| class="sidebarContents" |
| style=${styleMap({height: `${this.sidebarHeight}px`})} |
| > |
| <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> |
| ` |
| )} |
| </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"> |
| ${this.renderSidebarTriggers()} ${this.renderBlameButton()} |
| ${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 for="annotation-checkbox" id="annotation-label"></label> |
| <iron-input> |
| <input |
| is="iron-input" |
| type="checkbox" |
| id="annotation-checkbox" |
| disabled="" |
| /> |
| </iron-input> |
| </span> |
| </gr-endpoint-decorator> |
| </div>`; |
| } |
| |
| private renderBlameButton() { |
| if (!this.allowBlame) return; |
| const blameLoaderClass = |
| !isMagicPath(this.path) && !isImageDiff(this.diff) ? 'show' : ''; |
| let blameToggleLabel = 'Loading blame ...'; |
| if (!this.isBlameLoading) { |
| blameToggleLabel = this.isBlameLoaded ? 'Hide blame' : 'Show blame'; |
| } |
| return html` <span class="blameLoader ${blameLoaderClass}"> |
| <gr-button |
| link="" |
| id="toggleBlame" |
| title=${this.createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)} |
| ?disabled=${this.isBlameLoading} |
| @click=${this.toggleBlame} |
| >${blameToggleLabel}</gr-button |
| > |
| </span>`; |
| } |
| |
| 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 HTMLInputElement; |
| 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.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()); |
| fireAlert( |
| this, |
| `Press ${shortcut} again to navigate to ${direction} unreviewed file` |
| ); |
| } |
| } |
| |
| 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 handleOpenDownloadDialog() { |
| assertIsDefined(this.downloadModal, 'downloadModal'); |
| this.downloadModal.showModal(); |
| whenVisible(this.downloadModal, () => { |
| assertIsDefined(this.downloadModal, 'downloadModal'); |
| assertIsDefined(this.downloadDialog, 'downloadDialog'); |
| this.downloadDialog.focus(); |
| const downloadCommands = queryAndAssert( |
| this.downloadDialog, |
| 'gr-download-commands' |
| ); |
| const paperTabs = queryAndAssert<PaperTabsElement>( |
| downloadCommands, |
| 'paper-tabs' |
| ); |
| // Paper Tabs normally listen to 'iron-resize' event to call this method. |
| // After migrating to Dialog element, this event is no longer fired |
| // which means this method is not called which ends up styling the |
| // selected paper tab with an underline. |
| paperTabs._onTabSizingChanged(); |
| }); |
| } |
| |
| 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) { |
| if (!this.path || !this.patchNum) return; |
| const url = this.getViewModel().diffUrl({ |
| diffView: { |
| path: this.path, |
| lineNum, |
| leftSide, |
| }, |
| 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(); |
| } |
| |
| /** |
| * (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.reInitCursor(); |
| this.diffHost?.initLayers(); |
| this.classList.remove('hideComments'); |
| } |
| |
| /** |
| * 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; |
| } |
| |
| // 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 handleToggleHideAllCommentThreads() { |
| this.classList.toggle('hideComments'); |
| } |
| |
| 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.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; |
| } |
| } |