1803 lines
51 KiB
TypeScript
1803 lines
51 KiB
TypeScript
/**
|
||
* @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 '@polymer/iron-dropdown/iron-dropdown';
|
||
import '@polymer/iron-input/iron-input';
|
||
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-icons/gr-icons';
|
||
import '../../shared/gr-select/gr-select';
|
||
import '../../shared/revision-info/revision-info';
|
||
import '../gr-comment-api/gr-comment-api';
|
||
import '../gr-diff-cursor/gr-diff-cursor';
|
||
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 {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
|
||
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
|
||
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
|
||
import {PolymerElement} from '@polymer/polymer/polymer-element';
|
||
import {htmlTemplate} from './gr-diff-view_html';
|
||
import {
|
||
KeyboardShortcutMixin,
|
||
Shortcut,
|
||
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
|
||
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
|
||
import {appContext} from '../../../services/app-context';
|
||
import {
|
||
computeAllPatchSets,
|
||
computeLatestPatchNum,
|
||
PatchSet,
|
||
} from '../../../utils/patch-set-util';
|
||
import {
|
||
addUnmodifiedFiles,
|
||
computeDisplayPath,
|
||
computeTruncatedPath,
|
||
isMagicPath,
|
||
specialFilePathCompare,
|
||
} from '../../../utils/path-list-util';
|
||
import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
|
||
import {customElement, observe, property} from '@polymer/decorators';
|
||
import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
|
||
import {
|
||
DropdownItem,
|
||
GrDropdownList,
|
||
} from '../../shared/gr-dropdown-list/gr-dropdown-list';
|
||
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
|
||
import {ChangeComments, GrCommentApi} from '../gr-comment-api/gr-comment-api';
|
||
import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
|
||
import {
|
||
ChangeInfo,
|
||
CommitId,
|
||
ConfigInfo,
|
||
EditInfo,
|
||
EditPatchSetNum,
|
||
ElementPropertyDeepChange,
|
||
FileInfo,
|
||
NumericChangeId,
|
||
ParentPatchSetNum,
|
||
PatchRange,
|
||
PatchSetNum,
|
||
PreferencesInfo,
|
||
RepoName,
|
||
RevisionInfo,
|
||
} from '../../../types/common';
|
||
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
|
||
import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
|
||
import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
|
||
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
|
||
import {GrDiffCursor} from '../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 {LineOfInterest} from '../gr-diff/gr-diff';
|
||
import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
|
||
import {
|
||
CommentMap,
|
||
isInBaseOfPatchRange,
|
||
getPatchRangeForCommentUrl,
|
||
} from '../../../utils/comment-util';
|
||
import {AppElementParams} from '../../gr-app-types';
|
||
import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
|
||
import {fireAlert, fireTitleChange} from '../../../utils/event-util';
|
||
import {GerritView} from '../../../services/router/router-model';
|
||
const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
|
||
const MSG_LOADING_BLAME = 'Loading blame...';
|
||
const MSG_LOADED_BLAME = 'Blame loaded';
|
||
|
||
interface Files {
|
||
sortedFileList: string[];
|
||
changeFilesByPath: {[path: string]: FileInfo};
|
||
}
|
||
|
||
interface CommentSkips {
|
||
previous: string | null;
|
||
next: string | null;
|
||
}
|
||
|
||
export interface GrDiffView {
|
||
$: {
|
||
commentAPI: GrCommentApi;
|
||
cursor: GrDiffCursor;
|
||
diffHost: GrDiffHost;
|
||
reviewed: HTMLInputElement;
|
||
dropdown: GrDropdownList;
|
||
diffPreferencesDialog: GrOverlay;
|
||
applyFixDialog: GrApplyFixDialog;
|
||
modeSelect: GrDiffModeSelector;
|
||
};
|
||
}
|
||
|
||
@customElement('gr-diff-view')
|
||
export class GrDiffView extends KeyboardShortcutMixin(
|
||
GestureEventListeners(LegacyElementMixin(PolymerElement))
|
||
) {
|
||
static get template() {
|
||
return htmlTemplate;
|
||
}
|
||
|
||
/**
|
||
* Fired when the title of the page should change.
|
||
*
|
||
* @event title-change
|
||
*/
|
||
|
||
/**
|
||
* Fired when user tries to navigate away while comments are pending save.
|
||
*
|
||
* @event show-alert
|
||
*/
|
||
|
||
@property({type: Object, observer: '_paramsChanged'})
|
||
params?: AppElementParams;
|
||
|
||
@property({type: Object})
|
||
keyEventTarget: HTMLElement = document.body;
|
||
|
||
@property({type: Object, notify: true, observer: '_changeViewStateChanged'})
|
||
changeViewState: Partial<ChangeViewState> = {};
|
||
|
||
@property({type: Boolean})
|
||
disableDiffPrefs = false;
|
||
|
||
@property({
|
||
type: Boolean,
|
||
computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
|
||
})
|
||
_diffPrefsDisabled?: boolean;
|
||
|
||
@property({type: Object})
|
||
_patchRange?: PatchRange;
|
||
|
||
@property({type: Object})
|
||
_commitRange?: CommitRange;
|
||
|
||
@property({type: Object})
|
||
_change?: ChangeInfo;
|
||
|
||
@property({type: Object})
|
||
_changeComments?: ChangeComments;
|
||
|
||
@property({type: String})
|
||
_changeNum?: NumericChangeId;
|
||
|
||
@property({type: Object})
|
||
_diff?: DiffInfo;
|
||
|
||
@property({
|
||
type: Array,
|
||
computed: '_formatFilesForDropdown(_files, _patchRange, _changeComments)',
|
||
})
|
||
_formattedFiles?: DropdownItem[];
|
||
|
||
@property({type: Array, computed: '_getSortedFileList(_files)'})
|
||
_fileList?: string[];
|
||
|
||
@property({type: Object})
|
||
_files: Files = {sortedFileList: [], changeFilesByPath: {}};
|
||
|
||
@property({type: Object, computed: '_getCurrentFile(_files, _path)'})
|
||
_file?: FileInfo;
|
||
|
||
@property({type: String, observer: '_pathChanged'})
|
||
_path?: string;
|
||
|
||
@property({type: Number, computed: '_computeFileNum(_path, _formattedFiles)'})
|
||
_fileNum?: number;
|
||
|
||
@property({type: Boolean})
|
||
_loggedIn = false;
|
||
|
||
@property({type: Boolean})
|
||
_loading = true;
|
||
|
||
@property({type: Object})
|
||
_prefs?: DiffPreferencesInfo;
|
||
|
||
@property({type: Object})
|
||
_projectConfig?: ConfigInfo;
|
||
|
||
@property({type: Object})
|
||
_userPrefs?: PreferencesInfo;
|
||
|
||
@property({
|
||
type: String,
|
||
computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
|
||
})
|
||
_diffMode?: string;
|
||
|
||
@property({type: Boolean})
|
||
_isImageDiff?: boolean;
|
||
|
||
@property({type: Object})
|
||
_filesWeblinks?: FilesWebLinks;
|
||
|
||
@property({type: Object})
|
||
_commentMap?: CommentMap;
|
||
|
||
@property({
|
||
type: Object,
|
||
computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
|
||
})
|
||
_commentSkips?: CommentSkips;
|
||
|
||
@property({type: Boolean, computed: '_computeEditMode(_patchRange.*)'})
|
||
_editMode?: boolean;
|
||
|
||
@property({type: Boolean})
|
||
_isBlameLoaded?: boolean;
|
||
|
||
@property({type: Boolean})
|
||
_isBlameLoading = false;
|
||
|
||
@property({
|
||
type: Array,
|
||
computed: '_computeAllPatchSets(_change, _change.revisions.*)',
|
||
})
|
||
_allPatchSets?: PatchSet[] = [];
|
||
|
||
@property({type: Object, computed: '_getRevisionInfo(_change)'})
|
||
_revisionInfo?: RevisionInfoObj;
|
||
|
||
@property({type: Object})
|
||
_reviewedFiles = new Set<string>();
|
||
|
||
@property({type: Number})
|
||
_focusLineNum?: number;
|
||
|
||
get keyBindings() {
|
||
return {
|
||
esc: '_handleEscKey',
|
||
};
|
||
}
|
||
|
||
keyboardShortcuts() {
|
||
return {
|
||
[Shortcut.LEFT_PANE]: '_handleLeftPane',
|
||
[Shortcut.RIGHT_PANE]: '_handleRightPane',
|
||
[Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
|
||
[Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
|
||
[Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
|
||
[Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
|
||
[Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
|
||
[Shortcut.NEW_COMMENT]: '_handleNewComment',
|
||
[Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
|
||
[Shortcut.NEXT_FILE]: '_handleNextFile',
|
||
[Shortcut.PREV_FILE]: '_handlePrevFile',
|
||
[Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
|
||
[Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
|
||
[Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
|
||
[Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
|
||
[Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
|
||
[Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
|
||
[Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
|
||
[Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
|
||
[Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
|
||
[Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
|
||
[Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
|
||
[Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
|
||
[Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
|
||
[Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
|
||
[Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
|
||
'_handleToggleHideAllCommentThreads',
|
||
[Shortcut.OPEN_FILE_LIST]: '_handleOpenFileList',
|
||
[Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
|
||
[Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
|
||
[Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
|
||
[Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
|
||
[Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
|
||
|
||
// Final two are actually handled by gr-comment-thread.
|
||
[Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
|
||
[Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
|
||
};
|
||
}
|
||
|
||
reporting = appContext.reportingService;
|
||
|
||
flagsService = appContext.flagsService;
|
||
|
||
private readonly restApiService = appContext.restApiService;
|
||
|
||
_throttledToggleFileReviewed?: EventListener;
|
||
|
||
_onRenderHandler?: EventListener;
|
||
|
||
/** @override */
|
||
connectedCallback() {
|
||
super.connectedCallback();
|
||
this._throttledToggleFileReviewed = this._throttleWrap(e =>
|
||
this._handleToggleFileReviewed(e as CustomKeyboardEvent)
|
||
);
|
||
}
|
||
|
||
/** @override */
|
||
attached() {
|
||
super.attached();
|
||
this._getLoggedIn().then(loggedIn => {
|
||
this._loggedIn = loggedIn;
|
||
});
|
||
|
||
this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
|
||
this.$.cursor.push('diffs', this.$.diffHost);
|
||
this._onRenderHandler = (_: Event) => {
|
||
this.$.cursor.reInitCursor();
|
||
};
|
||
this.$.diffHost.addEventListener('render', this._onRenderHandler);
|
||
}
|
||
|
||
/** @override */
|
||
detached() {
|
||
if (this._onRenderHandler) {
|
||
this.$.diffHost.removeEventListener('render', this._onRenderHandler);
|
||
}
|
||
}
|
||
|
||
_getLoggedIn() {
|
||
return this.restApiService.getLoggedIn();
|
||
}
|
||
|
||
@observe('_change.project')
|
||
_getProjectConfig(project?: RepoName) {
|
||
if (!project) return;
|
||
return this.restApiService.getProjectConfig(project).then(config => {
|
||
this._projectConfig = config;
|
||
});
|
||
}
|
||
|
||
_getChangeDetail(changeNum: NumericChangeId) {
|
||
return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
|
||
if (!change) throw new Error('Missing "change" in API response.');
|
||
this._change = change;
|
||
return change;
|
||
});
|
||
}
|
||
|
||
_getChangeEdit() {
|
||
if (!this._changeNum) throw new Error('Missing this._changeNum');
|
||
return this.restApiService.getChangeEdit(this._changeNum);
|
||
}
|
||
|
||
_getSortedFileList(files?: Files) {
|
||
if (!files) return [];
|
||
return files.sortedFileList;
|
||
}
|
||
|
||
_getCurrentFile(files?: Files, path?: string) {
|
||
if (!files || !path) return;
|
||
const fileInfo = files.changeFilesByPath[path];
|
||
const fileRange: FileRange = {path};
|
||
if (fileInfo && fileInfo.old_path) {
|
||
fileRange.basePath = fileInfo.old_path;
|
||
}
|
||
return fileRange;
|
||
}
|
||
|
||
@observe('_changeNum', '_patchRange.*', '_changeComments')
|
||
_getFiles(
|
||
changeNum: NumericChangeId,
|
||
patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
|
||
changeComments: ChangeComments
|
||
) {
|
||
// Polymer 2: check for undefined
|
||
if (
|
||
[changeNum, patchRangeRecord, patchRangeRecord.base, changeComments].some(
|
||
arg => arg === undefined
|
||
)
|
||
) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
if (!patchRangeRecord.base.patchNum) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
const patchRange = patchRangeRecord.base;
|
||
return this.restApiService
|
||
.getChangeFiles(changeNum, patchRange)
|
||
.then(changeFiles => {
|
||
if (!changeFiles) return;
|
||
const commentedPaths = changeComments.getPaths(patchRange);
|
||
const files = {...changeFiles};
|
||
addUnmodifiedFiles(files, commentedPaths);
|
||
this._files = {
|
||
sortedFileList: Object.keys(files).sort(specialFilePathCompare),
|
||
changeFilesByPath: files,
|
||
};
|
||
});
|
||
}
|
||
|
||
_getDiffPreferences() {
|
||
return this.restApiService.getDiffPreferences().then(prefs => {
|
||
this._prefs = prefs;
|
||
});
|
||
}
|
||
|
||
_getPreferences() {
|
||
return this.restApiService.getPreferences();
|
||
}
|
||
|
||
_getWindowWidth() {
|
||
return window.innerWidth;
|
||
}
|
||
|
||
_handleReviewedChange(e: Event) {
|
||
this._setReviewed(
|
||
((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
|
||
);
|
||
}
|
||
|
||
_setReviewed(reviewed: boolean) {
|
||
if (this._editMode) return;
|
||
this.$.reviewed.checked = reviewed;
|
||
if (!this._patchRange?.patchNum) return;
|
||
this._saveReviewedState(reviewed).catch(err => {
|
||
fireAlert(this, ERR_REVIEW_STATUS);
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
_saveReviewedState(reviewed: boolean): Promise<Response | undefined> {
|
||
if (!this._changeNum) return Promise.resolve(undefined);
|
||
if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
|
||
if (!this._path) return Promise.resolve(undefined);
|
||
return this.restApiService.saveFileReviewed(
|
||
this._changeNum,
|
||
this._patchRange?.patchNum,
|
||
this._path,
|
||
reviewed
|
||
);
|
||
}
|
||
|
||
_handleToggleFileReviewed(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
|
||
e.preventDefault();
|
||
this._setReviewed(!this.$.reviewed.checked);
|
||
}
|
||
|
||
_handleEscKey(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
|
||
e.preventDefault();
|
||
this.$.diffHost.displayLine = false;
|
||
}
|
||
|
||
_handleLeftPane(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
|
||
e.preventDefault();
|
||
this.$.cursor.moveLeft();
|
||
}
|
||
|
||
_handleRightPane(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
|
||
e.preventDefault();
|
||
this.$.cursor.moveRight();
|
||
}
|
||
|
||
_handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
|
||
if (
|
||
e.detail.keyboardEvent?.shiftKey &&
|
||
e.detail.keyboardEvent?.keyCode === 75
|
||
) {
|
||
// 'K'
|
||
this._moveToPreviousFileWithComment();
|
||
return;
|
||
}
|
||
if (this.modifierPressed(e)) {
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
this.$.diffHost.displayLine = true;
|
||
this.$.cursor.moveUp();
|
||
}
|
||
|
||
_handleVisibleLine(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
|
||
e.preventDefault();
|
||
this.$.cursor.moveToVisibleArea();
|
||
}
|
||
|
||
_onOpenFixPreview(e: OpenFixPreviewEvent) {
|
||
this.$.applyFixDialog.open(e);
|
||
}
|
||
|
||
_handleNextLineOrFileWithComments(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
|
||
if (
|
||
e.detail.keyboardEvent?.shiftKey &&
|
||
e.detail.keyboardEvent?.keyCode === 74
|
||
) {
|
||
// 'J'
|
||
this._moveToNextFileWithComment();
|
||
return;
|
||
}
|
||
if (this.modifierPressed(e)) {
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
this.$.diffHost.displayLine = true;
|
||
this.$.cursor.moveDown();
|
||
}
|
||
|
||
_moveToPreviousFileWithComment() {
|
||
if (!this._commentSkips) return;
|
||
if (!this._change) return;
|
||
if (!this._patchRange?.patchNum) return;
|
||
|
||
// If there is no previous diff with comments, then return to the change
|
||
// view.
|
||
if (!this._commentSkips.previous) {
|
||
this._navToChangeView();
|
||
return;
|
||
}
|
||
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
this._commentSkips.previous,
|
||
this._patchRange.patchNum,
|
||
this._patchRange.basePatchNum
|
||
);
|
||
}
|
||
|
||
_moveToNextFileWithComment() {
|
||
if (!this._commentSkips) return;
|
||
if (!this._change) return;
|
||
if (!this._patchRange?.patchNum) return;
|
||
|
||
// If there is no next diff with comments, then return to the change view.
|
||
if (!this._commentSkips.next) {
|
||
this._navToChangeView();
|
||
return;
|
||
}
|
||
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
this._commentSkips.next,
|
||
this._patchRange.patchNum,
|
||
this._patchRange.basePatchNum
|
||
);
|
||
}
|
||
|
||
_handleNewComment(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
|
||
e.preventDefault();
|
||
this.classList.remove('hideComments');
|
||
this.$.cursor.createCommentInPlace();
|
||
}
|
||
|
||
_handlePrevFile(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
// Check for meta key to avoid overriding native chrome shortcut.
|
||
if (this.getKeyboardEvent(e).metaKey) return;
|
||
if (!this._path) return;
|
||
if (!this._fileList) return;
|
||
|
||
e.preventDefault();
|
||
this._navToFile(this._path, this._fileList, -1);
|
||
}
|
||
|
||
_handleNextFile(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
// Check for meta key to avoid overriding native chrome shortcut.
|
||
if (this.getKeyboardEvent(e).metaKey) return;
|
||
if (!this._path) return;
|
||
if (!this._fileList) return;
|
||
|
||
e.preventDefault();
|
||
this._navToFile(this._path, this._fileList, 1);
|
||
}
|
||
|
||
_handleNextChunkOrCommentThread(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
|
||
e.preventDefault();
|
||
if (e.detail.keyboardEvent?.shiftKey) {
|
||
this.$.cursor.moveToNextCommentThread();
|
||
} else {
|
||
if (this.modifierPressed(e)) return;
|
||
// navigate to next file if key is not being held down
|
||
this.$.cursor.moveToNextChunk(
|
||
/* opt_clipToTop = */ false,
|
||
/* opt_navigateToNextFile = */ !e.detail.keyboardEvent?.repeat
|
||
);
|
||
}
|
||
}
|
||
|
||
_handlePrevChunkOrCommentThread(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
|
||
e.preventDefault();
|
||
if (e.detail.keyboardEvent?.shiftKey) {
|
||
this.$.cursor.moveToPreviousCommentThread();
|
||
} else {
|
||
if (this.modifierPressed(e)) return;
|
||
this.$.cursor.moveToPreviousChunk();
|
||
}
|
||
}
|
||
|
||
_handleOpenReplyDialog(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
if (!this._loggedIn) return;
|
||
|
||
this.set('changeViewState.showReplyDialog', true);
|
||
e.preventDefault();
|
||
this._navToChangeView();
|
||
}
|
||
|
||
_handleToggleLeftPane(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (!e.detail.keyboardEvent?.shiftKey) return;
|
||
|
||
e.preventDefault();
|
||
this.$.diffHost.toggleLeftDiff();
|
||
}
|
||
|
||
_handleOpenDownloadDialog(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
|
||
this.set('changeViewState.showDownloadDialog', true);
|
||
e.preventDefault();
|
||
this._navToChangeView();
|
||
}
|
||
|
||
_handleUpToChange(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
|
||
e.preventDefault();
|
||
this._navToChangeView();
|
||
}
|
||
|
||
_handleCommaKey(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
if (this._diffPrefsDisabled) return;
|
||
|
||
e.preventDefault();
|
||
this.$.diffPreferencesDialog.open();
|
||
}
|
||
|
||
_handleToggleDiffMode(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
|
||
e.preventDefault();
|
||
if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
|
||
this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
|
||
} else {
|
||
this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
|
||
}
|
||
}
|
||
|
||
_navToChangeView() {
|
||
if (!this._changeNum || !this._patchRange?.patchNum) {
|
||
return;
|
||
}
|
||
this._navigateToChange(
|
||
this._change,
|
||
this._patchRange,
|
||
this._change && this._change.revisions
|
||
);
|
||
}
|
||
|
||
_navToFile(path: string, fileList: string[], direction: -1 | 1) {
|
||
const newPath = this._getNavLinkPath(path, fileList, direction);
|
||
if (!newPath) return;
|
||
if (!this._change) return;
|
||
if (!this._patchRange) return;
|
||
|
||
if (newPath.up) {
|
||
this._navigateToChange(
|
||
this._change,
|
||
this._patchRange,
|
||
this._change && this._change.revisions
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!newPath.path) return;
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
newPath.path,
|
||
this._patchRange.patchNum,
|
||
this._patchRange.basePatchNum
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @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).
|
||
* @return The next URL when proceeding in the specified
|
||
* direction.
|
||
*/
|
||
_computeNavLinkURL(
|
||
change?: ChangeInfo,
|
||
path?: string,
|
||
fileList?: string[],
|
||
direction?: -1 | 1
|
||
) {
|
||
if (!change) return null;
|
||
if (!path) return null;
|
||
if (!fileList) return null;
|
||
if (!direction) return null;
|
||
|
||
const newPath = this._getNavLinkPath(path, fileList, direction);
|
||
if (!newPath) {
|
||
return null;
|
||
}
|
||
|
||
if (newPath.up) {
|
||
return this._getChangePath(
|
||
this._change,
|
||
this._patchRange,
|
||
this._change && this._change.revisions
|
||
);
|
||
}
|
||
return this._getDiffUrl(this._change, this._patchRange, newPath.path);
|
||
}
|
||
|
||
_goToEditFile() {
|
||
if (!this._change) return;
|
||
if (!this._path) return;
|
||
if (!this._patchRange) return;
|
||
|
||
// TODO(taoalpha): add a shortcut for editing
|
||
const cursorAddress = this.$.cursor.getAddress();
|
||
const editUrl = GerritNav.getEditUrlForDiff(
|
||
this._change,
|
||
this._path,
|
||
this._patchRange.patchNum,
|
||
cursorAddress?.number
|
||
);
|
||
GerritNav.navigateToRelativeUrl(editUrl);
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
*/
|
||
_getNavLinkPath(path: string, fileList: string[], direction: -1 | 1) {
|
||
if (!path || !fileList || fileList.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
let idx = fileList.indexOf(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 opt_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]};
|
||
}
|
||
|
||
_getReviewedFiles(
|
||
changeNum?: NumericChangeId,
|
||
patchNum?: PatchSetNum
|
||
): Promise<Set<string>> {
|
||
if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
|
||
return this.restApiService
|
||
.getReviewedFiles(changeNum, patchNum)
|
||
.then(files => {
|
||
this._reviewedFiles = new Set(files);
|
||
return this._reviewedFiles;
|
||
});
|
||
}
|
||
|
||
_getReviewedStatus(
|
||
editMode?: boolean,
|
||
changeNum?: NumericChangeId,
|
||
patchNum?: PatchSetNum,
|
||
path?: string
|
||
) {
|
||
if (editMode || !path) {
|
||
return Promise.resolve(false);
|
||
}
|
||
return this._getReviewedFiles(changeNum, patchNum).then(files =>
|
||
files.has(path)
|
||
);
|
||
}
|
||
|
||
_initLineOfInterestAndCursor(leftSide: boolean) {
|
||
this.$.diffHost.lineOfInterest = this._getLineOfInterest(leftSide);
|
||
this._initCursor(leftSide);
|
||
}
|
||
|
||
_displayDiffBaseAgainstLeftToast() {
|
||
if (!this._patchRange) return;
|
||
fireAlert(
|
||
this,
|
||
`Patchset ${this._patchRange.basePatchNum} vs ` +
|
||
`${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
|
||
`Base vs ${this._patchRange.basePatchNum}`
|
||
);
|
||
}
|
||
|
||
_displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
|
||
if (!this._patchRange) return;
|
||
const leftPatchset =
|
||
this._patchRange.basePatchNum === ParentPatchSetNum
|
||
? 'Base'
|
||
: `Patchset ${this._patchRange.basePatchNum}`;
|
||
fireAlert(
|
||
this,
|
||
`${leftPatchset} vs
|
||
${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
|
||
${leftPatchset} vs Patchset ${latestPatchNum}`
|
||
);
|
||
}
|
||
|
||
_displayToasts() {
|
||
if (!this._patchRange) return;
|
||
if (this._patchRange.basePatchNum !== ParentPatchSetNum) {
|
||
this._displayDiffBaseAgainstLeftToast();
|
||
return;
|
||
}
|
||
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
|
||
if (this._patchRange.patchNum !== latestPatchNum) {
|
||
this._displayDiffAgainstLatestToast(latestPatchNum);
|
||
return;
|
||
}
|
||
}
|
||
|
||
_initCommitRange() {
|
||
let commit: CommitId | undefined;
|
||
let baseCommit: CommitId | undefined;
|
||
if (!this._change) return;
|
||
if (!this._patchRange || !this._patchRange.patchNum) return;
|
||
const revisions = this._change.revisions ?? {};
|
||
for (const [commitSha, revision] of Object.entries(revisions)) {
|
||
const patchNum = revision._number;
|
||
if (patchNum === this._patchRange.patchNum) {
|
||
commit = commitSha as CommitId;
|
||
const commitObj = revision.commit;
|
||
const parents = commitObj?.parents || [];
|
||
if (
|
||
this._patchRange.basePatchNum === ParentPatchSetNum &&
|
||
parents.length
|
||
) {
|
||
baseCommit = parents[parents.length - 1].commit;
|
||
}
|
||
} else if (patchNum === this._patchRange.basePatchNum) {
|
||
baseCommit = commitSha as CommitId;
|
||
}
|
||
}
|
||
this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
|
||
}
|
||
|
||
_updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
|
||
if (!this._change) return;
|
||
if (!this._patchRange) return;
|
||
if (!this._changeNum) return;
|
||
if (!this._path) return;
|
||
const url = GerritNav.getUrlForDiffById(
|
||
this._changeNum,
|
||
this._change.project,
|
||
this._path,
|
||
this._patchRange.patchNum,
|
||
this._patchRange.basePatchNum,
|
||
lineNum,
|
||
leftSide
|
||
);
|
||
history.replaceState(null, '', url);
|
||
}
|
||
|
||
_initPatchRange() {
|
||
let leftSide = false;
|
||
if (!this._change) return;
|
||
if (this.params?.view !== GerritView.DIFF) return;
|
||
if (this.params?.commentId) {
|
||
const comment = this._changeComments?.findCommentById(
|
||
this.params.commentId
|
||
);
|
||
if (!comment) {
|
||
fireAlert(this, 'comment not found');
|
||
GerritNav.navigateToChange(this._change);
|
||
return;
|
||
}
|
||
this._path = comment.path;
|
||
|
||
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
|
||
if (!latestPatchNum) throw new Error('Missing _allPatchSets');
|
||
this._patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
|
||
leftSide = isInBaseOfPatchRange(comment, this._patchRange);
|
||
|
||
this._focusLineNum = comment.line;
|
||
} else {
|
||
if (this.params.path) {
|
||
this._path = this.params.path;
|
||
}
|
||
if (this.params.patchNum) {
|
||
this._patchRange = {
|
||
patchNum: this.params.patchNum,
|
||
basePatchNum: this.params.basePatchNum || ParentPatchSetNum,
|
||
};
|
||
}
|
||
if (this.params.lineNum) {
|
||
this._focusLineNum = this.params.lineNum;
|
||
leftSide = !!this.params.leftSide;
|
||
}
|
||
}
|
||
if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
|
||
this._initLineOfInterestAndCursor(leftSide);
|
||
|
||
if (this.params?.commentId) {
|
||
// url is of type /comment/{commentId} which isn't meaningful
|
||
this._updateUrlToDiffUrl(this._focusLineNum, leftSide);
|
||
}
|
||
|
||
this._commentMap = this._getPaths(this._patchRange);
|
||
}
|
||
|
||
_isFileUnchanged(diff: DiffInfo) {
|
||
if (!diff || !diff.content) return false;
|
||
return !diff.content.some(
|
||
content =>
|
||
(content.a && !content.common) || (content.b && !content.common)
|
||
);
|
||
}
|
||
|
||
_paramsChanged(value: AppElementParams) {
|
||
if (value.view !== GerritView.DIFF) {
|
||
return;
|
||
}
|
||
|
||
this._change = undefined;
|
||
this._files = {sortedFileList: [], changeFilesByPath: {}};
|
||
this._path = undefined;
|
||
this._patchRange = undefined;
|
||
this._commitRange = undefined;
|
||
this._changeComments = undefined;
|
||
this._focusLineNum = undefined;
|
||
|
||
if (value.changeNum && value.project) {
|
||
this.restApiService.setInProjectLookup(value.changeNum, value.project);
|
||
}
|
||
|
||
this._changeNum = value.changeNum;
|
||
this.classList.remove('hideComments');
|
||
|
||
// When navigating away from the page, there is a possibility that the
|
||
// patch number is no longer a part of the URL (say when navigating to
|
||
// the top-level change info view) and therefore undefined in `params`.
|
||
// If route is of type /comment/<commentId>/ then no patchNum is present
|
||
if (!value.patchNum && !value.commentLink) {
|
||
console.warn('invalid url, no patchNum found');
|
||
return;
|
||
}
|
||
|
||
const promises: Promise<unknown>[] = [];
|
||
|
||
promises.push(this._getDiffPreferences());
|
||
|
||
promises.push(
|
||
this._getPreferences().then(prefs => {
|
||
this._userPrefs = prefs;
|
||
})
|
||
);
|
||
|
||
promises.push(this._getChangeDetail(this._changeNum));
|
||
promises.push(this._loadComments(value.patchNum));
|
||
|
||
promises.push(this._getChangeEdit());
|
||
|
||
this.$.diffHost.cancel();
|
||
this.$.diffHost.clearDiffContent();
|
||
this._loading = true;
|
||
return Promise.all(promises)
|
||
.then(r => {
|
||
this._loading = false;
|
||
this._initPatchRange();
|
||
this._initCommitRange();
|
||
|
||
if (!this._path) throw new Error('path must be defined');
|
||
if (!this._changeComments)
|
||
throw new Error('change comments must be defined');
|
||
if (!this._patchRange) throw new Error('patch range must be defined');
|
||
|
||
// TODO(dhruvsri): check if basePath should be set here
|
||
this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
|
||
{path: this._path},
|
||
this._patchRange
|
||
);
|
||
|
||
const edit = r[4] as EditInfo | undefined;
|
||
if (edit) {
|
||
this.set(`_change.revisions.${edit.commit.commit}`, {
|
||
_number: EditPatchSetNum,
|
||
basePatchNum: edit.base_patch_set_number,
|
||
commit: edit.commit,
|
||
});
|
||
}
|
||
return this.$.diffHost.reload(true);
|
||
})
|
||
.then(() => {
|
||
this.reporting.diffViewFullyLoaded();
|
||
// If diff view displayed has not ended yet, it ends here.
|
||
this.reporting.diffViewDisplayed();
|
||
})
|
||
.then(() => {
|
||
if (!this._diff) throw new Error('Missing this._diff');
|
||
const fileUnchanged = this._isFileUnchanged(this._diff);
|
||
if (fileUnchanged && value.commentLink) {
|
||
if (!this._change) throw new Error('Missing this._change');
|
||
if (!this._path) throw new Error('Missing this._path');
|
||
if (!this._patchRange) throw new Error('Missing this._patchRange');
|
||
|
||
if (this._patchRange.basePatchNum === ParentPatchSetNum) {
|
||
// file is unchanged between Base vs X
|
||
// hence should not show diff between Base vs Base
|
||
return;
|
||
}
|
||
|
||
fireAlert(
|
||
this,
|
||
`File is unchanged between Patchset
|
||
${this._patchRange.basePatchNum} and
|
||
${this._patchRange.patchNum}. Showing diff of Base vs
|
||
${this._patchRange.basePatchNum}`
|
||
);
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
this._path,
|
||
this._patchRange.basePatchNum,
|
||
ParentPatchSetNum,
|
||
this._focusLineNum
|
||
);
|
||
return;
|
||
}
|
||
if (value.commentLink) {
|
||
this._displayToasts();
|
||
}
|
||
// If the blame was loaded for a previous file and user navigates to
|
||
// another file, then we load the blame for this file too
|
||
if (this._isBlameLoaded) this._loadBlame();
|
||
});
|
||
}
|
||
|
||
_changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
|
||
if (changeViewState.diffMode === null) {
|
||
// If screen size is small, always default to unified view.
|
||
this.restApiService.getPreferences().then(prefs => {
|
||
if (prefs) {
|
||
this.set('changeViewState.diffMode', prefs.default_diff_view);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
@observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
|
||
_setReviewedObserver(
|
||
_loggedIn?: boolean,
|
||
paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
|
||
_prefs?: DiffPreferencesInfo,
|
||
patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
|
||
) {
|
||
if (_loggedIn === undefined) return;
|
||
if (paramsRecord === undefined) return;
|
||
if (_prefs === undefined) return;
|
||
if (patchRangeRecord === undefined) return;
|
||
if (patchRangeRecord.base === undefined) return;
|
||
|
||
const patchRange = patchRangeRecord.base;
|
||
if (!_loggedIn) {
|
||
return;
|
||
}
|
||
|
||
if (_prefs.manual_review) {
|
||
// Checkbox state needs to be set explicitly only when manual_review
|
||
// is specified.
|
||
|
||
if (patchRange.patchNum) {
|
||
this._getReviewedStatus(
|
||
this._editMode,
|
||
this._changeNum,
|
||
patchRange.patchNum,
|
||
this._path
|
||
).then((status: boolean) => {
|
||
this.$.reviewed.checked = status;
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (paramsRecord.base?.view === GerritNav.View.DIFF) {
|
||
this._setReviewed(true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* If the params specify a diff address then configure the diff cursor.
|
||
*/
|
||
_initCursor(leftSide: boolean) {
|
||
if (this._focusLineNum === undefined) {
|
||
return;
|
||
}
|
||
if (leftSide) {
|
||
this.$.cursor.side = Side.LEFT;
|
||
} else {
|
||
this.$.cursor.side = Side.RIGHT;
|
||
}
|
||
this.$.cursor.initialLineNumber = this._focusLineNum;
|
||
}
|
||
|
||
_getLineOfInterest(leftSide: boolean): LineOfInterest | 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 {number: this._focusLineNum, leftSide};
|
||
}
|
||
|
||
_pathChanged(path: string) {
|
||
if (path) {
|
||
fireTitleChange(this, computeTruncatedPath(path));
|
||
}
|
||
|
||
if (!this._fileList || this._fileList.length === 0) return;
|
||
|
||
this.set('changeViewState.selectedFileIndex', this._fileList.indexOf(path));
|
||
}
|
||
|
||
_getDiffUrl(change?: ChangeInfo, patchRange?: PatchRange, path?: string) {
|
||
if (!change || !patchRange || !path) return '';
|
||
return GerritNav.getUrlForDiff(
|
||
change,
|
||
path,
|
||
patchRange.patchNum,
|
||
patchRange.basePatchNum
|
||
);
|
||
}
|
||
|
||
_patchRangeStr(patchRange: PatchRange) {
|
||
let patchStr = `${patchRange.patchNum}`;
|
||
if (
|
||
patchRange.basePatchNum &&
|
||
patchRange.basePatchNum !== ParentPatchSetNum
|
||
) {
|
||
patchStr = `${patchRange.basePatchNum}..${patchRange.patchNum}`;
|
||
}
|
||
return patchStr;
|
||
}
|
||
|
||
/**
|
||
* When the latest patch of the change is selected (and there is no base
|
||
* patch) then the patch range need not appear in the URL. Return a patch
|
||
* range object with undefined values when a range is not needed.
|
||
*/
|
||
_getChangeUrlRange(
|
||
patchRange?: PatchRange,
|
||
revisions?: {[revisionId: string]: RevisionInfo}
|
||
) {
|
||
let patchNum = undefined;
|
||
let basePatchNum = undefined;
|
||
let latestPatchNum = -1;
|
||
for (const rev of Object.values(revisions || {})) {
|
||
if (typeof rev._number === 'number') {
|
||
latestPatchNum = Math.max(latestPatchNum, rev._number);
|
||
}
|
||
}
|
||
if (!patchRange) return {patchNum, basePatchNum};
|
||
if (
|
||
patchRange.basePatchNum !== ParentPatchSetNum ||
|
||
patchRange.patchNum !== latestPatchNum
|
||
) {
|
||
patchNum = patchRange.patchNum;
|
||
basePatchNum = patchRange.basePatchNum;
|
||
}
|
||
return {patchNum, basePatchNum};
|
||
}
|
||
|
||
_getChangePath(
|
||
change?: ChangeInfo,
|
||
patchRange?: PatchRange,
|
||
revisions?: {[revisionId: string]: RevisionInfo}
|
||
) {
|
||
if (!change) return '';
|
||
if (!patchRange) return '';
|
||
|
||
const range = this._getChangeUrlRange(patchRange, revisions);
|
||
return GerritNav.getUrlForChange(
|
||
change,
|
||
range.patchNum,
|
||
range.basePatchNum
|
||
);
|
||
}
|
||
|
||
_navigateToChange(
|
||
change?: ChangeInfo,
|
||
patchRange?: PatchRange,
|
||
revisions?: {[revisionId: string]: RevisionInfo}
|
||
) {
|
||
if (!change) return;
|
||
const range = this._getChangeUrlRange(patchRange, revisions);
|
||
GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
|
||
}
|
||
|
||
_computeChangePath(
|
||
change?: ChangeInfo,
|
||
patchRangeRecord?: PolymerDeepPropertyChange<PatchRange, PatchRange>,
|
||
revisions?: {[revisionId: string]: RevisionInfo}
|
||
) {
|
||
if (!patchRangeRecord) return '';
|
||
return this._getChangePath(change, patchRangeRecord.base, revisions);
|
||
}
|
||
|
||
_formatFilesForDropdown(
|
||
files?: Files,
|
||
patchRange?: PatchRange,
|
||
changeComments?: ChangeComments
|
||
): DropdownItem[] {
|
||
if (!files) return [];
|
||
if (!patchRange) return [];
|
||
if (!changeComments) return [];
|
||
|
||
const dropdownContent: DropdownItem[] = [];
|
||
for (const path of files.sortedFileList) {
|
||
dropdownContent.push({
|
||
text: computeDisplayPath(path),
|
||
mobileText: computeTruncatedPath(path),
|
||
value: path,
|
||
bottomText: changeComments.computeCommentsString(
|
||
patchRange,
|
||
path,
|
||
files.changeFilesByPath[path],
|
||
/* includeUnmodified= */ true
|
||
),
|
||
file: {...files.changeFilesByPath[path], __path: path},
|
||
});
|
||
}
|
||
return dropdownContent;
|
||
}
|
||
|
||
_computePrefsButtonHidden(
|
||
prefs?: DiffPreferencesInfo,
|
||
prefsDisabled?: boolean
|
||
) {
|
||
return prefsDisabled || !prefs;
|
||
}
|
||
|
||
_handleFileChange(e: CustomEvent) {
|
||
if (!this._change) return;
|
||
if (!this._patchRange) return;
|
||
|
||
// This is when it gets set initially.
|
||
const path = e.detail.value;
|
||
if (path === this._path) {
|
||
return;
|
||
}
|
||
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
path,
|
||
this._patchRange.patchNum,
|
||
this._patchRange.basePatchNum
|
||
);
|
||
}
|
||
|
||
_handlePatchChange(e: CustomEvent) {
|
||
if (!this._change) return;
|
||
if (!this._path) return;
|
||
if (!this._patchRange) return;
|
||
|
||
const {basePatchNum, patchNum} = e.detail;
|
||
if (
|
||
basePatchNum === this._patchRange.basePatchNum &&
|
||
patchNum === this._patchRange.patchNum
|
||
) {
|
||
return;
|
||
}
|
||
GerritNav.navigateToDiff(this._change, this._path, patchNum, basePatchNum);
|
||
}
|
||
|
||
_handlePrefsTap(e: Event) {
|
||
e.preventDefault();
|
||
this.$.diffPreferencesDialog.open();
|
||
}
|
||
|
||
/**
|
||
* _getDiffViewMode: Get the diff view (side-by-side or unified) based on
|
||
* the current state.
|
||
*
|
||
* The expected behavior is to use the mode specified in the user's
|
||
* preferences unless they have manually chosen the alternative view or they
|
||
* are on a mobile device. If the user navigates up to the change view, it
|
||
* should clear this choice and revert to the preference the next time a
|
||
* diff is viewed.
|
||
*
|
||
* Use side-by-side if the user is not logged in.
|
||
*/
|
||
_getDiffViewMode() {
|
||
if (this.changeViewState.diffMode) {
|
||
return this.changeViewState.diffMode;
|
||
} else if (this._userPrefs) {
|
||
this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
|
||
return this._userPrefs.default_diff_view;
|
||
} else {
|
||
return 'SIDE_BY_SIDE';
|
||
}
|
||
}
|
||
|
||
_computeModeSelectHideClass(diff?: DiffInfo) {
|
||
return !diff || diff.binary ? 'hide' : '';
|
||
}
|
||
|
||
_onLineSelected(
|
||
_: Event,
|
||
detail: {side: Side | CommentSide; number: number}
|
||
) {
|
||
// for on-comment-anchor-tap side can be PARENT/REVISIONS
|
||
// for on-line-selected side can be left/right
|
||
this._updateUrlToDiffUrl(
|
||
detail.number,
|
||
detail.side === Side.LEFT || detail.side === CommentSide.PARENT
|
||
);
|
||
}
|
||
|
||
_computeDownloadDropdownLinks(
|
||
project?: RepoName,
|
||
changeNum?: NumericChangeId,
|
||
patchRange?: PatchRange,
|
||
path?: string,
|
||
diff?: DiffInfo
|
||
) {
|
||
if (!project) return [];
|
||
if (!changeNum) return [];
|
||
if (!patchRange || !patchRange.patchNum) return [];
|
||
if (!path) return [];
|
||
|
||
const links = [
|
||
{
|
||
url: this._computeDownloadPatchLink(
|
||
project,
|
||
changeNum,
|
||
patchRange,
|
||
path
|
||
),
|
||
name: 'Patch',
|
||
},
|
||
];
|
||
|
||
if (diff && diff.meta_a) {
|
||
let leftPath = path;
|
||
if (diff.change_type === 'RENAMED') {
|
||
leftPath = diff.meta_a.name;
|
||
}
|
||
links.push({
|
||
url: this._computeDownloadFileLink(
|
||
project,
|
||
changeNum,
|
||
patchRange,
|
||
leftPath,
|
||
true
|
||
),
|
||
name: 'Left Content',
|
||
});
|
||
}
|
||
|
||
if (diff && diff.meta_b) {
|
||
links.push({
|
||
url: this._computeDownloadFileLink(
|
||
project,
|
||
changeNum,
|
||
patchRange,
|
||
path,
|
||
false
|
||
),
|
||
name: 'Right Content',
|
||
});
|
||
}
|
||
|
||
return links;
|
||
}
|
||
|
||
_computeDownloadFileLink(
|
||
project: RepoName,
|
||
changeNum: NumericChangeId,
|
||
patchRange: PatchRange,
|
||
path: string,
|
||
isBase?: boolean
|
||
) {
|
||
let patchNum = patchRange.patchNum;
|
||
|
||
const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
|
||
|
||
if (isBase && !comparedAgainsParent) {
|
||
patchNum = patchRange.basePatchNum;
|
||
}
|
||
|
||
let url =
|
||
changeBaseURL(project, changeNum, patchNum) +
|
||
`/files/${encodeURIComponent(path)}/download`;
|
||
|
||
if (isBase && comparedAgainsParent) {
|
||
url += '?parent=1';
|
||
}
|
||
|
||
return url;
|
||
}
|
||
|
||
_computeDownloadPatchLink(
|
||
project: RepoName,
|
||
changeNum: NumericChangeId,
|
||
patchRange: PatchRange,
|
||
path: string
|
||
) {
|
||
let url = changeBaseURL(project, changeNum, patchRange.patchNum);
|
||
url += '/patch?zip&path=' + encodeURIComponent(path);
|
||
return url;
|
||
}
|
||
|
||
_loadComments(patchSet?: PatchSetNum) {
|
||
if (!this._changeNum) throw new Error('Missing this._changeNum');
|
||
return this.$.commentAPI
|
||
.loadAll(this._changeNum, patchSet)
|
||
.then(comments => {
|
||
this._changeComments = comments;
|
||
});
|
||
}
|
||
|
||
@observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
|
||
_recomputeComments(
|
||
files?: {[path: string]: FileInfo},
|
||
path?: string,
|
||
patchRange?: PatchRange,
|
||
projectConfig?: ConfigInfo
|
||
) {
|
||
if (!files) return;
|
||
if (!path) return;
|
||
if (!patchRange) return;
|
||
if (!projectConfig) return;
|
||
if (!this._changeComments) return;
|
||
|
||
const file = files[path];
|
||
if (file && file.old_path) {
|
||
this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
|
||
{path, basePath: file.old_path},
|
||
patchRange
|
||
);
|
||
}
|
||
}
|
||
|
||
_getPaths(patchRange: PatchRange) {
|
||
if (!this._changeComments) return {};
|
||
return this._changeComments.getPaths(patchRange);
|
||
}
|
||
|
||
_getDiffDrafts() {
|
||
if (!this._changeNum) throw new Error('Missing this._changeNum');
|
||
|
||
return this.restApiService.getDiffDrafts(this._changeNum);
|
||
}
|
||
|
||
_computeCommentSkips(
|
||
commentMap?: CommentMap,
|
||
fileList?: string[],
|
||
path?: string
|
||
) {
|
||
if (!commentMap) return undefined;
|
||
if (!fileList) return undefined;
|
||
if (!path) return undefined;
|
||
|
||
const skips: CommentSkips = {previous: null, next: null};
|
||
if (!fileList.length) {
|
||
return skips;
|
||
}
|
||
const pathIndex = fileList.indexOf(path);
|
||
|
||
// Scan backward for the previous file.
|
||
for (let i = pathIndex - 1; i >= 0; i--) {
|
||
if (commentMap[fileList[i]]) {
|
||
skips.previous = fileList[i];
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Scan forward for the next file.
|
||
for (let i = pathIndex + 1; i < fileList.length; i++) {
|
||
if (commentMap[fileList[i]]) {
|
||
skips.next = fileList[i];
|
||
break;
|
||
}
|
||
}
|
||
|
||
return skips;
|
||
}
|
||
|
||
_computeContainerClass(editMode: boolean) {
|
||
return editMode ? 'editMode' : '';
|
||
}
|
||
|
||
_computeEditMode(
|
||
patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
|
||
) {
|
||
const patchRange = patchRangeRecord.base || {};
|
||
return patchRange.patchNum === EditPatchSetNum;
|
||
}
|
||
|
||
_computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
|
||
return loaded && !loading ? 'Hide blame' : 'Show blame';
|
||
}
|
||
|
||
_loadBlame() {
|
||
this._isBlameLoading = true;
|
||
fireAlert(this, MSG_LOADING_BLAME);
|
||
this.$.diffHost
|
||
.loadBlame()
|
||
.then(() => {
|
||
this._isBlameLoading = false;
|
||
fireAlert(this, MSG_LOADED_BLAME);
|
||
})
|
||
.catch(() => {
|
||
this._isBlameLoading = false;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Load and display blame information if it has not already been loaded.
|
||
* Otherwise hide it.
|
||
*/
|
||
_toggleBlame() {
|
||
if (this._isBlameLoaded) {
|
||
this.$.diffHost.clearBlame();
|
||
return;
|
||
}
|
||
this._loadBlame();
|
||
}
|
||
|
||
_handleToggleBlame(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
|
||
this._toggleBlame();
|
||
}
|
||
|
||
_handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
|
||
this.toggleClass('hideComments');
|
||
}
|
||
|
||
_handleOpenFileList(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (this.modifierPressed(e)) return;
|
||
this.$.dropdown.open();
|
||
}
|
||
|
||
_handleDiffAgainstBase(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (!this._change) return;
|
||
if (!this._path) return;
|
||
if (!this._patchRange) return;
|
||
|
||
if (this._patchRange.basePatchNum === ParentPatchSetNum) {
|
||
fireAlert(this, 'Base is already selected.');
|
||
return;
|
||
}
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
this._path,
|
||
this._patchRange.patchNum
|
||
);
|
||
}
|
||
|
||
_handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (!this._change) return;
|
||
if (!this._path) return;
|
||
if (!this._patchRange) return;
|
||
|
||
if (this._patchRange.basePatchNum === ParentPatchSetNum) {
|
||
fireAlert(this, 'Left is already base.');
|
||
return;
|
||
}
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
this._path,
|
||
this._patchRange.basePatchNum,
|
||
'PARENT' as PatchSetNum,
|
||
this.params?.view === GerritView.DIFF && this.params?.commentLink
|
||
? this._focusLineNum
|
||
: undefined
|
||
);
|
||
}
|
||
|
||
_handleDiffAgainstLatest(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (!this._change) return;
|
||
if (!this._path) return;
|
||
if (!this._patchRange) return;
|
||
|
||
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
|
||
if (this._patchRange.patchNum === latestPatchNum) {
|
||
fireAlert(this, 'Latest is already selected.');
|
||
return;
|
||
}
|
||
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
this._path,
|
||
latestPatchNum,
|
||
this._patchRange.basePatchNum
|
||
);
|
||
}
|
||
|
||
_handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (!this._change) return;
|
||
if (!this._path) return;
|
||
if (!this._patchRange) return;
|
||
|
||
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
|
||
if (this._patchRange.patchNum === latestPatchNum) {
|
||
fireAlert(this, 'Right is already latest.');
|
||
return;
|
||
}
|
||
GerritNav.navigateToDiff(
|
||
this._change,
|
||
this._path,
|
||
latestPatchNum,
|
||
this._patchRange.patchNum
|
||
);
|
||
}
|
||
|
||
_handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (!this._change) return;
|
||
if (!this._path) return;
|
||
if (!this._patchRange) return;
|
||
|
||
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
|
||
if (
|
||
this._patchRange.patchNum === latestPatchNum &&
|
||
this._patchRange.basePatchNum === ParentPatchSetNum
|
||
) {
|
||
fireAlert(this, 'Already diffing base against latest.');
|
||
return;
|
||
}
|
||
GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
|
||
}
|
||
|
||
_computeBlameLoaderClass(isImageDiff?: boolean, path?: string) {
|
||
return !isMagicPath(path) && !isImageDiff ? 'show' : '';
|
||
}
|
||
|
||
_getRevisionInfo(change: ChangeInfo) {
|
||
return new RevisionInfoObj(change);
|
||
}
|
||
|
||
_computeFileNum(file?: string, files?: DropdownItem[]) {
|
||
if (!file || !files) return undefined;
|
||
|
||
return files.findIndex(({value}) => value === file) + 1;
|
||
}
|
||
|
||
_computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
|
||
if (files && fileNum && fileNum > 0) {
|
||
return 'show';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
_handleExpandAllDiffContext(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
|
||
this.$.diffHost.expandAllContext();
|
||
}
|
||
|
||
_computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
|
||
return disableDiffPrefs || !loggedIn;
|
||
}
|
||
|
||
_handleNextUnreviewedFile(e: CustomKeyboardEvent) {
|
||
if (this.shouldSuppressKeyboardShortcut(e)) return;
|
||
if (!this._path) return;
|
||
if (!this._fileList) return;
|
||
if (!this._reviewedFiles) return;
|
||
|
||
this._setReviewed(true);
|
||
// Ensure that the currently viewed file always appears in unreviewedFiles
|
||
// so we resolve the right "next" file.
|
||
const unreviewedFiles = this._fileList.filter(
|
||
file => file === this._path || !this._reviewedFiles.has(file)
|
||
);
|
||
this._navToFile(this._path, unreviewedFiles, 1);
|
||
}
|
||
|
||
_handleReloadingDiffPreference() {
|
||
this._getDiffPreferences();
|
||
}
|
||
|
||
_computeCanEdit(
|
||
loggedIn?: boolean,
|
||
changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
|
||
) {
|
||
if (!changeChangeRecord?.base) return false;
|
||
return loggedIn && changeIsOpen(changeChangeRecord.base);
|
||
}
|
||
|
||
_computeIsLoggedIn(loggedIn: boolean) {
|
||
return loggedIn ? true : false;
|
||
}
|
||
|
||
/**
|
||
* Wrapper for using in the element template and computed properties
|
||
*/
|
||
_computeAllPatchSets(change: ChangeInfo) {
|
||
return computeAllPatchSets(change);
|
||
}
|
||
|
||
/**
|
||
* 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 path ? computeTruncatedPath(path) : '';
|
||
}
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
'gr-diff-view': GrDiffView;
|
||
}
|
||
}
|