blob: bb0f1e99cd1f0bed78628585b7ae6d36b8c9073f [file] [log] [blame]
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@polymer/iron-input/iron-input';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-select/gr-select';
import {AccountDetailInfo, PreferencesInput} from '../../../types/common';
import {grFormStyles} from '../../../styles/gr-form-styles';
import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, html, css, nothing} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {convertToString} from '../../../utils/string-util';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {userModelToken} from '../../../models/user/user-model';
import {
AppTheme,
DateFormat,
DiffViewMode,
EmailFormat,
EmailStrategy,
TimeFormat,
} from '../../../constants/constants';
import {getAppContext} from '../../../services/app-context';
import {KnownExperimentId} from '../../../services/flags/flags';
import {areNotificationsEnabled} from '../../../utils/worker-util';
import {getDocUrl} from '../../../utils/url-util';
import {configModelToken} from '../../../models/config/config-model';
import {SuggestionsProvider} from '../../../api/suggestions';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
/**
* This provides an interface to show settings for a user profile
* as defined in PreferencesInfo.
*/
@customElement('gr-preferences')
export class GrPreferences extends LitElement {
@query('#themeSelect') themeSelect!: HTMLInputElement;
@query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
@query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
@query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
@query('#emailNotificationsSelect')
emailNotificationsSelect!: HTMLInputElement;
@query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
@query('#allowBrowserNotifications')
allowBrowserNotifications?: HTMLInputElement;
@query('#allowSuggestCodeWhileCommenting')
allowSuggestCodeWhileCommenting?: HTMLInputElement;
@query('#defaultBaseForMergesSelect')
defaultBaseForMergesSelect!: HTMLInputElement;
@query('#relativeDateInChangeTable')
relativeDateInChangeTable!: HTMLInputElement;
@query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
@query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
@query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
@query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
@query('#disableKeyboardShortcuts')
disableKeyboardShortcuts!: HTMLInputElement;
@query('#disableTokenHighlighting')
disableTokenHighlighting!: HTMLInputElement;
@query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
@state() prefs?: PreferencesInput;
@state() private originalPrefs?: PreferencesInput;
@state() account?: AccountDetailInfo;
@state() private docsBaseUrl = '';
@state()
suggestionsProvider?: SuggestionsProvider;
readonly getUserModel = resolve(this, userModelToken);
private readonly getConfigModel = resolve(this, configModelToken);
private readonly getPluginLoader = resolve(this, pluginLoaderToken);
// private but used in test
readonly flagsService = getAppContext().flagsService;
constructor() {
super();
subscribe(
this,
() => this.getUserModel().preferences$,
prefs => {
this.originalPrefs = prefs;
this.prefs = {...prefs};
}
);
subscribe(
this,
() => this.getUserModel().account$,
acc => {
this.account = acc;
}
);
subscribe(
this,
() => this.getConfigModel().docsBaseUrl$,
docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
);
}
override connectedCallback() {
super.connectedCallback();
this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
const suggestionsPlugins =
this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
// We currently support results from only 1 provider.
this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
});
}
static override get styles() {
return [
sharedStyles,
menuPageStyles,
grFormStyles,
css`
:host {
border: none;
margin-bottom: var(--spacing-xxl);
}
h2 {
font-family: var(--header-font-family);
font-size: var(--font-size-h2);
font-weight: var(--font-weight-h2);
line-height: var(--line-height-h2);
}
`,
];
}
override render() {
return html`
<h2 id="Preferences" class=${this.hasUnsavedChanges() ? 'edited' : ''}>
Preferences
</h2>
<fieldset id="preferences">
<div id="preferences" class="gr-form-styles">
<section>
<label class="title" for="themeSelect">Theme</label>
<span class="value">
<gr-select
.bindValue=${this.prefs?.theme ?? AppTheme.AUTO}
@change=${() => {
this.prefs!.theme = this.themeSelect.value as AppTheme;
this.requestUpdate();
}}
>
<select id="themeSelect">
<option value="AUTO">Auto (based on OS prefs)</option>
<option value="LIGHT">Light</option>
<option value="DARK">Dark</option>
</select>
</gr-select>
</span>
</section>
<section>
<label class="title" for="changesPerPageSelect"
>Changes per page</label
>
<span class="value">
<gr-select
.bindValue=${convertToString(this.prefs?.changes_per_page)}
@change=${() => {
this.prefs!.changes_per_page = Number(
this.changesPerPageSelect.value
) as 10 | 25 | 50 | 100;
this.requestUpdate();
}}
>
<select id="changesPerPageSelect">
<option value="10">10 rows per page</option>
<option value="25">25 rows per page</option>
<option value="50">50 rows per page</option>
<option value="100">100 rows per page</option>
</select>
</gr-select>
</span>
</section>
<section>
<label class="title" for="dateTimeFormatSelect"
>Date/time format</label
>
<span class="value">
<gr-select
.bindValue=${convertToString(this.prefs?.date_format)}
@change=${() => {
this.prefs!.date_format = this.dateTimeFormatSelect
.value as DateFormat;
this.requestUpdate();
}}
>
<select id="dateTimeFormatSelect">
<option value="STD">Jun 3 ; Jun 3, 2016</option>
<option value="US">06/03 ; 06/03/16</option>
<option value="ISO">06-03 ; 2016-06-03</option>
<option value="EURO">3. Jun ; 03.06.2016</option>
<option value="UK">03/06 ; 03/06/2016</option>
</select>
</gr-select>
<gr-select
.bindValue=${convertToString(this.prefs?.time_format)}
aria-label="Time Format"
@change=${() => {
this.prefs!.time_format = this.timeFormatSelect
.value as TimeFormat;
this.requestUpdate();
}}
>
<select id="timeFormatSelect">
<option value="HHMM_12">4:10 PM</option>
<option value="HHMM_24">16:10</option>
</select>
</gr-select>
</span>
</section>
<section>
<label class="title" for="emailNotificationsSelect"
>Email notifications</label
>
<span class="value">
<gr-select
.bindValue=${convertToString(this.prefs?.email_strategy)}
@change=${() => {
this.prefs!.email_strategy = this.emailNotificationsSelect
.value as EmailStrategy;
this.requestUpdate();
}}
>
<select id="emailNotificationsSelect">
<option value="CC_ON_OWN_COMMENTS">Every comment</option>
<option value="ENABLED">Only comments left by others</option>
<option value="ATTENTION_SET_ONLY">
Only when I am in the attention set
</option>
<option value="DISABLED">None</option>
</select>
</gr-select>
</span>
</section>
<section>
<label class="title" for="emailFormatSelect">Email format</label>
<span class="value">
<gr-select
.bindValue=${convertToString(this.prefs?.email_format)}
@change=${() => {
this.prefs!.email_format = this.emailFormatSelect
.value as EmailFormat;
this.requestUpdate();
}}
>
<select id="emailFormatSelect">
<option value="HTML_PLAINTEXT">HTML and plaintext</option>
<option value="PLAINTEXT">Plaintext only</option>
</select>
</gr-select>
</span>
</section>
${this.renderBrowserNotifications()}
${this.renderGenerateSuggestionWhenCommenting()}
${this.renderDefaultBaseForMerges()}
<section>
<label class="title" for="relativeDateInChangeTable"
>Show Relative Dates In Changes Table</label
>
<span class="value">
<input
id="relativeDateInChangeTable"
type="checkbox"
?checked=${this.prefs?.relative_date_in_change_table}
@change=${() => {
this.prefs!.relative_date_in_change_table =
this.relativeDateInChangeTable.checked;
this.requestUpdate();
}}
/>
</span>
</section>
<section>
<span class="title">Diff view</span>
<span class="value">
<gr-select
.bindValue=${convertToString(this.prefs?.diff_view)}
@change=${() => {
this.prefs!.diff_view = this.diffViewSelect
.value as DiffViewMode;
this.requestUpdate();
}}
>
<select id="diffViewSelect">
<option value="SIDE_BY_SIDE">Side by side</option>
<option value="UNIFIED_DIFF">Unified diff</option>
</select>
</gr-select>
</span>
</section>
<section>
<label for="showSizeBarsInFileList" class="title"
>Show size bars in file list</label
>
<span class="value">
<input
id="showSizeBarsInFileList"
type="checkbox"
?checked=${this.prefs?.size_bar_in_change_table}
@change=${() => {
this.prefs!.size_bar_in_change_table =
this.showSizeBarsInFileList.checked;
this.requestUpdate();
}}
/>
</span>
</section>
<section>
<label for="publishCommentsOnPush" class="title"
>Publish comments on push</label
>
<span class="value">
<input
id="publishCommentsOnPush"
type="checkbox"
?checked=${this.prefs?.publish_comments_on_push}
@change=${() => {
this.prefs!.publish_comments_on_push =
this.publishCommentsOnPush.checked;
this.requestUpdate();
}}
/>
</span>
</section>
<section>
<label for="workInProgressByDefault" class="title"
>Set new changes to "work in progress" by default</label
>
<span class="value">
<input
id="workInProgressByDefault"
type="checkbox"
?checked=${this.prefs?.work_in_progress_by_default}
@change=${() => {
this.prefs!.work_in_progress_by_default =
this.workInProgressByDefault.checked;
this.requestUpdate();
}}
/>
</span>
</section>
<section>
<label for="disableKeyboardShortcuts" class="title"
>Disable all keyboard shortcuts</label
>
<span class="value">
<input
id="disableKeyboardShortcuts"
type="checkbox"
?checked=${this.prefs?.disable_keyboard_shortcuts}
@change=${() => {
this.prefs!.disable_keyboard_shortcuts =
this.disableKeyboardShortcuts.checked;
this.requestUpdate();
}}
/>
</span>
</section>
<section>
<label for="disableTokenHighlighting" class="title"
>Disable token highlighting on hover</label
>
<span class="value">
<input
id="disableTokenHighlighting"
type="checkbox"
?checked=${this.prefs?.disable_token_highlighting}
@change=${() => {
this.prefs!.disable_token_highlighting =
this.disableTokenHighlighting.checked;
this.requestUpdate();
}}
/>
</span>
</section>
<section>
<label for="insertSignedOff" class="title">
Insert Signed-off-by Footer For Inline Edit Changes
</label>
<span class="value">
<input
id="insertSignedOff"
type="checkbox"
?checked=${this.prefs?.signed_off_by}
@change=${() => {
this.prefs!.signed_off_by = this.insertSignedOff.checked;
this.requestUpdate();
}}
/>
</span>
</section>
</div>
<gr-button
id="savePrefs"
@click=${async () => {
await this.save();
}}
?disabled=${!this.hasUnsavedChanges()}
>Save changes</gr-button
>
</fieldset>
`;
}
// When the experiment is over, move this back to render(),
// removing this function.
private renderBrowserNotifications() {
if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
return nothing;
if (
!this.flagsService.isEnabled(
KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
) &&
!areNotificationsEnabled(this.account)
)
return nothing;
return html` <section id="allowBrowserNotificationsSection">
<div class="title">
<label for="allowBrowserNotifications"
>Allow browser notifications</label
>
<a
href=${getDocUrl(
this.docsBaseUrl,
'user-attention-set.html#_browser_notifications'
)}
target="_blank"
rel="noopener noreferrer"
>
<gr-icon icon="help" title="read documentation"></gr-icon>
</a>
</div>
<span class="value">
<input
id="allowBrowserNotifications"
type="checkbox"
?checked=${this.prefs?.allow_browser_notifications}
@change=${() => {
this.prefs!.allow_browser_notifications =
this.allowBrowserNotifications!.checked;
this.requestUpdate();
}}
/>
</span>
</section>`;
}
// When the experiment is over, move this back to render(),
// removing this function.
private renderGenerateSuggestionWhenCommenting() {
if (
!this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
!this.suggestionsProvider
)
return nothing;
return html`
<section id="allowSuggestCodeWhileCommentingSection">
<div class="title">
<label for="allowSuggestCodeWhileCommenting"
>AI suggested fixes while commenting</label
>
<a
href=${this.suggestionsProvider.getDocumentationLink?.() ||
getDocUrl(
this.docsBaseUrl,
'user-suggest-edits.html#_generate_suggestion'
)}
target="_blank"
rel="noopener noreferrer"
>
<gr-icon icon="help" title="read documentation"></gr-icon>
</a>
</div>
<span class="value">
<input
id="allowSuggestCodeWhileCommenting"
type="checkbox"
?checked=${this.prefs?.allow_suggest_code_while_commenting}
@change=${() => {
this.prefs!.allow_suggest_code_while_commenting =
this.allowSuggestCodeWhileCommenting!.checked;
this.requestUpdate();
}}
/>
</span>
</section>
`;
}
// When this is fixed and can be re-enabled, move this back to render()
// and remove function.
private renderDefaultBaseForMerges() {
if (!this.prefs?.default_base_for_merges) return nothing;
return nothing;
// TODO: Re-enable respecting the default_base_for_merges preference.
// See corresponding TODO in change-model.
// return html`
// <section>
// <span class="title">Default Base For Merges</span>
// <span class="value">
// <gr-select
// .bindValue=${convertToString(
// this.prefs?.default_base_for_merges
// )}
// @change=${() => {
// this.prefs!.default_base_for_merges = this
// .defaultBaseForMergesSelect.value as DefaultBase;
// this.requestUpdate();
// }}
// >
// <select id="defaultBaseForMergesSelect">
// <option value="AUTO_MERGE">Auto Merge</option>
// <option value="FIRST_PARENT">First Parent</option>
// </select>
// </gr-select>
// </span>
// </section>
// `;
}
// private but used in test
hasUnsavedChanges() {
// We have to wrap boolean values in Boolean() to ensure undefined values
// use false rather than undefined.
return (
this.originalPrefs?.theme !== this.prefs?.theme ||
this.originalPrefs?.changes_per_page !== this.prefs?.changes_per_page ||
this.originalPrefs?.date_format !== this.prefs?.date_format ||
this.originalPrefs?.time_format !== this.prefs?.time_format ||
this.originalPrefs?.email_strategy !== this.prefs?.email_strategy ||
this.originalPrefs?.email_format !== this.prefs?.email_format ||
Boolean(this.originalPrefs?.allow_browser_notifications) !==
Boolean(this.prefs?.allow_browser_notifications) ||
Boolean(this.originalPrefs?.allow_suggest_code_while_commenting) !==
Boolean(this.prefs?.allow_suggest_code_while_commenting) ||
this.originalPrefs?.default_base_for_merges !==
this.prefs?.default_base_for_merges ||
Boolean(this.originalPrefs?.relative_date_in_change_table) !==
Boolean(this.prefs?.relative_date_in_change_table) ||
this.originalPrefs?.diff_view !== this.prefs?.diff_view ||
Boolean(this.originalPrefs?.size_bar_in_change_table) !==
Boolean(this.prefs?.size_bar_in_change_table) ||
Boolean(this.originalPrefs?.publish_comments_on_push) !==
Boolean(this.prefs?.publish_comments_on_push) ||
Boolean(this.originalPrefs?.work_in_progress_by_default) !==
Boolean(this.prefs?.work_in_progress_by_default) ||
Boolean(this.originalPrefs?.disable_keyboard_shortcuts) !==
Boolean(this.prefs?.disable_keyboard_shortcuts) ||
Boolean(this.originalPrefs?.disable_token_highlighting) !==
Boolean(this.prefs?.disable_token_highlighting) ||
Boolean(this.originalPrefs?.signed_off_by) !==
Boolean(this.prefs?.signed_off_by)
);
}
async save() {
if (!this.prefs) return;
await this.getUserModel().updatePreferences(this.prefs);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-preferences': GrPreferences;
}
}