blob: 2d1acf81c0d03d19e101058525098e9452b123d6 [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,
GrDiffLineType,
LineNumber,
LineSelectedEventDetail,
} from '../../../api/diff';
import {ScrollMode, Side} from '../../../constants/constants';
import {
GrCursorManager,
isTargetable,
} from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
import {GrDiff} from '../gr-diff/gr-diff';
import {fire} from '../../../utils/event-util';
import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
const LEFT_SIDE_CLASS = 'target-side-left';
const RIGHT_SIDE_CLASS = 'target-side-right';
/**
* 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.diffRowTR) {
this.fireCursorMoved('line-cursor-moved-out');
}
this.sideInternal = side;
this.updateSideClass();
if (this.diffRowTR) {
this.fireCursorMoved('line-cursor-moved-in');
}
}
get side(): Side {
return this.sideInternal;
}
private sideInternal = Side.RIGHT;
set diffRowTR(diffRowTR: HTMLTableRowElement | undefined) {
if (this.diffRowTRInternal) {
this.diffRowTRInternal.classList.remove(
LEFT_SIDE_CLASS,
RIGHT_SIDE_CLASS
);
this.fireCursorMoved('line-cursor-moved-out');
}
this.diffRowTRInternal = diffRowTR;
this.updateSideClass();
if (this.diffRowTR) {
this.fireCursorMoved('line-cursor-moved-in');
}
}
/**
* This is the current target of the diff cursor.
*/
get diffRowTR(): HTMLTableRowElement | undefined {
return this.diffRowTRInternal;
}
private diffRowTRInternal?: HTMLTableRowElement;
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.diffRowTR = (target ?? undefined) as HTMLTableRowElement | 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);
}
}
/**
* The target of the diff cursor is always a <tr> element. That is the first
* direct child of a <gr-diff-row> element. We typically want to retrieve
* the `GrDiffRow`, because it supplies methods that we can use without
* making further assumptions about the internal DOM structure.
*/
getTargetDiffRow(): GrDiffRow | undefined {
let el: HTMLElement | undefined = this.diffRowTR;
while (el) {
if (el.tagName === 'GR-DIFF-ROW') return el as GrDiffRow;
el = el.parentElement ?? undefined;
}
return undefined;
}
getTargetLineNumber(): LineNumber | undefined {
const diffRow = this.getTargetDiffRow();
return diffRow?.lineNumber(this.side);
}
getTargetDiffElement(): GrDiff | undefined {
if (!this.diffRowTR) return undefined;
const hostOwner = this.diffRowTR.getRootNode() as ShadowRoot;
if (hostOwner?.host?.tagName === 'GR-DIFF') {
return hostOwner.host as GrDiff;
}
return undefined;
}
moveToFirstChunk() {
this.cursorManager.moveToStart();
if (this.diffRowTR && !this.isFirstRowOfChunk(this.diffRowTR)) {
this.moveToNextChunk(true);
} else {
this.fixSide();
}
}
moveToLastChunk() {
this.cursorManager.moveToEnd();
if (this.diffRowTR && !this.isFirstRowOfChunk(this.diffRowTR)) {
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.diffRowTR) {
// 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 diffRow = this.getTargetDiffRow();
const lineNumber = diffRow?.lineNumber(this.side);
const diff = this.getTargetDiffElement();
if (diff && lineNumber) {
diff.addDraftAtLine(lineNumber, this.side);
}
}
}
private getViewMode() {
if (!this.diffRowTR) {
return null;
}
if (this.diffRowTR.classList.contains('side-by-side')) {
return DiffViewMode.SIDE_BY_SIDE;
} else {
return DiffViewMode.UNIFIED;
}
}
private rowHasSide(row: Element) {
const selector =
(this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
return !!row.querySelector(selector);
}
private 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;
}
private 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.
*/
private fixSide() {
if (
this.getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
this.isTargetBlank()
) {
this.side = this.side === Side.LEFT ? Side.RIGHT : Side.LEFT;
}
}
private isTargetBlank() {
const line = this.getTargetDiffRow()?.line(this.side);
return line?.type === GrDiffLineType.BLANK;
}
private fireCursorMoved(
event: 'line-cursor-moved-out' | 'line-cursor-moved-in'
) {
const lineNum = this.getTargetLineNumber();
if (!lineNum) return;
fire(this.diffRowTR, event, {lineNum, side: this.side});
}
private updateSideClass() {
if (!this.diffRowTR) {
return;
}
this.diffRowTR.classList.toggle(LEFT_SIDE_CLASS, this.side === Side.LEFT);
this.diffRowTR.classList.toggle(RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
}
// visible for testing
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);
}
// visible for testing
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));
}
}