| /** |
| * @license |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| import '../../../styles/shared-styles'; |
| import '../../diff/gr-diff-cursor/gr-diff-cursor'; |
| import '../../diff/gr-diff-host/gr-diff-host'; |
| import '../../diff/gr-comment-api/gr-comment-api'; |
| import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog'; |
| import '../../edit/gr-edit-file-controls/gr-edit-file-controls'; |
| import '../../shared/gr-button/gr-button'; |
| import '../../shared/gr-cursor-manager/gr-cursor-manager'; |
| import '../../shared/gr-icons/gr-icons'; |
| import '../../shared/gr-linked-text/gr-linked-text'; |
| import '../../shared/gr-select/gr-select'; |
| import '../../shared/gr-tooltip-content/gr-tooltip-content'; |
| import '../../shared/gr-copy-clipboard/gr-copy-clipboard'; |
| import '../../shared/gr-file-status-chip/gr-file-status-chip'; |
| import {flush} from '@polymer/polymer/lib/legacy/polymer.dom'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-file-list_html'; |
| import {asyncForeach, debounce, DelayedTask} from '../../../utils/async-util'; |
| import { |
| KeyboardShortcutMixin, |
| Shortcut, |
| } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin'; |
| import {FilesExpandedState} from '../gr-file-list-constants'; |
| import {pluralize} from '../../../utils/string-util'; |
| import {GerritNav} from '../../core/gr-navigation/gr-navigation'; |
| import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints'; |
| import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader'; |
| import {appContext} from '../../../services/app-context'; |
| import { |
| DiffViewMode, |
| ScrollMode, |
| SpecialFilePath, |
| } from '../../../constants/constants'; |
| import { |
| descendedFromClass, |
| getKeyboardEvent, |
| isShiftPressed, |
| toggleClass, |
| } from '../../../utils/dom-util'; |
| import { |
| addUnmodifiedFiles, |
| computeDisplayPath, |
| computeTruncatedPath, |
| isMagicPath, |
| specialFilePathCompare, |
| } from '../../../utils/path-list-util'; |
| import {customElement, observe, property} from '@polymer/decorators'; |
| import { |
| BasePatchSetNum, |
| EditPatchSetNum, |
| ElementPropertyDeepChange, |
| FileInfo, |
| FileNameToFileInfoMap, |
| NumericChangeId, |
| PatchRange, |
| } from '../../../types/common'; |
| import {DiffPreferencesInfo} from '../../../types/diff'; |
| import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host'; |
| import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog'; |
| import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor'; |
| import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager'; |
| import {PolymerSpliceChange} from '@polymer/polymer/interfaces'; |
| import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api'; |
| import {CustomKeyboardEvent} from '../../../types/events'; |
| import {ParsedChangeInfo, PatchSetFile} from '../../../types/types'; |
| import {Timing} from '../../../constants/reporting'; |
| import {RevisionInfo} from '../../shared/revision-info/revision-info'; |
| import {preferences$} from '../../../services/user/user-model'; |
| import {changeComments$} from '../../../services/comments/comments-model'; |
| import {Subject} from 'rxjs'; |
| import {takeUntil} from 'rxjs/operators'; |
| |
| export const DEFAULT_NUM_FILES_SHOWN = 200; |
| |
| const WARN_SHOW_ALL_THRESHOLD = 1000; |
| const LOADING_DEBOUNCE_INTERVAL = 100; |
| |
| const SIZE_BAR_MAX_WIDTH = 61; |
| const SIZE_BAR_GAP_WIDTH = 1; |
| const SIZE_BAR_MIN_WIDTH = 1.5; |
| |
| const FILE_ROW_CLASS = 'file-row'; |
| |
| export interface GrFileList { |
| $: { |
| diffPreferencesDialog: GrDiffPreferencesDialog; |
| }; |
| } |
| |
| interface ReviewedFileInfo extends FileInfo { |
| isReviewed?: boolean; |
| } |
| export interface NormalizedFileInfo extends ReviewedFileInfo { |
| __path: string; |
| } |
| |
| interface PatchChange { |
| inserted: number; |
| deleted: number; |
| size_delta_inserted: number; |
| size_delta_deleted: number; |
| total_size: number; |
| } |
| |
| function createDefaultPatchChange(): PatchChange { |
| // Use function instead of const to prevent unexpected changes in the default |
| // values. |
| return { |
| inserted: 0, |
| deleted: 0, |
| size_delta_inserted: 0, |
| size_delta_deleted: 0, |
| total_size: 0, |
| }; |
| } |
| |
| interface SizeBarLayout { |
| maxInserted: number; |
| maxDeleted: number; |
| maxAdditionWidth: number; |
| maxDeletionWidth: number; |
| deletionOffset: number; |
| } |
| |
| function createDefaultSizeBarLayout(): SizeBarLayout { |
| // Use function instead of const to prevent unexpected changes in the default |
| // values. |
| return { |
| maxInserted: 0, |
| maxDeleted: 0, |
| maxAdditionWidth: 0, |
| maxDeletionWidth: 0, |
| deletionOffset: 0, |
| }; |
| } |
| |
| interface FileRow { |
| file: PatchSetFile; |
| element: HTMLElement; |
| } |
| |
| export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo}; |
| |
| /** |
| * Type for FileInfo |
| * |
| * This should match with the type returned from `files` API plus |
| * additional info like `__path`. |
| * |
| * @typedef {Object} FileInfo |
| * @property {string} __path |
| * @property {?string} old_path |
| * @property {number} size |
| * @property {number} size_delta - fallback to 0 if not present in api |
| * @property {number} lines_deleted - fallback to 0 if not present in api |
| * @property {number} lines_inserted - fallback to 0 if not present in api |
| */ |
| |
| // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error. |
| const base = KeyboardShortcutMixin(PolymerElement); |
| |
| @customElement('gr-file-list') |
| export class GrFileList extends base { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| @property({type: Object}) |
| patchRange?: PatchRange; |
| |
| @property({type: String}) |
| patchNum?: string; |
| |
| @property({type: Number}) |
| changeNum?: NumericChangeId; |
| |
| @property({type: Object}) |
| changeComments?: ChangeComments; |
| |
| @property({type: Number, notify: true}) |
| selectedIndex = -1; |
| |
| @property({type: Object}) |
| keyEventTarget = document.body; |
| |
| @property({type: Object}) |
| change?: ParsedChangeInfo; |
| |
| @property({type: String, notify: true, observer: '_updateDiffPreferences'}) |
| diffViewMode?: DiffViewMode; |
| |
| @property({type: Boolean, observer: '_editModeChanged'}) |
| editMode?: boolean; |
| |
| @property({type: String, notify: true}) |
| filesExpanded = FilesExpandedState.NONE; |
| |
| @property({type: Object}) |
| _filesByPath?: FileNameToFileInfoMap; |
| |
| @property({type: Array, observer: '_filesChanged'}) |
| _files: NormalizedFileInfo[] = []; |
| |
| @property({type: Boolean}) |
| _loggedIn = false; |
| |
| @property({type: Array}) |
| _reviewed?: string[] = []; |
| |
| @property({type: Object, notify: true, observer: '_updateDiffPreferences'}) |
| diffPrefs?: DiffPreferencesInfo; |
| |
| @property({type: Boolean}) |
| _showInlineDiffs?: boolean; |
| |
| @property({type: Number, notify: true}) |
| numFilesShown: number = DEFAULT_NUM_FILES_SHOWN; |
| |
| @property({type: Object, computed: '_calculatePatchChange(_files)'}) |
| _patchChange: PatchChange = createDefaultPatchChange(); |
| |
| @property({type: Number}) |
| fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN; |
| |
| @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'}) |
| _hideChangeTotals = true; |
| |
| @property({ |
| type: Boolean, |
| computed: '_shouldHideBinaryChangeTotals(_patchChange)', |
| }) |
| _hideBinaryChangeTotals = true; |
| |
| @property({ |
| type: Array, |
| computed: '_computeFilesShown(numFilesShown, _files)', |
| }) |
| _shownFiles: NormalizedFileInfo[] = []; |
| |
| @property({type: Number}) |
| _reportinShownFilesIncrement = 0; |
| |
| @property({type: Array}) |
| _expandedFiles: PatchSetFile[] = []; |
| |
| @property({type: Boolean}) |
| _displayLine?: boolean; |
| |
| @property({type: Boolean, observer: '_loadingChanged'}) |
| _loading?: boolean; |
| |
| @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'}) |
| _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout(); |
| |
| @property({type: Boolean}) |
| _showSizeBars = true; |
| |
| // For merge commits vs Auto Merge, an extra file row is shown detailing the |
| // files that were merged without conflict. These files are also passed to any |
| // plugins. |
| @property({type: Array}) |
| _cleanlyMergedPaths: string[] = []; |
| |
| @property({type: Array}) |
| _cleanlyMergedOldPaths: string[] = []; |
| |
| private _cancelForEachDiff?: () => void; |
| |
| loadingTask?: DelayedTask; |
| |
| @property({ |
| type: Boolean, |
| computed: |
| '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' + |
| '_dynamicContentEndpoints, _dynamicSummaryEndpoints)', |
| }) |
| _showDynamicColumns = false; |
| |
| @property({ |
| type: Boolean, |
| computed: |
| '_computeShowPrependedDynamicColumns(' + |
| '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)', |
| }) |
| _showPrependedDynamicColumns = false; |
| |
| @property({type: Array}) |
| _dynamicHeaderEndpoints?: string[]; |
| |
| @property({type: Array}) |
| _dynamicContentEndpoints?: string[]; |
| |
| @property({type: Array}) |
| _dynamicSummaryEndpoints?: string[]; |
| |
| @property({type: Array}) |
| _dynamicPrependedHeaderEndpoints?: string[]; |
| |
| @property({type: Array}) |
| _dynamicPrependedContentEndpoints?: string[]; |
| |
| private readonly reporting = appContext.reportingService; |
| |
| private readonly restApiService = appContext.restApiService; |
| |
| disconnected$ = new Subject(); |
| |
| get keyBindings() { |
| return { |
| esc: '_handleEscKey', |
| }; |
| } |
| |
| override keyboardShortcuts() { |
| return { |
| [Shortcut.LEFT_PANE]: '_handleLeftPane', |
| [Shortcut.RIGHT_PANE]: '_handleRightPane', |
| [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff', |
| [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs', |
| [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]: |
| '_handleToggleHideAllCommentThreads', |
| [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext', |
| [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev', |
| [Shortcut.NEXT_LINE]: '_handleCursorNext', |
| [Shortcut.PREV_LINE]: '_handleCursorPrev', |
| [Shortcut.NEW_COMMENT]: '_handleNewComment', |
| [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile', |
| [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile', |
| [Shortcut.OPEN_FILE]: '_handleOpenFile', |
| [Shortcut.NEXT_CHUNK]: '_handleNextChunk', |
| [Shortcut.PREV_CHUNK]: '_handlePrevChunk', |
| [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed', |
| [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane', |
| |
| // Final two are actually handled by gr-comment-thread. |
| [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null, |
| [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null, |
| }; |
| } |
| |
| private fileCursor = new GrCursorManager(); |
| |
| private diffCursor = new GrDiffCursor(); |
| |
| constructor() { |
| super(); |
| this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE; |
| this.fileCursor.cursorTargetClass = 'selected'; |
| this.fileCursor.focusOnMove = true; |
| this.addEventListener('keydown', e => |
| this._scopedKeydownHandler(e as unknown as CustomKeyboardEvent) |
| ); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| changeComments$ |
| .pipe(takeUntil(this.disconnected$)) |
| .subscribe(changeComments => { |
| this.changeComments = changeComments; |
| }); |
| getPluginLoader() |
| .awaitPluginsLoaded() |
| .then(() => { |
| this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints( |
| 'change-view-file-list-header' |
| ); |
| this._dynamicContentEndpoints = |
| getPluginEndpoints().getDynamicEndpoints( |
| 'change-view-file-list-content' |
| ); |
| this._dynamicPrependedHeaderEndpoints = |
| getPluginEndpoints().getDynamicEndpoints( |
| 'change-view-file-list-header-prepend' |
| ); |
| this._dynamicPrependedContentEndpoints = |
| getPluginEndpoints().getDynamicEndpoints( |
| 'change-view-file-list-content-prepend' |
| ); |
| this._dynamicSummaryEndpoints = |
| getPluginEndpoints().getDynamicEndpoints( |
| 'change-view-file-list-summary' |
| ); |
| |
| if ( |
| this._dynamicHeaderEndpoints.length !== |
| this._dynamicContentEndpoints.length |
| ) { |
| this.reporting.error(new Error('dynamic header/content mismatch')); |
| } |
| if ( |
| this._dynamicPrependedHeaderEndpoints.length !== |
| this._dynamicPrependedContentEndpoints.length |
| ) { |
| this.reporting.error(new Error('dynamic header/content mismatch')); |
| } |
| if ( |
| this._dynamicHeaderEndpoints.length !== |
| this._dynamicSummaryEndpoints.length |
| ) { |
| this.reporting.error(new Error('dynamic header/content mismatch')); |
| } |
| }); |
| } |
| |
| override disconnectedCallback() { |
| this.disconnected$.next(); |
| this.diffCursor.dispose(); |
| this.fileCursor.unsetCursor(); |
| this._cancelDiffs(); |
| this.loadingTask?.cancel(); |
| super.disconnectedCallback(); |
| } |
| |
| /** |
| * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard |
| * events must be scoped to a component level (e.g. `enter`) in order to not |
| * override native browser functionality. |
| * |
| * Context: Issue 7277 |
| */ |
| _scopedKeydownHandler(e: CustomKeyboardEvent) { |
| if (e.keyCode === 13) { |
| // TODO(TS): e is not an instance of CustomKeyboardEvent. |
| // However, to fix it we should fix keyboard-shortcut-mixin first |
| // The keyboard-shortcut-mixin will be updated in a separate change |
| this._handleOpenFile(e as unknown as CustomKeyboardEvent); |
| } |
| } |
| |
| reload() { |
| if (!this.changeNum || !this.patchRange?.patchNum) { |
| return Promise.resolve(); |
| } |
| const changeNum = this.changeNum; |
| const patchRange = this.patchRange; |
| |
| this._loading = true; |
| |
| this.collapseAllDiffs(); |
| const promises = []; |
| |
| promises.push( |
| this.restApiService |
| .getChangeOrEditFiles(changeNum, patchRange) |
| .then(filesByPath => { |
| this._filesByPath = filesByPath; |
| }) |
| ); |
| |
| promises.push( |
| this._getLoggedIn() |
| .then(loggedIn => (this._loggedIn = loggedIn)) |
| .then(loggedIn => { |
| if (!loggedIn) { |
| return; |
| } |
| |
| return this._getReviewedFiles(changeNum, patchRange).then( |
| reviewed => { |
| this._reviewed = reviewed; |
| } |
| ); |
| }) |
| ); |
| |
| promises.push( |
| this._getDiffPreferences().then(prefs => { |
| this.diffPrefs = prefs; |
| }) |
| ); |
| |
| preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => { |
| this._showSizeBars = !!prefs?.size_bar_in_change_table; |
| }); |
| |
| return Promise.all(promises).then(() => { |
| this._loading = false; |
| this._detectChromiteButler(); |
| this.reporting.fileListDisplayed(); |
| }); |
| } |
| |
| @observe('_filesByPath') |
| async _updateCleanlyMergedPaths(filesByPath?: FileNameToFileInfoMap) { |
| // When viewing Auto Merge base vs a patchset, add an additional row that |
| // knows how many files were cleanly merged. This requires an additional RPC |
| // for the diffs between target parent and the patch set. The cleanly merged |
| // files are all the files in the target RPC that weren't in the Auto Merge |
| // RPC. |
| if ( |
| this.change && |
| this.changeNum && |
| this.patchRange?.patchNum && |
| new RevisionInfo(this.change).isMergeCommit(this.patchRange.patchNum) && |
| this.patchRange.basePatchNum === 'PARENT' && |
| this.patchRange.patchNum !== EditPatchSetNum |
| ) { |
| const allFilesByPath = await this.restApiService.getChangeOrEditFiles( |
| this.changeNum, |
| { |
| basePatchNum: -1 as BasePatchSetNum, // -1 is first (target) parent |
| patchNum: this.patchRange.patchNum, |
| } |
| ); |
| if (!allFilesByPath || !filesByPath) return; |
| const conflictingPaths = Object.keys(filesByPath); |
| this._cleanlyMergedPaths = Object.keys(allFilesByPath).filter( |
| path => !conflictingPaths.includes(path) |
| ); |
| this._cleanlyMergedOldPaths = this._cleanlyMergedPaths |
| .map(path => allFilesByPath[path].old_path) |
| .filter((oldPath): oldPath is string => !!oldPath); |
| } else { |
| this._cleanlyMergedPaths = []; |
| this._cleanlyMergedOldPaths = []; |
| } |
| } |
| |
| _detectChromiteButler() { |
| const hasButler = !!document.getElementById('butler-suggested-owners'); |
| if (hasButler) { |
| this.reporting.reportExtension('butler'); |
| } |
| } |
| |
| get diffs(): GrDiffHost[] { |
| const diffs = this.root!.querySelectorAll('gr-diff-host'); |
| // It is possible that a bogus diff element is hanging around invisibly |
| // from earlier with a different patch set choice and associated with a |
| // different entry in the files array. So filter on visible items only. |
| return Array.from(diffs).filter( |
| el => !!el && !!el.style && el.style.display !== 'none' |
| ); |
| } |
| |
| openDiffPrefs() { |
| this.$.diffPreferencesDialog.open(); |
| } |
| |
| _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange { |
| const magicFilesExcluded = files.filter( |
| files => !isMagicPath(files.__path) |
| ); |
| |
| return magicFilesExcluded.reduce((acc, obj) => { |
| const inserted = obj.lines_inserted ? obj.lines_inserted : 0; |
| const deleted = obj.lines_deleted ? obj.lines_deleted : 0; |
| const total_size = obj.size && obj.binary ? obj.size : 0; |
| const size_delta_inserted = |
| obj.binary && obj.size_delta > 0 ? obj.size_delta : 0; |
| const size_delta_deleted = |
| obj.binary && obj.size_delta < 0 ? obj.size_delta : 0; |
| |
| return { |
| inserted: acc.inserted + inserted, |
| deleted: acc.deleted + deleted, |
| size_delta_inserted: acc.size_delta_inserted + size_delta_inserted, |
| size_delta_deleted: acc.size_delta_deleted + size_delta_deleted, |
| total_size: acc.total_size + total_size, |
| }; |
| }, createDefaultPatchChange()); |
| } |
| |
| _getDiffPreferences() { |
| return this.restApiService.getDiffPreferences(); |
| } |
| |
| _getPreferences() { |
| return this.restApiService.getPreferences(); |
| } |
| |
| private _toggleFileExpanded(file: PatchSetFile) { |
| // Is the path in the list of expanded diffs? If so, remove it, otherwise |
| // add it to the list. |
| const indexInExpanded = this._expandedFiles.findIndex( |
| f => f.path === file.path |
| ); |
| if (indexInExpanded === -1) { |
| this.push('_expandedFiles', file); |
| } else { |
| this.splice('_expandedFiles', indexInExpanded, 1); |
| } |
| const indexInAll = this._files.findIndex(f => f.__path === file.path); |
| this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)[ |
| indexInAll |
| ].scrollIntoView({block: 'nearest'}); |
| } |
| |
| _toggleFileExpandedByIndex(index: number) { |
| this._toggleFileExpanded(this._computePatchSetFile(this._files[index])); |
| } |
| |
| _updateDiffPreferences() { |
| if (!this.diffs.length) { |
| return; |
| } |
| // Re-render all expanded diffs sequentially. |
| this.reporting.time(Timing.FILE_EXPAND_ALL); |
| this._renderInOrder( |
| this._expandedFiles, |
| this.diffs, |
| this._expandedFiles.length |
| ); |
| } |
| |
| _forEachDiff(fn: (host: GrDiffHost) => void) { |
| const diffs = this.diffs; |
| for (let i = 0; i < diffs.length; i++) { |
| fn(diffs[i]); |
| } |
| } |
| |
| expandAllDiffs() { |
| this._showInlineDiffs = true; |
| |
| // Find the list of paths that are in the file list, but not in the |
| // expanded list. |
| const newFiles: PatchSetFile[] = []; |
| let path: string; |
| for (let i = 0; i < this._shownFiles.length; i++) { |
| path = this._shownFiles[i].__path; |
| if (!this._expandedFiles.some(f => f.path === path)) { |
| newFiles.push(this._computePatchSetFile(this._shownFiles[i])); |
| } |
| } |
| |
| this.splice('_expandedFiles', 0, 0, ...newFiles); |
| } |
| |
| collapseAllDiffs() { |
| this._showInlineDiffs = false; |
| this._expandedFiles = []; |
| this.filesExpanded = this._computeExpandedFiles( |
| this._expandedFiles.length, |
| this._files.length |
| ); |
| this.diffCursor.handleDiffUpdate(); |
| } |
| |
| /** |
| * Computes a string with the number of comments and unresolved comments. |
| */ |
| _computeCommentsString( |
| changeComments?: ChangeComments, |
| patchRange?: PatchRange, |
| file?: NormalizedFileInfo |
| ) { |
| if ( |
| changeComments === undefined || |
| patchRange === undefined || |
| file?.__path === undefined |
| ) { |
| return ''; |
| } |
| return changeComments.computeCommentsString(patchRange, file.__path, file); |
| } |
| |
| /** |
| * Computes a string with the number of drafts. |
| */ |
| _computeDraftsString( |
| changeComments?: ChangeComments, |
| patchRange?: PatchRange, |
| file?: NormalizedFileInfo |
| ) { |
| if (changeComments === undefined) return ''; |
| const draftCount = changeComments.computeDraftCountForFile( |
| patchRange, |
| file |
| ); |
| if (draftCount === 0) return ''; |
| return pluralize(Number(draftCount), 'draft'); |
| } |
| |
| /** |
| * Computes a shortened string with the number of drafts. |
| */ |
| _computeDraftsStringMobile( |
| changeComments?: ChangeComments, |
| patchRange?: PatchRange, |
| file?: NormalizedFileInfo |
| ) { |
| if (changeComments === undefined) return ''; |
| const draftCount = changeComments.computeDraftCountForFile( |
| patchRange, |
| file |
| ); |
| return draftCount === 0 ? '' : `${draftCount}d`; |
| } |
| |
| /** |
| * Computes a shortened string with the number of comments. |
| */ |
| _computeCommentsStringMobile( |
| changeComments?: ChangeComments, |
| patchRange?: PatchRange, |
| file?: NormalizedFileInfo |
| ) { |
| if ( |
| changeComments === undefined || |
| patchRange === undefined || |
| file === undefined |
| ) { |
| return ''; |
| } |
| const commentThreadCount = |
| changeComments.computeCommentThreadCount({ |
| patchNum: patchRange.basePatchNum, |
| path: file.__path, |
| }) + |
| changeComments.computeCommentThreadCount({ |
| patchNum: patchRange.patchNum, |
| path: file.__path, |
| }); |
| return commentThreadCount === 0 ? '' : `${commentThreadCount}c`; |
| } |
| |
| private _reviewFile(path: string, reviewed?: boolean) { |
| if (this.editMode) { |
| return Promise.resolve(); |
| } |
| const index = this._files.findIndex(file => file.__path === path); |
| reviewed = reviewed || !this._files[index].isReviewed; |
| |
| this.set(['_files', index, 'isReviewed'], reviewed); |
| if (index < this._shownFiles.length) { |
| this.notifyPath(`_shownFiles.${index}.isReviewed`); |
| } |
| |
| return this._saveReviewedState(path, reviewed); |
| } |
| |
| _saveReviewedState(path: string, reviewed: boolean) { |
| if (!this.changeNum || !this.patchRange) { |
| throw new Error('changeNum and patchRange must be set'); |
| } |
| |
| return this.restApiService.saveFileReviewed( |
| this.changeNum, |
| this.patchRange.patchNum, |
| path, |
| reviewed |
| ); |
| } |
| |
| _getLoggedIn() { |
| return this.restApiService.getLoggedIn(); |
| } |
| |
| _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) { |
| if (this.editMode) { |
| return Promise.resolve([]); |
| } |
| return this.restApiService.getReviewedFiles(changeNum, patchRange.patchNum); |
| } |
| |
| _normalizeChangeFilesResponse( |
| response: FileNameToReviewedFileInfoMap |
| ): NormalizedFileInfo[] { |
| const paths = Object.keys(response).sort(specialFilePathCompare); |
| const files: NormalizedFileInfo[] = []; |
| for (let i = 0; i < paths.length; i++) { |
| // TODO(TS): make copy instead of as NormalizedFileInfo |
| const info = response[paths[i]] as NormalizedFileInfo; |
| info.__path = paths[i]; |
| info.lines_inserted = info.lines_inserted || 0; |
| info.lines_deleted = info.lines_deleted || 0; |
| info.size_delta = info.size_delta || 0; |
| files.push(info); |
| } |
| return files; |
| } |
| |
| /** |
| * Returns true if the event e is a click on an element. |
| * |
| * The click is: mouse click or pressing Enter or Space key |
| * P.S> Screen readers sends click event as well |
| */ |
| _isClickEvent(e: MouseEvent | KeyboardEvent) { |
| if (e.type === 'click') { |
| return true; |
| } |
| const ke = e as KeyboardEvent; |
| const isSpaceOrEnter = ke.key === 'Enter' || ke.key === ' '; |
| return ke.type === 'keydown' && isSpaceOrEnter; |
| } |
| |
| _fileActionClick( |
| e: MouseEvent | KeyboardEvent, |
| fileAction: (file: PatchSetFile) => void |
| ) { |
| if (this._isClickEvent(e)) { |
| const fileRow = this._getFileRowFromEvent(e); |
| if (!fileRow) { |
| return; |
| } |
| // Prevent default actions (e.g. scrolling for space key) |
| e.preventDefault(); |
| // Prevent _handleFileListClick handler call |
| e.stopPropagation(); |
| this.fileCursor.setCursor(fileRow.element); |
| fileAction(fileRow.file); |
| } |
| } |
| |
| _reviewedClick(e: MouseEvent | KeyboardEvent) { |
| this._fileActionClick(e, file => this._reviewFile(file.path)); |
| } |
| |
| _expandedClick(e: MouseEvent | KeyboardEvent) { |
| this._fileActionClick(e, file => this._toggleFileExpanded(file)); |
| } |
| |
| /** |
| * Handle all events from the file list dom-repeat so event handlers don't |
| * have to get registered for potentially very long lists. |
| */ |
| _handleFileListClick(e: MouseEvent) { |
| if (!e.target) { |
| return; |
| } |
| const fileRow = this._getFileRowFromEvent(e); |
| if (!fileRow) { |
| return; |
| } |
| const file = fileRow.file; |
| const path = file.path; |
| // If a path cannot be interpreted from the click target (meaning it's not |
| // somewhere in the row, e.g. diff content) or if the user clicked the |
| // link, defer to the native behavior. |
| if (!path || descendedFromClass(e.target as Element, 'pathLink')) { |
| return; |
| } |
| |
| // Disregard the event if the click target is in the edit controls. |
| if (descendedFromClass(e.target as Element, 'editFileControls')) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this.fileCursor.setCursor(fileRow.element); |
| this._toggleFileExpanded(file); |
| } |
| |
| _getFileRowFromEvent(e: Event): FileRow | null { |
| // Traverse upwards to find the row element if the target is not the row. |
| let row = e.target as HTMLElement; |
| while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) { |
| row = row.parentElement; |
| } |
| |
| // No action needed for item without a valid file |
| if (!row.dataset['file']) { |
| return null; |
| } |
| |
| return { |
| file: JSON.parse(row.dataset['file']) as PatchSetFile, |
| element: row, |
| }; |
| } |
| |
| /** |
| * Generates file range from file info object. |
| */ |
| _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile { |
| const fileData: PatchSetFile = { |
| path: file.__path, |
| }; |
| if (file.old_path) { |
| fileData.basePath = file.old_path; |
| } |
| return fileData; |
| } |
| |
| _handleLeftPane(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this.diffCursor.moveLeft(); |
| } |
| |
| _handleRightPane(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this.diffCursor.moveRight(); |
| } |
| |
| _handleToggleInlineDiff(e: CustomKeyboardEvent) { |
| if ( |
| this.shouldSuppressKeyboardShortcut(e) || |
| this.modifierPressed(e) || |
| e.detail?.keyboardEvent?.repeat || |
| this.fileCursor.index === -1 |
| ) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this._toggleFileExpandedByIndex(this.fileCursor.index); |
| } |
| |
| _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) { |
| if ( |
| this.shouldSuppressKeyboardShortcut(e) || |
| e.detail?.keyboardEvent?.repeat |
| ) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this._toggleInlineDiffs(); |
| } |
| |
| _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| toggleClass(this, 'hideComments'); |
| } |
| |
| _handleCursorNext(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| if (this._showInlineDiffs) { |
| e.preventDefault(); |
| this.diffCursor.moveDown(); |
| this._displayLine = true; |
| } else { |
| // Down key |
| if (getKeyboardEvent(e).keyCode === 40) { |
| return; |
| } |
| e.preventDefault(); |
| this.fileCursor.next({circular: true}); |
| this.selectedIndex = this.fileCursor.index; |
| } |
| } |
| |
| _handleCursorPrev(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| if (this._showInlineDiffs) { |
| e.preventDefault(); |
| this.diffCursor.moveUp(); |
| this._displayLine = true; |
| } else { |
| // Up key |
| if (getKeyboardEvent(e).keyCode === 38) { |
| return; |
| } |
| e.preventDefault(); |
| this.fileCursor.previous({circular: true}); |
| this.selectedIndex = this.fileCursor.index; |
| } |
| } |
| |
| _handleNewComment(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { |
| return; |
| } |
| e.preventDefault(); |
| this.classList.remove('hideComments'); |
| this.diffCursor.createCommentInPlace(); |
| } |
| |
| _handleOpenLastFile(e: CustomKeyboardEvent) { |
| // Check for meta key to avoid overriding native chrome shortcut. |
| if (this.shouldSuppressKeyboardShortcut(e) || getKeyboardEvent(e).metaKey) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this._openSelectedFile(this._files.length - 1); |
| } |
| |
| _handleOpenFirstFile(e: CustomKeyboardEvent) { |
| // Check for meta key to avoid overriding native chrome shortcut. |
| if (this.shouldSuppressKeyboardShortcut(e) || getKeyboardEvent(e).metaKey) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this._openSelectedFile(0); |
| } |
| |
| _handleOpenFile(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { |
| return; |
| } |
| e.preventDefault(); |
| |
| if (this._showInlineDiffs) { |
| this._openCursorFile(); |
| return; |
| } |
| |
| this._openSelectedFile(); |
| } |
| |
| _handleNextChunk(e: CustomKeyboardEvent) { |
| if ( |
| this.shouldSuppressKeyboardShortcut(e) || |
| (this.modifierPressed(e) && !isShiftPressed(e)) || |
| this._noDiffsExpanded() |
| ) { |
| return; |
| } |
| |
| e.preventDefault(); |
| if (isShiftPressed(e)) { |
| this.diffCursor.moveToNextCommentThread(); |
| } else { |
| this.diffCursor.moveToNextChunk(); |
| } |
| } |
| |
| _handlePrevChunk(e: CustomKeyboardEvent) { |
| if ( |
| this.shouldSuppressKeyboardShortcut(e) || |
| (this.modifierPressed(e) && !isShiftPressed(e)) || |
| this._noDiffsExpanded() |
| ) { |
| return; |
| } |
| |
| e.preventDefault(); |
| if (isShiftPressed(e)) { |
| this.diffCursor.moveToPreviousCommentThread(); |
| } else { |
| this.diffCursor.moveToPreviousChunk(); |
| } |
| } |
| |
| _handleToggleFileReviewed(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| if (!this._files[this.fileCursor.index]) { |
| return; |
| } |
| this._reviewFile(this._files[this.fileCursor.index].__path); |
| } |
| |
| _handleToggleLeftPane(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this._forEachDiff(diff => { |
| diff.toggleLeftDiff(); |
| }); |
| } |
| |
| _toggleInlineDiffs() { |
| if (this._showInlineDiffs) { |
| this.collapseAllDiffs(); |
| } else { |
| this.expandAllDiffs(); |
| } |
| } |
| |
| _openCursorFile() { |
| const diff = this.diffCursor.getTargetDiffElement(); |
| if (!this.change || !diff || !this.patchRange || !diff.path) { |
| throw new Error('change, diff and patchRange must be all set and valid'); |
| } |
| GerritNav.navigateToDiff( |
| this.change, |
| diff.path, |
| this.patchRange.patchNum, |
| this.patchRange.basePatchNum |
| ); |
| } |
| |
| _openSelectedFile(index?: number) { |
| if (index !== undefined) { |
| this.fileCursor.setCursorAtIndex(index); |
| } |
| if (!this._files[this.fileCursor.index]) { |
| return; |
| } |
| if (!this.change || !this.patchRange) { |
| throw new Error('change and patchRange must be set'); |
| } |
| GerritNav.navigateToDiff( |
| this.change, |
| this._files[this.fileCursor.index].__path, |
| this.patchRange.patchNum, |
| this.patchRange.basePatchNum |
| ); |
| } |
| |
| _shouldHideChangeTotals(_patchChange: PatchChange): boolean { |
| return _patchChange.inserted === 0 && _patchChange.deleted === 0; |
| } |
| |
| _shouldHideBinaryChangeTotals(_patchChange: PatchChange) { |
| return ( |
| _patchChange.size_delta_inserted === 0 && |
| _patchChange.size_delta_deleted === 0 |
| ); |
| } |
| |
| _computeDiffURL( |
| change?: ParsedChangeInfo, |
| patchRange?: PatchRange, |
| path?: string, |
| editMode?: boolean |
| ) { |
| // Polymer 2: check for undefined |
| if ( |
| change === undefined || |
| !patchRange?.patchNum || |
| path === undefined || |
| editMode === undefined |
| ) { |
| return; |
| } |
| if (editMode && path !== SpecialFilePath.MERGE_LIST) { |
| return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum); |
| } |
| return GerritNav.getUrlForDiff( |
| change, |
| path, |
| patchRange.patchNum, |
| patchRange.basePatchNum |
| ); |
| } |
| |
| _formatBytes(bytes?: number) { |
| if (!bytes) return '+/-0 B'; |
| const bits = 1024; |
| const decimals = 1; |
| const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; |
| const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits)); |
| const prepend = bytes > 0 ? '+' : ''; |
| const value = parseFloat( |
| (bytes / Math.pow(bits, exponent)).toFixed(decimals) |
| ); |
| return `${prepend}${value} ${sizes[exponent]}`; |
| } |
| |
| _formatPercentage(size?: number, delta?: number) { |
| if (size === undefined || delta === undefined) { |
| return ''; |
| } |
| const oldSize = size - delta; |
| |
| if (oldSize === 0) { |
| return ''; |
| } |
| |
| const percentage = Math.round(Math.abs((delta * 100) / oldSize)); |
| return `(${delta > 0 ? '+' : '-'}${percentage}%)`; |
| } |
| |
| _computeBinaryClass(delta?: number) { |
| if (!delta) { |
| return; |
| } |
| return delta > 0 ? 'added' : 'removed'; |
| } |
| |
| _computeClass(baseClass?: string, path?: string) { |
| const classes = []; |
| if (baseClass) { |
| classes.push(baseClass); |
| } |
| if ( |
| path === SpecialFilePath.COMMIT_MESSAGE || |
| path === SpecialFilePath.MERGE_LIST |
| ) { |
| classes.push('invisible'); |
| } |
| return classes.join(' '); |
| } |
| |
| _computePathClass( |
| path: string | undefined, |
| expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'> |
| ) { |
| return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : ''; |
| } |
| |
| _computeShowHideIcon( |
| path: string | undefined, |
| expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'> |
| ) { |
| return this._isFileExpanded(path, expandedFilesRecord) |
| ? 'gr-icons:expand-less' |
| : 'gr-icons:expand-more'; |
| } |
| |
| _computeShowNumCleanlyMerged(cleanlyMergedPaths: string[]): boolean { |
| return cleanlyMergedPaths.length > 0; |
| } |
| |
| _computeCleanlyMergedText(cleanlyMergedPaths: string[]): string { |
| const fileCount = pluralize(cleanlyMergedPaths.length, 'file'); |
| return `${fileCount} merged cleanly in Parent 1`; |
| } |
| |
| _handleShowParent1(): void { |
| if (!this.change || !this.patchRange) return; |
| GerritNav.navigateToChange( |
| this.change, |
| this.patchRange.patchNum, |
| -1 as BasePatchSetNum // Parent 1 |
| ); |
| } |
| |
| @observe( |
| '_filesByPath', |
| 'changeComments', |
| 'patchRange', |
| '_reviewed', |
| '_loading' |
| ) |
| _computeFiles( |
| filesByPath?: FileNameToFileInfoMap, |
| changeComments?: ChangeComments, |
| patchRange?: PatchRange, |
| reviewed?: string[], |
| loading?: boolean |
| ) { |
| // Polymer 2: check for undefined |
| if ( |
| filesByPath === undefined || |
| changeComments === undefined || |
| patchRange === undefined || |
| reviewed === undefined || |
| loading === undefined |
| ) { |
| return; |
| } |
| // Await all promises resolving from reload. @See Issue 9057 |
| if (loading || !changeComments) { |
| return; |
| } |
| const commentedPaths = changeComments.getPaths(patchRange); |
| const files: FileNameToReviewedFileInfoMap = {...filesByPath}; |
| addUnmodifiedFiles(files, commentedPaths); |
| const reviewedSet = new Set(reviewed || []); |
| for (const [filePath, reviewedFileInfo] of Object.entries(files)) { |
| reviewedFileInfo.isReviewed = reviewedSet.has(filePath); |
| } |
| this._files = this._normalizeChangeFilesResponse(files); |
| } |
| |
| _computeFilesShown( |
| numFilesShown: number, |
| files: NormalizedFileInfo[] |
| ): NormalizedFileInfo[] | undefined { |
| // Polymer 2: check for undefined |
| if (numFilesShown === undefined || files === undefined) return undefined; |
| |
| const previousNumFilesShown = this._shownFiles |
| ? this._shownFiles.length |
| : 0; |
| |
| const filesShown = files.slice(0, numFilesShown); |
| this.dispatchEvent( |
| new CustomEvent('files-shown-changed', { |
| detail: {length: filesShown.length}, |
| composed: true, |
| bubbles: true, |
| }) |
| ); |
| |
| // Start the timer for the rendering work hwere because this is where the |
| // _shownFiles property is being set, and _shownFiles is used in the |
| // dom-repeat binding. |
| this.reporting.time(Timing.FILE_RENDER); |
| |
| // How many more files are being shown (if it's an increase). |
| this._reportinShownFilesIncrement = Math.max( |
| 0, |
| filesShown.length - previousNumFilesShown |
| ); |
| |
| return filesShown; |
| } |
| |
| _updateDiffCursor() { |
| // Overwrite the cursor's list of diffs: |
| this.diffCursor.replaceDiffs(this.diffs); |
| } |
| |
| _filesChanged() { |
| if (this._files && this._files.length > 0) { |
| flush(); |
| this.fileCursor.stops = Array.from( |
| this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`) |
| ); |
| this.fileCursor.setCursorAtIndex(this.selectedIndex, true); |
| } |
| } |
| |
| _incrementNumFilesShown() { |
| this.numFilesShown += this.fileListIncrement; |
| } |
| |
| _computeFileListControlClass( |
| numFilesShown?: number, |
| files?: NormalizedFileInfo[] |
| ) { |
| if (numFilesShown === undefined || files === undefined) return 'invisible'; |
| return numFilesShown >= files.length ? 'invisible' : ''; |
| } |
| |
| _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) { |
| if (numFilesShown === undefined || files === undefined) return ''; |
| const text = Math.min(this.fileListIncrement, files.length - numFilesShown); |
| return `Show ${text} more`; |
| } |
| |
| _computeShowAllText(files: NormalizedFileInfo[]) { |
| if (!files) { |
| return ''; |
| } |
| return `Show all ${files.length} files`; |
| } |
| |
| _computeWarnShowAll(files: NormalizedFileInfo[]) { |
| return files.length > WARN_SHOW_ALL_THRESHOLD; |
| } |
| |
| _computeShowAllWarning(files: NormalizedFileInfo[]) { |
| if (!this._computeWarnShowAll(files)) { |
| return ''; |
| } |
| return `Warning: showing all ${files.length} files may take several seconds.`; |
| } |
| |
| _showAllFiles() { |
| this.numFilesShown = this._files.length; |
| } |
| |
| /** |
| * Converts any boolean-like variable to the string 'true' or 'false' |
| * |
| * This method is useful when you bind aria-checked attribute to a boolean |
| * value. The aria-checked attribute is string attribute. Binding directly |
| * to boolean variable causes problem on gerrit-CI. |
| * |
| * @return 'true' if val is true-like, otherwise false |
| */ |
| _booleanToString(val?: unknown) { |
| return val ? 'true' : 'false'; |
| } |
| |
| _isFileExpanded( |
| path: string | undefined, |
| expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'> |
| ) { |
| return expandedFilesRecord.base.some(f => f.path === path); |
| } |
| |
| _isFileExpandedStr( |
| path: string | undefined, |
| expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'> |
| ) { |
| return this._booleanToString( |
| this._isFileExpanded(path, expandedFilesRecord) |
| ); |
| } |
| |
| private _computeExpandedFiles( |
| expandedCount: number, |
| totalCount: number |
| ): FilesExpandedState { |
| if (expandedCount === 0) { |
| return FilesExpandedState.NONE; |
| } else if (expandedCount === totalCount) { |
| return FilesExpandedState.ALL; |
| } |
| return FilesExpandedState.SOME; |
| } |
| |
| /** |
| * Handle splices to the list of expanded file paths. If there are any new |
| * entries in the expanded list, then render each diff corresponding in |
| * order by waiting for the previous diff to finish before starting the next |
| * one. |
| * |
| * @param record The splice record in the expanded paths list. |
| */ |
| @observe('_expandedFiles.splices') |
| _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) { |
| // Clear content for any diffs that are not open so if they get re-opened |
| // the stale content does not flash before it is cleared and reloaded. |
| const collapsedDiffs = this.diffs.filter( |
| diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1 |
| ); |
| this._clearCollapsedDiffs(collapsedDiffs); |
| |
| if (!record) { |
| return; |
| } // Happens after "Collapse all" clicked. |
| |
| this.filesExpanded = this._computeExpandedFiles( |
| this._expandedFiles.length, |
| this._files.length |
| ); |
| |
| // Find the paths introduced by the new index splices: |
| const newFiles = record.indexSplices.flatMap(splice => |
| splice.object.slice(splice.index, splice.index + splice.addedCount) |
| ); |
| |
| // Required so that the newly created diff view is included in this.diffs. |
| flush(); |
| |
| this.reporting.time(Timing.FILE_EXPAND_ALL); |
| |
| if (newFiles.length) { |
| this._renderInOrder(newFiles, this.diffs, newFiles.length); |
| } |
| |
| this._updateDiffCursor(); |
| this.diffCursor.reInitAndUpdateStops(); |
| } |
| |
| private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) { |
| for (const diff of collapsedDiffs) { |
| diff.cancel(); |
| diff.clearDiffContent(); |
| } |
| } |
| |
| /** |
| * Given an array of paths and a NodeList of diff elements, render the diff |
| * for each path in order, awaiting the previous render to complete before |
| * continuing. |
| * |
| * @param initialCount The total number of paths in the pass. This |
| * is used to generate log messages. |
| */ |
| private _renderInOrder( |
| files: PatchSetFile[], |
| diffElements: GrDiffHost[], |
| initialCount: number |
| ) { |
| let iter = 0; |
| |
| for (const file of files) { |
| const path = file.path; |
| const diffElem = this._findDiffByPath(path, diffElements); |
| if (diffElem) { |
| diffElem.prefetchDiff(); |
| } |
| } |
| |
| asyncForeach(files, (file, cancel) => { |
| const path = file.path; |
| this._cancelForEachDiff = cancel; |
| |
| iter++; |
| console.info('Expanding diff', iter, 'of', initialCount, ':', path); |
| const diffElem = this._findDiffByPath(path, diffElements); |
| if (!diffElem) { |
| this.reporting.error( |
| new Error(`Did not find <gr-diff-host> element for ${path}`) |
| ); |
| return Promise.resolve(); |
| } |
| if (!this.diffPrefs) { |
| throw new Error('diffPrefs must be set'); |
| } |
| |
| const promises: Array<Promise<unknown>> = [diffElem.reload()]; |
| if (this._loggedIn && !this.diffPrefs.manual_review) { |
| promises.push(this._reviewFile(path, true)); |
| } |
| return Promise.all(promises); |
| }).then(() => { |
| this._cancelForEachDiff = undefined; |
| console.info('Finished expanding', initialCount, 'diff(s)'); |
| this.reporting.timeEndWithAverage( |
| Timing.FILE_EXPAND_ALL, |
| Timing.FILE_EXPAND_ALL_AVG, |
| initialCount |
| ); |
| /* Block diff cursor from auto scrolling after files are done rendering. |
| * This prevents the bug where the screen jumps to the first diff chunk |
| * after files are done being rendered after the user has already begun |
| * scrolling. |
| * This also however results in the fact that the cursor does not auto |
| * focus on the first diff chunk on a small screen. This is however, a use |
| * case we are willing to not support for now. |
| |
| * Using handleDiffUpdate resulted in diffCursor.row being set which |
| * prevented the issue of scrolling to top when we expand the second |
| * file individually. |
| */ |
| this.diffCursor.reInitAndUpdateStops(); |
| }); |
| } |
| |
| /** Cancel the rendering work of every diff in the list */ |
| _cancelDiffs() { |
| if (this._cancelForEachDiff) { |
| this._cancelForEachDiff(); |
| } |
| this._forEachDiff(d => d.cancel()); |
| } |
| |
| /** |
| * In the given NodeList of diff elements, find the diff for the given path. |
| */ |
| private _findDiffByPath(path: string, diffElements: GrDiffHost[]) { |
| for (let i = 0; i < diffElements.length; i++) { |
| if (diffElements[i].path === path) { |
| return diffElements[i]; |
| } |
| } |
| return undefined; |
| } |
| |
| _handleEscKey(e: CustomKeyboardEvent) { |
| if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { |
| return; |
| } |
| e.preventDefault(); |
| this._displayLine = false; |
| } |
| |
| /** |
| * Update the loading class for the file list rows. The update is inside a |
| * debouncer so that the file list doesn't flash gray when the API requests |
| * are reasonably fast. |
| */ |
| _loadingChanged(loading?: boolean) { |
| this.loadingTask = debounce( |
| this.loadingTask, |
| () => { |
| // Only show set the loading if there have been files loaded to show. In |
| // this way, the gray loading style is not shown on initial loads. |
| this.classList.toggle('loading', loading && !!this._files.length); |
| }, |
| LOADING_DEBOUNCE_INTERVAL |
| ); |
| } |
| |
| _editModeChanged(editMode?: boolean) { |
| this.classList.toggle('editMode', editMode); |
| } |
| |
| _computeReviewedClass(isReviewed?: boolean) { |
| return isReviewed ? 'isReviewed' : ''; |
| } |
| |
| _computeReviewedText(isReviewed?: boolean) { |
| return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED'; |
| } |
| |
| /** |
| * Given a file path, return whether that path should have visible size bars |
| * and be included in the size bars calculation. |
| */ |
| _showBarsForPath(path?: string) { |
| return ( |
| path !== SpecialFilePath.COMMIT_MESSAGE && |
| path !== SpecialFilePath.MERGE_LIST |
| ); |
| } |
| |
| /** |
| * Compute size bar layout values from the file list. |
| */ |
| _computeSizeBarLayout( |
| shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'> |
| ) { |
| const stats: SizeBarLayout = createDefaultSizeBarLayout(); |
| if (!shownFilesRecord || !shownFilesRecord.base) { |
| return stats; |
| } |
| shownFilesRecord.base |
| .filter(f => this._showBarsForPath(f.__path)) |
| .forEach(f => { |
| if (f.lines_inserted) { |
| stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted); |
| } |
| if (f.lines_deleted) { |
| stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted); |
| } |
| }); |
| const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted); |
| if (!isNaN(ratio)) { |
| stats.maxAdditionWidth = |
| (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio; |
| stats.maxDeletionWidth = |
| SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth; |
| stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH; |
| } |
| return stats; |
| } |
| |
| /** |
| * Get the width of the addition bar for a file. |
| */ |
| _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) { |
| if ( |
| !file || |
| !stats || |
| stats.maxInserted === 0 || |
| !file.lines_inserted || |
| !this._showBarsForPath(file.__path) |
| ) { |
| return 0; |
| } |
| const width = |
| (stats.maxAdditionWidth * file.lines_inserted) / stats.maxInserted; |
| return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width); |
| } |
| |
| /** |
| * Get the x-offset of the addition bar for a file. |
| */ |
| _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) { |
| if (!file || !stats) return; |
| return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats); |
| } |
| |
| /** |
| * Get the width of the deletion bar for a file. |
| */ |
| _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) { |
| if ( |
| !file || |
| !stats || |
| stats.maxDeleted === 0 || |
| !file.lines_deleted || |
| !this._showBarsForPath(file.__path) |
| ) { |
| return 0; |
| } |
| const width = |
| (stats.maxDeletionWidth * file.lines_deleted) / stats.maxDeleted; |
| return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width); |
| } |
| |
| /** |
| * Get the x-offset of the deletion bar for a file. |
| */ |
| _computeBarDeletionX(stats: SizeBarLayout) { |
| return stats.deletionOffset; |
| } |
| |
| _computeSizeBarsClass(showSizeBars?: boolean, path?: string) { |
| let hideClass = ''; |
| if (!showSizeBars) { |
| hideClass = 'hide'; |
| } else if (!this._showBarsForPath(path)) { |
| hideClass = 'invisible'; |
| } |
| return `sizeBars desktop ${hideClass}`; |
| } |
| |
| /** |
| * Shows registered dynamic columns iff the 'header', 'content' and |
| * 'summary' endpoints are registered the exact same number of times. |
| * Ideally, there should be a better way to enforce the expectation of the |
| * dependencies between dynamic endpoints. |
| */ |
| _computeShowDynamicColumns( |
| headerEndpoints?: string, |
| contentEndpoints?: string, |
| summaryEndpoints?: string |
| ) { |
| return ( |
| headerEndpoints && |
| contentEndpoints && |
| summaryEndpoints && |
| headerEndpoints.length && |
| headerEndpoints.length === contentEndpoints.length && |
| headerEndpoints.length === summaryEndpoints.length |
| ); |
| } |
| |
| /** |
| * Shows registered dynamic prepended columns iff the 'header', 'content' |
| * endpoints are registered the exact same number of times. |
| */ |
| _computeShowPrependedDynamicColumns( |
| headerEndpoints?: string, |
| contentEndpoints?: string |
| ) { |
| return ( |
| headerEndpoints && |
| contentEndpoints && |
| headerEndpoints.length && |
| headerEndpoints.length === contentEndpoints.length |
| ); |
| } |
| |
| /** |
| * Returns true if none of the inline diffs have been expanded. |
| */ |
| _noDiffsExpanded() { |
| return this.filesExpanded === FilesExpandedState.NONE; |
| } |
| |
| /** |
| * Method to call via binding when each file list row is rendered. This |
| * allows approximate detection of when the dom-repeat has completed |
| * rendering. |
| * |
| * @param index The index of the row being rendered. |
| */ |
| _reportRenderedRow(index: number) { |
| if (index === this._shownFiles.length - 1) { |
| setTimeout(() => { |
| this.reporting.timeEndWithAverage( |
| Timing.FILE_RENDER, |
| Timing.FILE_RENDER_AVG, |
| this._reportinShownFilesIncrement |
| ); |
| }, 1); |
| } |
| return ''; |
| } |
| |
| _reviewedTitle(reviewed?: boolean) { |
| if (reviewed) { |
| return 'Mark as not reviewed (shortcut: r)'; |
| } |
| |
| return 'Mark as reviewed (shortcut: r)'; |
| } |
| |
| _handleReloadingDiffPreference() { |
| this._getDiffPreferences().then(prefs => { |
| this.diffPrefs = prefs; |
| }); |
| } |
| |
| /** |
| * Wrapper for using in the element template and computed properties |
| */ |
| _computeDisplayPath(path: string) { |
| return computeDisplayPath(path); |
| } |
| |
| /** |
| * Wrapper for using in the element template and computed properties |
| */ |
| _computeTruncatedPath(path: string) { |
| return computeTruncatedPath(path); |
| } |
| |
| _getOldPath(file: NormalizedFileInfo) { |
| // The gr-endpoint-decorator is waiting until all gr-endpoint-param |
| // values are updated. |
| // The old_path property is undefined for added files, and the |
| // gr-endpoint-param value bound to file.old_path is never updates. |
| // As a results, the gr-endpoint-decorator doesn't work for added files. |
| // As a workaround, this method returns null instead of undefined. |
| return file.old_path ?? null; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-file-list': GrFileList; |
| } |
| } |