blob: 79feca024d474ce300fc1dd56e37985a02bb3d6d [file] [log] [blame]
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@polymer/paper-button/paper-button';
import '@polymer/paper-card/paper-card';
import '@polymer/paper-checkbox/paper-checkbox';
import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
import '@polymer/paper-fab/paper-fab';
import '@polymer/paper-icon-button/paper-icon-button';
import '@polymer/paper-item/paper-item';
import '@polymer/paper-listbox/paper-listbox';
import '@polymer/paper-tooltip/paper-tooltip';
import {of, EMPTY, Subject} from 'rxjs';
import {switchMap, delay} from 'rxjs/operators';
import '../../../elements/shared/gr-button/gr-button';
import {pluralize} from '../../../utils/string-util';
import {fire} from '../../../utils/event-util';
import {assertIsDefined} from '../../../utils/common-util';
import {
css,
html,
LitElement,
nothing,
PropertyValues,
TemplateResult,
} from 'lit';
import {property, state} from 'lit/decorators.js';
import {subscribe} from '../../../elements/lit/subscription-controller';
import {
ContextButtonType,
DiffContextButtonHoveredDetail,
RenderPreferences,
SyntaxBlock,
} from '../../../api/diff';
import {
GrDiffGroup,
GrDiffGroupType,
hideInContextControl,
} from '../gr-diff/gr-diff-group';
import {resolve} from '../../../models/dependency';
import {diffModelToken} from '../gr-diff-model/gr-diff-model';
declare global {
interface HTMLElementEventMap {
'diff-context-button-hovered': CustomEvent<DiffContextButtonHoveredDetail>;
}
}
const PARTIAL_CONTEXT_AMOUNT = 10;
/**
* Traverses a hierarchical structure of syntax blocks and
* finds the most local/nested block that can be associated line.
* It finds the closest block that contains the whole line and
* returns the whole path from the syntax layer (blocks) sent as parameter
* to the most nested block - the complete path from the top to bottom layer of
* a syntax tree. Example: [myNamespace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
*
* @param lineNum line number for the targeted line.
* @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
*/
function findBlockTreePathForLine(
lineNum: number,
blocks?: SyntaxBlock[]
): SyntaxBlock[] {
const containingBlock = blocks?.find(
({range}) => range.start_line < lineNum && range.end_line > lineNum
);
if (!containingBlock) return [];
const innerPathInChild = findBlockTreePathForLine(
lineNum,
containingBlock?.children
);
return [containingBlock].concat(innerPathInChild);
}
/**
* 'above': Typically only for the context controls at the end of a file. So
* only show buttons "above" the middle line of the context control
* section.
* 'below': Typically only for the context controls at the beginning of a file.
* So only show buttons "below" the middle line of the context control
* section.
* 'both': Typically for the context controls in the middle of a file. So show
* two buttons, one for expanding from the top and one for expanding
* from the bottom.
*/
export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
export function getShowConfig(group?: GrDiffGroup, lineCountLeft = 0) {
const above = showAbove(group, lineCountLeft);
const below = showBelow(group, lineCountLeft);
if (above && !below) return 'above';
if (!above && below) return 'below';
// Note that !showAbove && !showBelow also intentionally returns 'both'.
// This means the file is completely collapsed, which is unusual, but at least
// happens in one test.
return 'both';
}
/** See GrContextControlsShowConfig for explanation of "above". */
export function showAbove(group?: GrDiffGroup, lineCountLeft = 0) {
if (group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return false;
// Note that we could as well use `right.start_line` here. And below we only
// use `left`, because we are comparing with `lineCountLeft`. But that is
// just an arbitrary choice.
const leftStart = group.lineRange.left.start_line;
const firstGroupIsSkipped = !!group.contextGroups[0].skip;
if (leftStart > 1 && !firstGroupIsSkipped) return true;
const leftEnd = group.lineRange.left.end_line;
const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
return containsWholeFile;
}
/** See GrContextControlsShowConfig for explanation of "below". */
export function showBelow(group?: GrDiffGroup, lineCountLeft = 0) {
if (group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return false;
// Note that we could as well use `right.start_line` here. But we would then
// require a `lineCountRight` parameter for making the comparison.
const leftEnd = group.lineRange.left.end_line;
const lastGroupIsSkipped =
!!group.contextGroups[group.contextGroups.length - 1].skip;
return leftEnd < lineCountLeft && !lastGroupIsSkipped;
}
/**
* Renders context control buttons such as "+23 lines" or "+Block". It is only
* meant to be used to be rendered into a diff table cell of its parent
* component <gr-context-controls-section>.
*/
export class GrContextControls extends LitElement {
@property({type: Object}) group?: GrDiffGroup;
// This is just a property (and not a state), because we want to "reflect".
@property({type: String, reflect: true})
showConfig: GrContextControlsShowConfig = 'both';
@state() syntaxTreeRight?: SyntaxBlock[];
@state() renderPreferences?: RenderPreferences;
@state() lineCountLeft = 0;
private readonly getDiffModel = resolve(this, diffModelToken);
private expandButtonsHover = new Subject<{
eventType: 'enter' | 'leave';
buttonType: ContextButtonType;
linesToExpand: number;
}>();
static override get styles() {
return [
css`
:host {
display: flex;
justify-content: center;
flex-direction: column;
position: relative;
}
:host([showConfig='above']) {
justify-content: flex-end;
margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
margin-bottom: var(--gr-context-controls-margin-bottom);
height: calc(var(--line-height-normal) + var(--spacing-s));
.horizontalFlex {
align-items: end;
}
}
:host([showConfig='below']) {
justify-content: flex-start;
margin-top: 1px;
margin-bottom: calc(
0px - var(--line-height-normal) - var(--spacing-s)
);
.horizontalFlex {
align-items: start;
}
}
:host([showConfig='both']) {
margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
margin-bottom: calc(
0px - var(--line-height-normal) - var(--spacing-s)
);
height: calc(
2 * var(--line-height-normal) + 2 * var(--spacing-s) +
var(--divider-height)
);
.horizontalFlex {
align-items: center;
}
}
.contextControlButton {
background-color: var(--default-button-background-color);
font: var(--context-control-button-font, inherit);
}
paper-button {
text-transform: none;
align-items: center;
background-color: var(--background-color);
font-family: inherit;
margin: var(--margin, 0);
min-width: var(--border, 0);
color: var(--diff-context-control-color);
border: solid var(--border-color);
border-width: 1px;
border-radius: var(--border-radius);
padding: var(--spacing-s) var(--spacing-l);
}
paper-button:hover {
/* same as defined in gr-button */
background: rgba(0, 0, 0, 0.12);
}
paper-button:focus-visible {
/* paper-button sets this to 0, thus preventing focus-based styling. */
outline-width: 1px;
}
.aboveBelowButtons {
display: flex;
flex-direction: column;
justify-content: center;
margin-left: var(--spacing-m);
position: relative;
}
.aboveBelowButtons:first-child {
margin-left: 0;
/* Places a default background layer behind the "all button" that can have opacity */
background-color: var(--default-button-background-color);
}
.horizontalFlex {
display: flex;
justify-content: center;
align-items: var(
--gr-context-controls-horizontal-align-items,
center
);
}
.aboveButton {
border-bottom-width: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
padding: var(--spacing-xxs) var(--spacing-l);
}
.belowButton {
border-top-width: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
padding: var(--spacing-xxs) var(--spacing-l);
margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
}
.belowButton:first-child {
margin-top: 0;
}
.breadcrumbTooltip {
white-space: nowrap;
}
.unrelatedChanges {
color: var(--primary-button-text-color);
background-color: var(--primary-button-background-color);
&:hover {
// TODO(anuragpathak): Update hover colors as per specification.
color: var(--primary-button-text-color);
background-color: var(--primary-button-background-color);
}
}
`,
];
}
constructor() {
super();
this.setupButtonHoverHandler();
subscribe(
this,
() => this.getDiffModel().syntaxTreeRight$,
syntaxTree => (this.syntaxTreeRight = syntaxTree)
);
subscribe(
this,
() => this.getDiffModel().renderPrefs$,
renderPrefs => (this.renderPreferences = renderPrefs)
);
subscribe(
this,
() => this.getDiffModel().lineCountLeft$,
lineCountLeft => {
this.lineCountLeft = lineCountLeft;
this.updateShowConfig();
}
);
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('group')) this.updateShowConfig();
}
private updateShowConfig() {
this.showConfig = getShowConfig(this.group, this.lineCountLeft);
}
private showBoth() {
return this.showConfig === 'both';
}
private showAbove() {
return this.showBoth() || this.showConfig === 'above';
}
private showBelow() {
return this.showBoth() || this.showConfig === 'below';
}
private setupButtonHoverHandler() {
subscribe(
this,
() =>
this.expandButtonsHover.pipe(
switchMap(e => {
if (e.eventType === 'leave') {
// cancel any previous delay
// for mouse enter
return EMPTY;
}
return of(e).pipe(delay(500));
})
),
({buttonType, linesToExpand}) => {
fire(this, 'diff-context-button-hovered', {
buttonType,
linesToExpand,
});
}
);
}
private numLines() {
assertIsDefined(this.group);
// In context groups, there is the same number of lines left and right
const left = this.group.lineRange.left;
// Both start and end inclusive, so we need to add 1.
return left.end_line - left.start_line + 1;
}
private createExpandAllButtonContainer() {
return html` <div class="aboveBelowButtons fullExpansion">
${this.createContextButton(ContextButtonType.ALL, this.numLines())}
</div>`;
}
/**
* Creates a specific expansion button (e.g. +X common lines, +10, +Block).
*/
private createContextButton(
type: ContextButtonType,
linesToExpand: number,
tooltip?: TemplateResult
) {
if (!this.group) return;
let text = '';
let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
let ariaLabel = '';
let classes = 'contextControlButton showContext ';
if (type === ContextButtonType.ALL) {
if (this.group.hasDeltaGroup()) {
text = '+ Unrelated changes';
ariaLabel = 'Show unrelated changes';
classes += ' unrelatedChanges ';
} else {
text = `+${pluralize(linesToExpand, 'common line')}`;
ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
}
classes += this.showBoth()
? 'centeredButton'
: this.showAbove()
? 'aboveButton'
: 'belowButton';
if (this.group?.hasSkipGroup()) {
// Expanding content would require load of more data
text += ' (too large)';
}
groups.push(...this.group.contextGroups);
} else if (type === ContextButtonType.ABOVE) {
groups = hideInContextControl(
this.group.contextGroups,
linesToExpand,
this.numLines()
);
text = `+${linesToExpand}`;
classes += 'aboveButton';
ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
} else if (type === ContextButtonType.BELOW) {
groups = hideInContextControl(
this.group.contextGroups,
0,
this.numLines() - linesToExpand
);
text = `+${linesToExpand}`;
classes += 'belowButton';
ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
} else if (type === ContextButtonType.BLOCK_ABOVE) {
groups = hideInContextControl(
this.group.contextGroups,
linesToExpand,
this.numLines()
);
text = '+Block';
classes += 'aboveButton';
ariaLabel = 'Show block above';
} else if (type === ContextButtonType.BLOCK_BELOW) {
groups = hideInContextControl(
this.group.contextGroups,
0,
this.numLines() - linesToExpand
);
text = '+Block';
classes += 'belowButton';
ariaLabel = 'Show block below';
}
const expandHandler = this.createExpansionHandler(
linesToExpand,
type,
groups
);
const mouseHandler = (eventType: 'enter' | 'leave') => {
this.expandButtonsHover.next({
eventType,
buttonType: type,
linesToExpand,
});
};
const button = html` <paper-button
class=${classes}
aria-label=${ariaLabel}
@click=${expandHandler}
@mouseenter=${() => mouseHandler('enter')}
@mouseleave=${() => mouseHandler('leave')}
>
<span class="showContext">${text}</span>
${tooltip}
</paper-button>`;
return button;
}
private createExpansionHandler(
linesToExpand: number,
type: ContextButtonType,
groups: GrDiffGroup[]
) {
return (e: Event) => {
assertIsDefined(this.group);
e.stopPropagation();
if (type === ContextButtonType.ALL && this.group?.hasSkipGroup()) {
fire(this, 'content-load-needed', {
lineRange: this.group.lineRange,
});
} else {
fire(this, 'diff-context-expanded', {
numLines: this.numLines(),
buttonType: type,
expandedLines: linesToExpand,
});
fire(this, 'diff-context-expanded-internal-new', {
contextGroup: this.group,
groups,
numLines: this.numLines(),
buttonType: type,
expandedLines: linesToExpand,
});
}
};
}
private showPartialLinks() {
return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
}
/**
* Creates a container div with partial (+10) expansion buttons (above and/or below).
*/
private createPartialExpansionButtons() {
if (!this.showPartialLinks() || this.group?.hasDeltaGroup()) {
return undefined;
}
let aboveButton;
let belowButton;
if (this.showAbove()) {
aboveButton = this.createContextButton(
ContextButtonType.ABOVE,
PARTIAL_CONTEXT_AMOUNT
);
}
if (this.showBelow()) {
belowButton = this.createContextButton(
ContextButtonType.BELOW,
PARTIAL_CONTEXT_AMOUNT
);
}
return aboveButton || belowButton
? html` <div class="aboveBelowButtons partialExpansion">
${aboveButton} ${belowButton}
</div>`
: undefined;
}
/**
* Creates a container div with block expansion buttons (above and/or below).
*/
private createBlockExpansionButtons() {
assertIsDefined(this.group, 'group');
if (
!this.showPartialLinks() ||
!this.renderPreferences?.use_block_expansion ||
this.group?.hasSkipGroup() ||
this.group?.hasDeltaGroup()
) {
return undefined;
}
let aboveBlockButton;
let belowBlockButton;
if (this.showAbove()) {
aboveBlockButton = this.createBlockButton(
ContextButtonType.BLOCK_ABOVE,
this.group.lineRange.right.start_line - 1
);
}
if (this.showBelow()) {
belowBlockButton = this.createBlockButton(
ContextButtonType.BLOCK_BELOW,
this.group.lineRange.right.end_line + 1
);
}
if (aboveBlockButton || belowBlockButton) {
return html` <div class="aboveBelowButtons blockExpansion">
${aboveBlockButton} ${belowBlockButton}
</div>`;
}
return undefined;
}
private createBlockButtonTooltip(
buttonType: ContextButtonType,
syntaxPath: SyntaxBlock[],
linesToExpand: number
) {
// Create breadcrumb string:
// myNamespace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
const tooltipText = syntaxPath.length
? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
: `${linesToExpand} common lines`;
const position =
buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
return html`<paper-tooltip offset="10" position=${position}
><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
>`;
}
/**
* Creates a "expand until end of block" button. This is based on syntax tree
* information for the *right* side of the diff.
*/
private createBlockButton(
buttonType: ContextButtonType,
referenceLineRight: number
) {
if (this.syntaxTreeRight === undefined) return;
const outlineSyntaxPath = findBlockTreePathForLine(
referenceLineRight,
this.syntaxTreeRight
);
let linesToExpand = this.numLines();
if (outlineSyntaxPath.length) {
const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
const targetLine =
buttonType === ContextButtonType.BLOCK_ABOVE
? range.end_line
: range.start_line;
const distanceToTargetLine = Math.abs(targetLine - referenceLineRight);
if (distanceToTargetLine < this.numLines()) {
linesToExpand = distanceToTargetLine;
}
}
const tooltip = this.createBlockButtonTooltip(
buttonType,
outlineSyntaxPath,
linesToExpand
);
return this.createContextButton(buttonType, linesToExpand, tooltip);
}
override render() {
if (!this.group) return nothing;
return html`
<div class="horizontalFlex">
${this.createExpandAllButtonContainer()}
${this.createPartialExpansionButtons()}
${this.createBlockExpansionButtons()}
</div>
`;
}
}
customElements.define('gr-context-controls', GrContextControls);
declare global {
interface HTMLElementTagNameMap {
'gr-context-controls': GrContextControls;
}
}