blob: 661c74f9fac5dad1c9acbb4b3bc2619baacb6b3d [file] [log] [blame]
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
NumericChangeId,
RepoName,
RevisionPatchSetNum,
BasePatchSetNum,
ChangeInfo,
PatchSetNumber,
EDIT,
PARENT,
} from '../../api/rest-api';
import {Tab} from '../../constants/constants';
import {GerritView} from '../../services/router/router-model';
import {UrlEncodedCommentId} from '../../types/common';
import {assertIsDefined, toggleSet} from '../../utils/common-util';
import {select} from '../../utils/observable-util';
import {
encodeURL,
getBaseUrl,
getPatchRangeExpression,
} from '../../utils/url-util';
import {AttemptChoice} from '../checks/checks-util';
import {define} from '../dependency';
import {Model} from '../base/model';
import {ViewState} from './base';
import {isNumber} from '../../utils/patch-set-util';
export enum ChangeChildView {
OVERVIEW = 'OVERVIEW',
DIFF = 'DIFF',
EDIT = 'EDIT',
}
export interface ChangeViewState extends ViewState {
view: GerritView.CHANGE;
childView: ChangeChildView;
changeNum: NumericChangeId;
repo: RepoName;
patchNum?: RevisionPatchSetNum;
basePatchNum?: BasePatchSetNum;
/** Refers to comment on COMMENTS tab in OVERVIEW. */
commentId?: UrlEncodedCommentId;
// TODO: Move properties that only apply to OVERVIEW into a submessage.
edit?: boolean;
/** This can be a string only for plugin provided tabs. */
tab?: Tab | string;
// TODO: Move properties that only apply to CHECKS tab into a submessage.
/** Checks related view state */
/** selected patchset for check runs (undefined=latest) */
checksPatchset?: PatchSetNumber;
/** regular expression for filtering check runs */
filter?: string;
/** selected attempt for check runs (undefined=latest) */
attempt?: AttemptChoice;
/** selected check runs identified by `checkName` */
checksRunsSelected?: Set<string>;
/** regular expression for filtering check results */
checksResultsFilter?: string;
/** State properties that trigger one-time actions */
/** for scrolling a Change Log message into view in gr-change-view */
messageHash?: string;
/**
* For logging where the user came from. This is handled by the router, so
* this is not inspected by the model.
*/
usp?: string;
/**
* Triggers all change related data to be reloaded. This is implemented by
* intercepting change view state updates and `forceReload` causing the view
* state to be wiped clean as `undefined` in an intermediate update.
*/
forceReload?: boolean;
/** triggers opening the reply dialog */
openReplyDialog?: boolean;
/** These properties apply to the DIFF child view only. */
diffView?: {
path: string;
// TODO: Use LineNumber as a type, i.e. accept FILE and LOST.
lineNum?: number;
leftSide?: boolean;
};
/** These properties apply to the EDIT child view only. */
editView?: {
path: string;
lineNum?: number;
};
}
export type DiffViewState = Partial<ChangeViewState> & {
patchNum: RevisionPatchSetNum;
diffView: {
path: string;
lineNum?: number;
leftSide?: boolean;
};
};
export type EditViewState = Partial<ChangeViewState> & {
patchNum: RevisionPatchSetNum;
editView: {
path: string;
lineNum?: number;
};
};
/**
* This is a convenience type such that you can pass a `ChangeInfo` object
* as the `change` property instead of having to set both the `changeNum` and
* `project` properties explicitly.
*/
export type CreateChangeUrlObject = Omit<
ChangeViewState,
'view' | 'childView' | 'changeNum' | 'repo'
> & {
change: Pick<ChangeInfo, '_number' | 'project'>;
};
export function isCreateChangeUrlObject(
state: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
): state is CreateChangeUrlObject {
return !!(state as CreateChangeUrlObject).change;
}
export function objToState(
obj:
| (CreateChangeUrlObject & {childView: ChangeChildView})
| Omit<ChangeViewState, 'view'>
): ChangeViewState {
if (isCreateChangeUrlObject(obj)) {
return {
...obj,
view: GerritView.CHANGE,
changeNum: obj.change._number,
repo: obj.change.project,
};
}
return {...obj, view: GerritView.CHANGE};
}
export function createChangeViewUrl(state: ChangeViewState): string {
switch (state.childView) {
case ChangeChildView.OVERVIEW:
return createChangeUrl(state);
case ChangeChildView.DIFF:
return createDiffUrl(state);
case ChangeChildView.EDIT:
return createEditUrl(state);
}
}
export function createChangeUrl(
obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
): string {
const state: ChangeViewState = objToState({
...obj,
childView: ChangeChildView.OVERVIEW,
});
let suffix = '';
const queries = [];
if (state.checksPatchset && state.checksPatchset > 0) {
queries.push(`checksPatchset=${state.checksPatchset}`);
}
if (state.attempt) {
if (state.attempt !== 'latest') queries.push(`attempt=${state.attempt}`);
}
if (state.filter) {
queries.push(`filter=${state.filter}`);
}
if (state.checksResultsFilter) {
queries.push(`checksResultsFilter=${state.checksResultsFilter}`);
}
if (state.checksRunsSelected && state.checksRunsSelected.size > 0) {
queries.push(`checksRunsSelected=${[...state.checksRunsSelected].sort()}`);
}
if (state.tab && state.tab !== Tab.FILES) {
queries.push(`tab=${state.tab}`);
}
if (state.forceReload) {
queries.push('forceReload=true');
}
if (state.openReplyDialog) {
queries.push('openReplyDialog=true');
}
if (state.usp) {
queries.push(`usp=${state.usp}`);
}
if (state.edit) {
suffix += ',edit';
}
if (state.commentId) {
suffix += `/comments/${state.commentId}`;
}
if (queries.length > 0) {
suffix += '?' + queries.join('&');
}
if (state.messageHash) {
suffix += state.messageHash;
}
return `${createChangeUrlCommon(state)}${suffix}`;
}
export function createDiffUrl(
obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
): string {
const state: ChangeViewState = objToState({
...obj,
childView: ChangeChildView.DIFF,
});
let path = `/${encodeURL(state.diffView?.path ?? '')}`;
// TODO: Move creating of comment URLs to a separate function. We are
// "abusing" the `commentId` property, which should only be used for pointing
// to comment in the COMMENTS tab of the OVERVIEW page.
if (state.commentId) {
path += `comment/${state.commentId}/`;
}
let queryParams = '';
const params = [];
if (state.checksPatchset && state.checksPatchset > 0) {
params.push(`checksPatchset=${state.checksPatchset}`);
}
if (params.length > 0) {
queryParams = '?' + params.join('&');
}
let hash = '';
if (state.diffView?.lineNum) {
hash += '#';
if (state.diffView?.leftSide) {
hash += 'b';
}
hash += state.diffView.lineNum;
}
return `${createChangeUrlCommon(state)}${path}${queryParams}${hash}`;
}
export function createEditUrl(
obj: Omit<ChangeViewState, 'view' | 'childView'>
): string {
const state: ChangeViewState = objToState({
...obj,
childView: ChangeChildView.DIFF,
patchNum: obj.patchNum ?? EDIT,
});
const path = `/${encodeURL(state.editView?.path ?? '')}`;
const line = state.editView?.lineNum;
const suffix = line ? `#${line}` : '';
return `${createChangeUrlCommon(state)}${path},edit${suffix}`;
}
/**
* The shared part of creating a change URL between OVERVIEW, DIFF and EDIT
* child views.
*/
function createChangeUrlCommon(state: ChangeViewState) {
let range = getPatchRangeExpression(state);
if (range.length) range = '/' + range;
let repo = '';
if (state.repo) repo = `${encodeURL(state.repo)}/+/`;
return `${getBaseUrl()}/c/${repo}${state.changeNum}${range}`;
}
export const changeViewModelToken =
define<ChangeViewModel>('change-view-model');
export class ChangeViewModel extends Model<ChangeViewState | undefined> {
public readonly changeNum$ = select(this.state$, state => state?.changeNum);
public readonly patchNum$ = select(this.state$, state => state?.patchNum);
public readonly basePatchNum$ = select(
this.state$,
state => state?.basePatchNum
);
public readonly openReplyDialog$ = select(
this.state$,
state => state?.openReplyDialog
);
public readonly commentId$ = select(this.state$, state => state?.commentId);
public readonly edit$ = select(this.state$, state => !!state?.edit);
public readonly editPath$ = select(
this.state$,
state => state?.editView?.path
);
public readonly diffPath$ = select(
this.state$,
state => state?.diffView?.path
);
public readonly diffLine$ = select(
this.state$,
state => state?.diffView?.lineNum
);
public readonly diffLeftSide$ = select(
this.state$,
state => state?.diffView?.leftSide ?? false
);
public readonly childView$ = select(this.state$, state => state?.childView);
public readonly tab$ = select(this.state$, state => {
if (state?.tab) return state.tab;
if (state?.commentId) return Tab.COMMENT_THREADS;
return Tab.FILES;
});
public readonly checksPatchset$ = select(
this.state$,
state => state?.checksPatchset
);
public readonly attempt$ = select(this.state$, state => state?.attempt);
public readonly filter$ = select(this.state$, state => state?.filter);
public readonly checksResultsFilter$ = select(
this.state$,
state => state?.checksResultsFilter ?? ''
);
public readonly checksRunsSelected$ = select(
this.state$,
state => state?.checksRunsSelected ?? new Set<string>()
);
constructor() {
super(undefined);
this.state$.subscribe(s => {
if (s?.usp || s?.forceReload || s?.openReplyDialog) {
this.updateState({
usp: undefined,
forceReload: undefined,
openReplyDialog: undefined,
});
}
});
document.addEventListener('reload', this.reload);
}
override finalize(): void {
document.removeEventListener('reload', this.reload);
}
/**
* Calling this is the same as firing the 'reload' event. This is also the
* same as adding `forceReload` parameter in the URL. See below.
*/
reload = () => {
const state = this.getState();
if (state !== undefined) this.forceLoad(state);
};
/**
* This is the destination of where the `reload()` method, the `reload` event
* and the `forceReload` URL parameter all end up.
*/
private forceLoad(state: ChangeViewState) {
this.setState(undefined);
// We have to do this in a timeout, because we need the `undefined` value to
// be processed by all observers first and thus have the "reset" completed.
setTimeout(() => this.setState({...state, forceReload: undefined}));
}
override setState(state: ChangeViewState | undefined): void {
if (state?.forceReload) {
this.forceLoad(state);
} else {
super.setState(state);
}
}
/**
* Wrapper around createDiffUrl() that falls back to the current state for all
* properties that are not explicitly provided as an override.
*/
diffUrl(override: DiffViewState): string {
const current = this.getState();
assertIsDefined(current?.changeNum);
assertIsDefined(current?.repo);
const patchNum = override.patchNum ?? current.patchNum;
let basePatchNum = override.basePatchNum ?? current.basePatchNum;
if (isNumber(basePatchNum) && isNumber(patchNum)) {
if ((patchNum as number) <= (basePatchNum as number)) {
basePatchNum = PARENT;
}
}
return createDiffUrl({
changeNum: override.changeNum ?? current.changeNum,
repo: override.repo ?? current.repo,
patchNum,
basePatchNum,
checksPatchset: override.checksPatchset ?? current.checksPatchset,
diffView: override.diffView ?? current.diffView,
});
}
/**
* Wrapper around createEditUrl() that falls back to the current state for all
* properties that are not explicitly provided as an override.
*/
editUrl(override: EditViewState): string {
const current = this.getState();
assertIsDefined(current?.changeNum);
assertIsDefined(current?.repo);
return createEditUrl({
changeNum: override.changeNum ?? current.changeNum,
repo: override.repo ?? current.repo,
patchNum: override.patchNum ?? current.patchNum,
editView: override.editView ?? current.editView,
});
}
toggleSelectedCheckRun(checkName: string) {
const current = this.getState()?.checksRunsSelected ?? new Set();
const next = new Set(current);
toggleSet(next, checkName);
this.updateState({checksRunsSelected: next});
}
}