blob: 9e3640bfad5b4f98b421f627478b8f058b166bb6 [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Subscription} from 'rxjs';
import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
import {
DiffViewMode,
GrDiffCursor as GrDiffCursorApi,
LineNumber,
LineSelectedEventDetail,
} from '../../../api/diff';
import {ScrollMode, Side} from '../../../constants/constants';
import {toggleClass} from '../../../utils/dom-util';
import {
GrCursorManager,
isTargetable,
} from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
import {GrDiffLineType} from '../gr-diff/gr-diff-line';
import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
import {GrDiff} from '../gr-diff/gr-diff';
import {fire} from '../../../utils/event-util';
type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
const LEFT_SIDE_CLASS = 'target-side-left';
const RIGHT_SIDE_CLASS = 'target-side-right';
interface Address {
leftSide: boolean;
number: number;
}
/**
* From <tr> diff row go up to <tbody> diff chunk.
*
* In Lit based diff there is a <gr-diff-row> element in between the two.
*/
export function fromRowToChunk(
rowEl: HTMLElement
): HTMLTableSectionElement | undefined {
const parent = rowEl.parentElement;
if (!parent) return undefined;
if (parent.tagName === 'TBODY') {
return parent as HTMLTableSectionElement;
}
const grandParent = parent.parentElement;
if (!grandParent) return undefined;
if (grandParent.tagName === 'TBODY') {
return grandParent as HTMLTableSectionElement;
}
return undefined;
}
/** A subset of the GrDiff API that the cursor is using. */
export interface GrDiffCursorable extends HTMLElement {
isRangeSelected(): boolean;
createRangeComment(): void;
getCursorStops(): Stop[];
path?: string;
}
export class GrDiffCursor implements GrDiffCursorApi {
private preventAutoScrollOnManualScroll = false;
set side(side: Side) {
if (this.sideInternal === side) {
return;
}
if (this.sideInternal && this.diffRow) {
this.fireCursorMoved(
'line-cursor-moved-out',
this.diffRow,
this.sideInternal
);
}
this.sideInternal = side;
this.updateSideClass();
if (this.diffRow) {
this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
}
}
get side(): Side {
return this.sideInternal;
}
private sideInternal = Side.RIGHT;
set diffRow(diffRow: HTMLElement | undefined) {
if (this.diffRowInternal) {
this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
this.fireCursorMoved(
'line-cursor-moved-out',
this.diffRowInternal,
this.side
);
}
this.diffRowInternal = diffRow;
this.updateSideClass();
if (this.diffRow) {
this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
}
}
get diffRow(): HTMLElement | undefined {
return this.diffRowInternal;
}
private diffRowInternal?: HTMLElement;
private diffs: GrDiffCursorable[] = [];
/**
* If set, the cursor will attempt to move to the line number (instead of
* the first chunk) the next time the diff renders. It is set back to null
* when used. It should be only used if you want the line to be focused
* after initialization of the component and page should scroll
* to that position. This parameter should be set at most for one gr-diff
* element in the page.
*/
initialLineNumber: number | null = null;
// visible for testing
cursorManager = new GrCursorManager();
private targetSubscription?: Subscription;
constructor() {
this.cursorManager.cursorTargetClass = 'target-row';
this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
this.cursorManager.focusOnMove = true;
window.addEventListener('scroll', this._boundHandleWindowScroll);
this.targetSubscription = this.cursorManager.target$.subscribe(target => {
this.diffRow = target || undefined;
});
}
dispose() {
this.cursorManager.unsetCursor();
if (this.targetSubscription) this.targetSubscription.unsubscribe();
window.removeEventListener('scroll', this._boundHandleWindowScroll);
}
// Don't remove - used by clients embedding gr-diff outside of Gerrit.
isAtStart() {
return this.cursorManager.isAtStart();
}
// Don't remove - used by clients embedding gr-diff outside of Gerrit.
isAtEnd() {
return this.cursorManager.isAtEnd();
}
moveLeft() {
this.side = Side.LEFT;
if (this._isTargetBlank()) {
this.moveUp();
}
}
moveRight() {
this.side = Side.RIGHT;
if (this._isTargetBlank()) {
this.moveUp();
}
}
moveDown() {
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
return this.cursorManager.next({
filter: (row: Element) => this._rowHasSide(row),
});
} else {
return this.cursorManager.next();
}
}
moveUp() {
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
return this.cursorManager.previous({
filter: (row: Element) => this._rowHasSide(row),
});
} else {
return this.cursorManager.previous();
}
}
moveToVisibleArea() {
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
this.cursorManager.moveToVisibleArea((row: Element) =>
this._rowHasSide(row)
);
} else {
this.cursorManager.moveToVisibleArea();
}
}
moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
const result = this.cursorManager.next({
filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
clipToTop,
});
this._fixSide();
return result;
}
moveToPreviousChunk(): CursorMoveResult {
const result = this.cursorManager.previous({
filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
});
this._fixSide();
return result;
}
moveToNextCommentThread(): CursorMoveResult {
if (this.isAtEnd()) {
return CursorMoveResult.CLIPPED;
}
const result = this.cursorManager.next({
filter: (row: HTMLElement) => this._rowHasThread(row),
});
this._fixSide();
return result;
}
moveToPreviousCommentThread(): CursorMoveResult {
const result = this.cursorManager.previous({
filter: (row: HTMLElement) => this._rowHasThread(row),
});
this._fixSide();
return result;
}
moveToLineNumber(
number: LineNumber,
side: Side,
path?: string,
intentionalMove?: boolean
) {
const row = this._findRowByNumberAndFile(number, side, path);
if (row) {
this.side = side;
this.cursorManager.setCursor(row, undefined, intentionalMove);
}
}
/**
* Get the line number element targeted by the cursor row and side.
*/
getTargetLineElement(): HTMLElement | null {
let lineElSelector = '.lineNum';
if (!this.diffRow) {
return null;
}
if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
}
return this.diffRow.querySelector(lineElSelector);
}
getTargetDiffElement(): GrDiff | null {
if (!this.diffRow) return null;
const hostOwner = this.diffRow.getRootNode() as ShadowRoot;
if (hostOwner?.host?.tagName === 'GR-DIFF') {
return hostOwner.host as GrDiff;
}
return null;
}
moveToFirstChunk() {
this.cursorManager.moveToStart();
if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
this.moveToNextChunk(true);
} else {
this._fixSide();
}
}
moveToLastChunk() {
this.cursorManager.moveToEnd();
if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
this.moveToPreviousChunk();
} else {
this._fixSide();
}
}
/**
* Move the cursor either to initialLineNumber or the first chunk and
* reset scroll behavior.
*
* This may grab the focus from the app.
*
* If you do not want to move the cursor or grab focus, and just want to
* reset the scroll behavior, use reInitAndUpdateStops() instead.
*/
reInitCursor() {
this._updateStops();
if (!this.diffRow) {
// does not scroll during init unless requested
this.cursorManager.scrollMode = this.initialLineNumber
? ScrollMode.KEEP_VISIBLE
: ScrollMode.NEVER;
if (this.initialLineNumber) {
this.moveToLineNumber(this.initialLineNumber, this.side);
this.initialLineNumber = null;
} else {
this.moveToFirstChunk();
}
}
this.resetScrollMode();
}
resetScrollMode() {
this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
}
private _boundHandleWindowScroll = () => {
if (this.preventAutoScrollOnManualScroll) {
this.cursorManager.scrollMode = ScrollMode.NEVER;
this.cursorManager.focusOnMove = false;
this.preventAutoScrollOnManualScroll = false;
}
};
reInitAndUpdateStops() {
this.resetScrollMode();
this._updateStops();
}
private boundHandleDiffLoadingChanged = () => {
this._updateStops();
};
private _boundHandleDiffRenderStart = () => {
this.preventAutoScrollOnManualScroll = true;
};
private _boundHandleDiffRenderContent = () => {
this._updateStops();
// When done rendering, turn focus on move and automatic scrolling back on
this.cursorManager.focusOnMove = true;
this.preventAutoScrollOnManualScroll = false;
};
private _boundHandleDiffLineSelected = (
e: CustomEvent<LineSelectedEventDetail>
) => {
this.moveToLineNumber(e.detail.number, e.detail.side, e.detail.path);
};
createCommentInPlace() {
const diffWithRangeSelected = this.diffs.find(diff =>
diff.isRangeSelected()
);
if (diffWithRangeSelected) {
diffWithRangeSelected.createRangeComment();
} else {
const line = this.getTargetLineElement();
const diff = this.getTargetDiffElement();
if (diff && line) {
diff.addDraftAtLine(line);
}
}
}
/**
* Get an object describing the location of the cursor. Such as
* {leftSide: false, number: 123} for line 123 of the revision, or
* {leftSide: true, number: 321} for line 321 of the base patch.
* Returns null if an address is not available.
*/
getAddress(): Address | null {
if (!this.diffRow) {
return null;
}
// Get the line-number cell targeted by the cursor. If the mode is unified
// then prefer the revision cell if available.
return this.getAddressFor(this.diffRow, this.side);
}
private getAddressFor(diffRow: HTMLElement, side: Side): Address | null {
let cell;
if (this._getViewMode() === DiffViewMode.UNIFIED) {
cell = diffRow.querySelector('.lineNum.right');
if (!cell) {
cell = diffRow.querySelector('.lineNum.left');
}
} else {
cell = diffRow.querySelector('.lineNum.' + side);
}
if (!cell) {
return null;
}
const number = cell.getAttribute('data-value');
if (!number || number === 'FILE') {
return null;
}
return {
leftSide: cell.matches('.left'),
number: Number(number),
};
}
_getViewMode() {
if (!this.diffRow) {
return null;
}
if (this.diffRow.classList.contains('side-by-side')) {
return DiffViewMode.SIDE_BY_SIDE;
} else {
return DiffViewMode.UNIFIED;
}
}
_rowHasSide(row: Element) {
const selector =
(this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
return !!row.querySelector(selector);
}
_isFirstRowOfChunk(row: HTMLElement) {
const chunk = fromRowToChunk(row);
if (!chunk) return false;
const isInDeltaChunk = chunk.classList.contains('delta');
if (!isInDeltaChunk) return false;
const firstRow = chunk.querySelector('tr:not(.moveControls)');
return firstRow === row;
}
_rowHasThread(row: HTMLElement): boolean {
const slots = [
...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
];
return slots.some(slot => slot.assignedElements().length > 0);
}
/**
* If we jumped to a row where there is no content on the current side then
* switch to the alternate side.
*/
_fixSide() {
if (
this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
this._isTargetBlank()
) {
this.side = this.side === Side.LEFT ? Side.RIGHT : Side.LEFT;
}
}
_isTargetBlank() {
if (!this.diffRow) {
return false;
}
const actions = this._getActionsForRow();
return (
(this.side === Side.LEFT && !actions.left) ||
(this.side === Side.RIGHT && !actions.right)
);
}
private fireCursorMoved(
event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
row: HTMLElement,
side: Side
) {
const address = this.getAddressFor(row, side);
if (address) {
const {leftSide, number} = address;
fire(row, event, {
lineNum: number,
side: leftSide ? Side.LEFT : Side.RIGHT,
});
}
}
private updateSideClass() {
if (!this.diffRow) {
return;
}
toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT);
toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
}
_isActionType(type: GrDiffRowType) {
return (
type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
);
}
_getActionsForRow() {
const actions = {left: false, right: false};
if (this.diffRow) {
actions.left = this._isActionType(
this.diffRow.getAttribute('left-type') as GrDiffRowType
);
actions.right = this._isActionType(
this.diffRow.getAttribute('right-type') as GrDiffRowType
);
}
return actions;
}
_updateStops() {
this.cursorManager.stops = this.diffs.reduce(
(stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
[]
);
}
replaceDiffs(diffs: GrDiffCursorable[]) {
for (const diff of this.diffs) {
this.removeEventListeners(diff);
}
this.diffs = [];
for (const diff of diffs) {
this.addEventListeners(diff);
}
this.diffs.push(...diffs);
this._updateStops();
}
unregisterDiff(diff: GrDiffCursorable) {
// This can happen during destruction - just don't unregister then.
if (!this.diffs) return;
const i = this.diffs.indexOf(diff);
if (i !== -1) {
this.diffs.splice(i, 1);
}
}
private removeEventListeners(diff: GrDiffCursorable) {
diff.removeEventListener(
'loading-changed',
this.boundHandleDiffLoadingChanged
);
diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
diff.removeEventListener(
'render-content',
this._boundHandleDiffRenderContent
);
diff.removeEventListener(
'line-selected',
this._boundHandleDiffLineSelected
);
}
private addEventListeners(diff: GrDiffCursorable) {
diff.addEventListener(
'loading-changed',
this.boundHandleDiffLoadingChanged
);
diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
}
_findRowByNumberAndFile(
targetNumber: LineNumber,
side: Side,
path?: string
): HTMLElement | undefined {
let stops: Array<HTMLElement | AbortStop>;
if (path) {
const diff = this.diffs.filter(diff => diff.path === path)[0];
stops = diff.getCursorStops();
} else {
stops = this.cursorManager.stops;
}
// Sadly needed for type narrowing to understand that the result is always
// targetable.
const targetableStops: HTMLElement[] = stops.filter(isTargetable);
const selector = `.lineNum.${side}[data-value="${targetNumber}"]`;
return targetableStops.find(stop => stop.querySelector(selector));
}
}