blob: 95e813af3e7fe45acbe5a45c676032cca259bbd8 [file] [log] [blame]
// Copyright (C) 2016 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.
(function() {
'use strict';
const COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
const MERGE_LIST_PATH = '/MERGE_LIST';
const COMMENT_SAVE = 'Try again when all comments have saved.';
const DiffSides = {
LEFT: 'left',
RIGHT: 'right',
};
const HASH_PATTERN = /^[ab]?\d+$/;
Polymer({
is: 'gr-diff-view',
/**
* 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
*/
properties: {
/**
* URL params passed from the router.
*/
params: {
type: Object,
observer: '_paramsChanged',
},
keyEventTarget: {
type: Object,
value() { return document.body; },
},
changeViewState: {
type: Object,
notify: true,
value() { return {}; },
},
_patchRange: Object,
_change: Object,
_changeNum: String,
_diff: Object,
_fileList: {
type: Array,
value() { return []; },
},
_path: {
type: String,
observer: '_pathChanged',
},
_loggedIn: {
type: Boolean,
value: false,
},
_loading: {
type: Boolean,
value: true,
},
_prefs: Object,
_localPrefs: Object,
_projectConfig: Object,
_userPrefs: Object,
_diffMode: {
type: String,
computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
},
_isImageDiff: Boolean,
_filesWeblinks: Object,
/**
* Map of paths in the current change and patch range that have comments
* or drafts or robot comments.
*/
_commentMap: Object,
/**
* Object to contain the path of the next and previous file in the current
* change and patch range that has comments.
*/
_commentSkips: {
type: Object,
computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
},
},
behaviors: [
Gerrit.BaseUrlBehavior,
Gerrit.KeyboardShortcutBehavior,
Gerrit.RESTClientBehavior,
Gerrit.URLEncodingBehavior,
],
observers: [
'_getProjectConfig(_change.project)',
'_getFiles(_changeNum, _patchRange.*)',
],
keyBindings: {
'esc': '_handleEscKey',
'shift+left': '_handleShiftLeftKey',
'shift+right': '_handleShiftRightKey',
'up k': '_handleUpKey',
'down j': '_handleDownKey',
'c': '_handleCKey',
'[': '_handleLeftBracketKey',
']': '_handleRightBracketKey',
'n shift+n': '_handleNKey',
'p shift+p': '_handlePKey',
'a shift+a': '_handleAKey',
'u': '_handleUKey',
',': '_handleCommaKey',
},
attached() {
this._getLoggedIn().then(loggedIn => {
this._loggedIn = loggedIn;
if (loggedIn) {
this._setReviewed(true);
}
});
if (this.changeViewState.diffMode === null) {
// If screen size is small, always default to unified view.
this.$.restAPI.getPreferences().then(prefs => {
this.set('changeViewState.diffMode', prefs.default_diff_view);
});
}
if (this._path) {
this.fire('title-change',
{title: this._computeFileDisplayName(this._path)});
}
this.$.cursor.push('diffs', this.$.diff);
},
_getLoggedIn() {
return this.$.restAPI.getLoggedIn();
},
_getProjectConfig(project) {
return this.$.restAPI.getProjectConfig(project).then(
config => {
this._projectConfig = config;
});
},
_getChangeDetail(changeNum) {
return this.$.restAPI.getDiffChangeDetail(changeNum).then(
change => {
this._change = change;
});
},
_getFiles(changeNum, patchRangeRecord) {
const patchRange = patchRangeRecord.base;
return this.$.restAPI.getChangeFilePathsAsSpeciallySortedArray(
changeNum, patchRange).then(files => {
this._fileList = files;
});
},
_getDiffPreferences() {
return this.$.restAPI.getDiffPreferences();
},
_getPreferences() {
return this.$.restAPI.getPreferences();
},
_getWindowWidth() {
return window.innerWidth;
},
_handleReviewedChange(e) {
this._setReviewed(Polymer.dom(e).rootTarget.checked);
},
_setReviewed(reviewed) {
this.$.reviewed.checked = reviewed;
this._saveReviewedState(reviewed).catch(err => {
alert('Couldn’t change file review status. Check the console ' +
'and contact the PolyGerrit team for assistance.');
throw err;
});
},
_saveReviewedState(reviewed) {
return this.$.restAPI.saveFileReviewed(this._changeNum,
this._patchRange.patchNum, this._path, reviewed);
},
_handleEscKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.diff.displayLine = false;
},
_handleShiftLeftKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
this.$.cursor.moveLeft();
},
_handleShiftRightKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
this.$.cursor.moveRight();
},
_handleUpKey(e) {
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.$.diff.displayLine = true;
this.$.cursor.moveUp();
},
_handleDownKey(e) {
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.$.diff.displayLine = true;
this.$.cursor.moveDown();
},
_moveToPreviousFileWithComment() {
if (this._commentSkips && this._commentSkips.previous) {
page.show(this._getDiffURL(this._changeNum, this._patchRange,
this._commentSkips.previous));
}
},
_moveToNextFileWithComment() {
if (this._commentSkips && this._commentSkips.next) {
page.show(this._getDiffURL(this._changeNum, this._patchRange,
this._commentSkips.next));
}
},
_handleCKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
if (this.$.diff.isRangeSelected()) { return; }
if (this.modifierPressed(e)) { return; }
e.preventDefault();
const line = this.$.cursor.getTargetLineElement();
if (line) {
this.$.diff.addDraftAtLine(line);
}
},
_handleLeftBracketKey(e) {
// Check for meta key to avoid overriding native chrome shortcut.
if (this.shouldSuppressKeyboardShortcut(e) ||
this.getKeyboardEvent(e).metaKey) { return; }
e.preventDefault();
this._navToFile(this._path, this._fileList, -1);
},
_handleRightBracketKey(e) {
// Check for meta key to avoid overriding native chrome shortcut.
if (this.shouldSuppressKeyboardShortcut(e) ||
this.getKeyboardEvent(e).metaKey) { return; }
e.preventDefault();
this._navToFile(this._path, this._fileList, 1);
},
_handleNKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
if (e.detail.keyboardEvent.shiftKey) {
this.$.cursor.moveToNextCommentThread();
} else {
if (this.modifierPressed(e)) { return; }
this.$.cursor.moveToNextChunk();
}
},
_handlePKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
if (e.detail.keyboardEvent.shiftKey) {
this.$.cursor.moveToPreviousCommentThread();
} else {
if (this.modifierPressed(e)) { return; }
this.$.cursor.moveToPreviousChunk();
}
},
_handleAKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
e.preventDefault();
this.$.diff.toggleLeftDiff();
return;
}
if (this.modifierPressed(e)) { return; }
if (!this._loggedIn) { return; }
if (this.$.restAPI.hasPendingDiffDrafts()) {
this.dispatchEvent(new CustomEvent('show-alert',
{detail: {message: COMMENT_SAVE}, bubbles: true}));
return;
}
this.set('changeViewState.showReplyDialog', true);
e.preventDefault();
this._navToChangeView();
},
_handleUKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this._navToChangeView();
},
_handleCommaKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.diffPreferences.open();
},
_navToChangeView() {
if (!this._changeNum || !this._patchRange.patchNum) { return; }
page.show(this._getChangePath(
this._changeNum,
this._patchRange,
this._change && this._change.revisions));
},
_computeUpURL(changeNum, patchRange, change, changeRevisions) {
return this._getChangePath(
changeNum,
patchRange,
change && changeRevisions);
},
_navToFile(path, fileList, direction) {
const url = this._computeNavLinkURL(path, fileList, direction);
if (!url) { return; }
page.show(this._computeNavLinkURL(path, fileList, direction));
},
/**
* @param {?string} path The path of the current file being shown.
* @param {Array.<string>} fileList The list of files in this change and
* patch range.
* @param {number} direction Either 1 (next file) or -1 (prev file).
* @param {(number|boolean)} opt_noUp Whether to return to the change view
* when advancing the file goes outside the bounds of fileList.
*
* @return {?string} The next URL when proceeding in the specified
* direction.
*/
_computeNavLinkURL(path, fileList, direction, opt_noUp) {
if (!path || fileList.length === 0) { return null; }
let idx = fileList.indexOf(path);
if (idx === -1) {
const file = direction > 0 ?
fileList[0] :
fileList[fileList.length - 1];
return this._getDiffURL(this._changeNum, this._patchRange, 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) {
if (opt_noUp) { return null; }
return this._getChangePath(
this._changeNum,
this._patchRange,
this._change && this._change.revisions);
}
return this._getDiffURL(this._changeNum, this._patchRange, fileList[idx]);
},
_paramsChanged(value) {
if (value.view != this.tagName.toLowerCase()) { return; }
this._loadHash(location.hash);
this._changeNum = value.changeNum;
this._patchRange = {
patchNum: value.patchNum,
basePatchNum: value.basePatchNum || 'PARENT',
};
this._path = value.path;
this.fire('title-change',
{title: this._computeFileDisplayName(this._path)});
// 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 (!this._patchRange.patchNum) {
return;
}
const promises = [];
this._localPrefs = this.$.storage.getPreferences();
promises.push(this._getDiffPreferences().then(prefs => {
this._prefs = prefs;
}));
promises.push(this._getPreferences().then(prefs => {
this._userPrefs = prefs;
}));
promises.push(this._getChangeDetail(this._changeNum));
Promise.all(promises).then(() => {
this._loading = false;
this.$.diff.reload();
});
this._loadCommentMap().then(commentMap => {
this._commentMap = commentMap;
});
},
/**
* If the URL hash is a diff address then configure the diff cursor.
*/
_loadHash(hash) {
hash = hash.replace(/^#/, '');
if (!HASH_PATTERN.test(hash)) { return; }
if (hash[0] === 'a' || hash[0] === 'b') {
this.$.cursor.side = DiffSides.LEFT;
hash = hash.substring(1);
} else {
this.$.cursor.side = DiffSides.RIGHT;
}
this.$.cursor.initialLineNumber = parseInt(hash, 10);
},
_pathChanged(path) {
if (this._fileList.length == 0) { return; }
this.set('changeViewState.selectedFileIndex',
this._fileList.indexOf(path));
if (this._loggedIn) {
this._setReviewed(true);
}
},
_getDiffURL(changeNum, patchRange, path) {
return this.getBaseUrl() + '/c/' + changeNum + '/' +
this._patchRangeStr(patchRange) + '/' + this.encodeURL(path, true);
},
_computeDiffURL(changeNum, patchRangeRecord, path) {
return this._getDiffURL(changeNum, patchRangeRecord.base, path);
},
_patchRangeStr(patchRange) {
let patchStr = patchRange.patchNum;
if (patchRange.basePatchNum != null &&
patchRange.basePatchNum != 'PARENT') {
patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
}
return patchStr;
},
_computeAvailablePatches(revisions) {
const patchNums = [];
for (const rev of Object.values(revisions)) {
patchNums.push(rev._number);
}
return patchNums.sort((a, b) => { return a - b; });
},
_getChangePath(changeNum, patchRange, revisions) {
const base = this.getBaseUrl() + '/c/' + changeNum + '/';
// The change may not have loaded yet, making revisions unavailable.
if (!revisions) {
return base + this._patchRangeStr(patchRange);
}
let latestPatchNum = -1;
for (const rev of Object.values(revisions)) {
latestPatchNum = Math.max(latestPatchNum, rev._number);
}
if (patchRange.basePatchNum !== 'PARENT' ||
parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
return base + this._patchRangeStr(patchRange);
}
return base;
},
_computeChangePath(changeNum, patchRangeRecord, revisions) {
return this._getChangePath(changeNum, patchRangeRecord.base, revisions);
},
_computeFileDisplayName(path) {
if (path === COMMIT_MESSAGE_PATH) {
return 'Commit message';
} else if (path === MERGE_LIST_PATH) {
return 'Merge list';
}
return path;
},
_computeTruncatedFileDisplayName(path) {
return util.truncatePath(this._computeFileDisplayName(path));
},
_computeFileSelected(path, currentPath) {
return path == currentPath;
},
_computePrefsButtonHidden(prefs, loggedIn) {
return !loggedIn || !prefs;
},
_computeKeyNav(path, selectedPath, fileList) {
const selectedIndex = fileList.indexOf(selectedPath);
if (fileList.indexOf(path) == selectedIndex - 1) {
return '[';
}
if (fileList.indexOf(path) == selectedIndex + 1) {
return ']';
}
return '';
},
_handleFileTap(e) {
// async is needed so that that the click event is fired before the
// dropdown closes (This was a bug for touch devices).
this.async(() => {
this.$.dropdown.close();
}, 1);
},
_handleMobileSelectChange(e) {
const path = Polymer.dom(e).rootTarget.value;
page.show(this._getDiffURL(this._changeNum, this._patchRange, path));
},
_showDropdownTapHandler(e) {
this.$.dropdown.open();
},
_handlePrefsTap(e) {
e.preventDefault();
this.$.diffPreferences.open();
},
_handlePrefsSave(e) {
e.stopPropagation();
const el = Polymer.dom(e).rootTarget;
el.disabled = true;
this.$.storage.savePreferences(this._localPrefs);
this._saveDiffPreferences().then(response => {
el.disabled = false;
if (!response.ok) { return response; }
this.$.prefsOverlay.close();
}).catch(err => {
el.disabled = false;
});
},
/**
* _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.
*
* @return {String}
*/
_getDiffViewMode() {
if (this.changeViewState.diffMode) {
return this.changeViewState.diffMode;
} else if (this._userPrefs) {
return this.changeViewState.diffMode =
this._userPrefs.default_diff_view;
} else {
return 'SIDE_BY_SIDE';
}
},
_computeModeSelectHidden() {
return this._isImageDiff;
},
_onLineSelected(e, detail) {
this.$.cursor.moveToLineNumber(detail.number, detail.side);
history.replaceState(null, null, '#' + this.$.cursor.getAddress());
},
_computeDownloadLink(changeNum, patchRange, path) {
let url = this.changeBaseURL(changeNum, patchRange.patchNum);
url += '/patch?zip&path=' + encodeURIComponent(path);
return url;
},
/**
* Request all comments (and drafts and robot comments) for the current
* change and construct the map of file paths that have comments for the
* current patch range.
* @return {Promise} A promise that yields a comment map object.
*/
_loadCommentMap() {
const filterByRange = comment => {
const patchNum = comment.patch_set + '';
return patchNum === this._patchRange.patchNum ||
patchNum === this._patchRange.basePatchNum;
};
return Promise.all([
this.$.restAPI.getDiffComments(this._changeNum),
this._getDiffDrafts(),
this.$.restAPI.getDiffRobotComments(this._changeNum),
]).then(results => {
const commentMap = {};
for (const response of results) {
for (const path in response) {
if (response.hasOwnProperty(path) &&
response[path].filter(filterByRange).length) {
commentMap[path] = true;
}
}
}
return commentMap;
});
},
_getDiffDrafts() {
return this._getLoggedIn().then(loggedIn => {
if (!loggedIn) { return Promise.resolve({}); }
return this.$.restAPI.getDiffDrafts(this._changeNum);
});
},
_computeCommentSkips(commentMap, fileList, path) {
const skips = {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;
},
});
})();