| /** |
| * @license |
| * Copyright 2024 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../../shared/gr-button/gr-button'; |
| 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 {css, html, LitElement, 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'; |
| import '@material/web/checkbox/checkbox'; |
| import {MdCheckbox} from '@material/web/checkbox/checkbox'; |
| import {materialStyles} from '../../../styles/gr-material-styles'; |
| import '@material/web/select/outlined-select'; |
| import '@material/web/select/select-option'; |
| |
| /** |
| * This provides an interface to show settings for a user profile |
| * as defined in PreferencesInfo. |
| */ |
| @customElement('gr-preferences') |
| export class GrPreferences extends LitElement { |
| @query('#allowBrowserNotifications') |
| allowBrowserNotifications?: MdCheckbox; |
| |
| @query('#allowSuggestCodeWhileCommenting') |
| allowSuggestCodeWhileCommenting?: MdCheckbox; |
| |
| @query('#allowAiCommentAutocompletion') |
| allowAiCommentAutocompletion?: MdCheckbox; |
| |
| @query('#relativeDateInChangeTable') |
| relativeDateInChangeTable!: MdCheckbox; |
| |
| @query('#showSizeBarsInFileList') showSizeBarsInFileList!: MdCheckbox; |
| |
| @query('#publishCommentsOnPush') publishCommentsOnPush!: MdCheckbox; |
| |
| @query('#workInProgressByDefault') workInProgressByDefault!: MdCheckbox; |
| |
| @query('#disableKeyboardShortcuts') |
| disableKeyboardShortcuts!: MdCheckbox; |
| |
| @query('#disableTokenHighlighting') |
| disableTokenHighlighting!: MdCheckbox; |
| |
| @query('#insertSignedOff') insertSignedOff!: MdCheckbox; |
| |
| @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) |
| ); |
| subscribe( |
| this, |
| () => this.getPluginLoader().pluginsModel.suggestionsPlugins$, |
| // We currently support results from only 1 provider. |
| suggestionsPlugins => |
| (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider) |
| ); |
| } |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| menuPageStyles, |
| grFormStyles, |
| materialStyles, |
| 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"> |
| <md-outlined-select |
| value=${this.prefs?.theme ?? AppTheme.AUTO} |
| @change=${(e: Event) => { |
| const select = e.target as HTMLSelectElement; |
| this.prefs!.theme = select.value as AppTheme; |
| this.requestUpdate(); |
| }} |
| > |
| <md-select-option value="AUTO"> |
| <div slot="headline">Auto (based on OS prefs)</div> |
| </md-select-option> |
| <md-select-option value="LIGHT"> |
| <div slot="headline">Light</div> |
| </md-select-option> |
| <md-select-option value="DARK"> |
| <div slot="headline">Dark</div> |
| </md-select-option> |
| </md-outlined-select> |
| </span> |
| </section> |
| <section> |
| <label class="title" for="changesPerPageSelect" |
| >Changes per page</label |
| > |
| <span class="value"> |
| <md-outlined-select |
| value=${convertToString(this.prefs?.changes_per_page)} |
| @change=${(e: Event) => { |
| const select = e.target as HTMLSelectElement; |
| this.prefs!.changes_per_page = Number(select.value) as |
| | 10 |
| | 25 |
| | 50 |
| | 100; |
| this.requestUpdate(); |
| }} |
| > |
| <md-select-option value="10"> |
| <div slot="headline">10 rows per page</div> |
| </md-select-option> |
| <md-select-option value="25"> |
| <div slot="headline">25 rows per page</div> |
| </md-select-option> |
| <md-select-option value="50"> |
| <div slot="headline">50 rows per page</div> |
| </md-select-option> |
| <md-select-option value="100"> |
| <div slot="headline">100 rows per page</div> |
| </md-select-option> |
| </md-outlined-select> |
| </span> |
| </section> |
| <section> |
| <label class="title" for="dateTimeFormatSelect" |
| >Date/time format</label |
| > |
| <span class="value"> |
| <md-outlined-select |
| value=${convertToString(this.prefs?.date_format)} |
| @change=${(e: Event) => { |
| const select = e.target as HTMLSelectElement; |
| this.prefs!.date_format = select.value as DateFormat; |
| this.requestUpdate(); |
| }} |
| > |
| <md-select-option value="STD"> |
| <div slot="headline">Jun 3 ; Jun 3, 2016</div> |
| </md-select-option> |
| <md-select-option value="US"> |
| <div slot="headline">06/03 ; 06/03/16</div> |
| </md-select-option> |
| <md-select-option value="ISO"> |
| <div slot="headline">06-03 ; 2016-06-03</div> |
| </md-select-option> |
| <md-select-option value="EURO"> |
| <div slot="headline">3. Jun ; 03.06.2016</div> |
| </md-select-option> |
| <md-select-option value="UK"> |
| <div slot="headline">03/06 ; 03/06/2016</div> |
| </md-select-option> |
| </md-outlined-select> |
| <md-outlined-select |
| value=${convertToString(this.prefs?.time_format)} |
| @change=${(e: Event) => { |
| const select = e.target as HTMLSelectElement; |
| this.prefs!.time_format = select.value as TimeFormat; |
| this.requestUpdate(); |
| }} |
| > |
| <md-select-option value="HHMM_12"> |
| <div slot="headline">4:10 PM</div> |
| </md-select-option> |
| <md-select-option value="HHMM_24"> |
| <div slot="headline">16:10</div> |
| </md-select-option> |
| </md-outlined-select> |
| </span> |
| </section> |
| <section> |
| <label class="title" for="emailNotificationsSelect" |
| >Email notifications</label |
| > |
| <span class="value"> |
| <md-outlined-select |
| value=${convertToString(this.prefs?.email_strategy)} |
| @change=${(e: Event) => { |
| const select = e.target as HTMLSelectElement; |
| this.prefs!.email_strategy = select.value as EmailStrategy; |
| this.requestUpdate(); |
| }} |
| > |
| <md-select-option value="CC_ON_OWN_COMMENTS"> |
| <div slot="headline">Every comment</div> |
| </md-select-option> |
| <md-select-option value="ENABLED"> |
| <div slot="headline">Only comments left by others</div> |
| </md-select-option> |
| <md-select-option value="ATTENTION_SET_ONLY"> |
| <div slot="headline">Only when I am in the attention set</div> |
| </md-select-option> |
| </md-outlined-select> |
| </span> |
| </section> |
| <section> |
| <label class="title" for="emailFormatSelect">Email format</label> |
| <span class="value"> |
| <md-outlined-select |
| value=${convertToString(this.prefs?.email_format)} |
| @change=${(e: Event) => { |
| const select = e.target as HTMLSelectElement; |
| this.prefs!.email_format = select.value as EmailFormat; |
| this.requestUpdate(); |
| }} |
| > |
| <md-select-option value="HTML_PLAINTEXT"> |
| <div slot="headline">HTML and plaintext</div> |
| </md-select-option> |
| <md-select-option value="PLAINTEXT"> |
| <div slot="headline">Plaintext only</div> |
| </md-select-option> |
| </md-outlined-select> |
| </span> |
| </section> |
| ${this.renderBrowserNotifications()} |
| ${this.renderGenerateSuggestionWhenCommenting()} |
| ${this.renderAiCommentAutocompletion()} |
| ${this.renderDefaultBaseForMerges()} |
| <section> |
| <label class="title" for="relativeDateInChangeTable" |
| >Show Relative Dates In Changes Table</label |
| > |
| <span class="value"> |
| <md-checkbox |
| id="relativeDateInChangeTable" |
| ?checked=${!!this.prefs?.relative_date_in_change_table} |
| @change=${() => { |
| this.prefs!.relative_date_in_change_table = |
| this.relativeDateInChangeTable.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </span> |
| </section> |
| <section> |
| <span class="title">Diff view</span> |
| <span class="value"> |
| <md-outlined-select |
| value=${convertToString(this.prefs?.diff_view)} |
| @change=${(e: Event) => { |
| const select = e.target as HTMLSelectElement; |
| this.prefs!.diff_view = select.value as DiffViewMode; |
| this.requestUpdate(); |
| }} |
| > |
| <md-select-option value="SIDE_BY_SIDE"> |
| <div slot="headline">Side by side</div> |
| </md-select-option> |
| <md-select-option value="UNIFIED_DIFF"> |
| <div slot="headline">Unified diff</div> |
| </md-select-option> |
| </md-outlined-select> |
| </span> |
| </section> |
| <section> |
| <label for="showSizeBarsInFileList" class="title" |
| >Show size bars in file list</label |
| > |
| <span class="value"> |
| <md-checkbox |
| id="showSizeBarsInFileList" |
| ?checked=${!!this.prefs?.size_bar_in_change_table} |
| @change=${() => { |
| this.prefs!.size_bar_in_change_table = |
| this.showSizeBarsInFileList.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </span> |
| </section> |
| <section> |
| <label for="publishCommentsOnPush" class="title" |
| >Publish comments on push</label |
| > |
| <span class="value"> |
| <md-checkbox |
| id="publishCommentsOnPush" |
| ?checked=${!!this.prefs?.publish_comments_on_push} |
| @change=${() => { |
| this.prefs!.publish_comments_on_push = |
| this.publishCommentsOnPush.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </span> |
| </section> |
| <section> |
| <label for="workInProgressByDefault" class="title" |
| >Set new changes to "work in progress" by default</label |
| > |
| <span class="value"> |
| <md-checkbox |
| id="workInProgressByDefault" |
| ?checked=${!!this.prefs?.work_in_progress_by_default} |
| @change=${() => { |
| this.prefs!.work_in_progress_by_default = |
| this.workInProgressByDefault.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </span> |
| </section> |
| <section> |
| <label for="disableKeyboardShortcuts" class="title" |
| >Disable all keyboard shortcuts</label |
| > |
| <span class="value"> |
| <md-checkbox |
| id="disableKeyboardShortcuts" |
| ?checked=${!!this.prefs?.disable_keyboard_shortcuts} |
| @change=${() => { |
| this.prefs!.disable_keyboard_shortcuts = |
| this.disableKeyboardShortcuts.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </span> |
| </section> |
| <section> |
| <label for="disableTokenHighlighting" class="title" |
| >Disable token highlighting on hover</label |
| > |
| <span class="value"> |
| <md-checkbox |
| id="disableTokenHighlighting" |
| ?checked=${!!this.prefs?.disable_token_highlighting} |
| @change=${() => { |
| this.prefs!.disable_token_highlighting = |
| this.disableTokenHighlighting.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </span> |
| </section> |
| <section> |
| <label for="insertSignedOff" class="title"> |
| Insert Signed-off-by Footer For Inline Edit Changes |
| </label> |
| <span class="value"> |
| <md-checkbox |
| id="insertSignedOff" |
| ?checked=${!!this.prefs?.signed_off_by} |
| @change=${() => { |
| this.prefs!.signed_off_by = this.insertSignedOff.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </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_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"> |
| <md-checkbox |
| id="allowBrowserNotifications" |
| ?checked=${!!this.prefs?.allow_browser_notifications} |
| @change=${() => { |
| this.prefs!.allow_browser_notifications = |
| this.allowBrowserNotifications!.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </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_V2) || |
| this.flagsService.isEnabled( |
| KnownExperimentId.ML_SUGGESTED_EDIT_UNCHECK_BY_DEFAULT |
| ) || |
| !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"> |
| <md-checkbox |
| id="allowSuggestCodeWhileCommenting" |
| ?checked=${!!this.prefs?.allow_suggest_code_while_commenting} |
| @change=${() => { |
| this.prefs!.allow_suggest_code_while_commenting = |
| this.allowSuggestCodeWhileCommenting!.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </span> |
| </section> |
| `; |
| } |
| |
| // When the experiment is over, move this back to render(), |
| // removing this function. |
| private renderAiCommentAutocompletion() { |
| if (!this.suggestionsProvider) return nothing; |
| return html` |
| <section id="allowAiCommentAutocompletionSection"> |
| <div class="title"> |
| <label for="allowAiCommentAutocompletion" |
| >AI suggested text completions while commenting</label |
| > |
| </div> |
| <span class="value"> |
| <md-checkbox |
| id="allowAiCommentAutocompletion" |
| ?checked=${!!this.prefs?.allow_autocompleting_comments} |
| @change=${() => { |
| this.prefs!.allow_autocompleting_comments = |
| this.allowAiCommentAutocompletion!.checked; |
| this.requestUpdate(); |
| }} |
| ></md-checkbox> |
| </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"> |
| // <md-outlined-select |
| // .value=${convertToString( |
| // this.prefs?.default_base_for_merges |
| // )} |
| // @change=${(e: Event) => { |
| // const select = e.target as HTMLSelectElement; |
| // this.prefs!.default_base_for_merges = select.value as DefaultBase; |
| // this.requestUpdate(); |
| // }} |
| // > |
| // <md-select-option value="AUTO_MERGE"> |
| // <div slot="headline">Auto Merge</div> |
| // </md-select-option> |
| // <md-select-option value="FIRST_PARENT"> |
| // <div slot="headline">First Parent</div> |
| // </md-select-option> |
| // </md-outlined-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) || |
| Boolean(this.originalPrefs?.allow_autocompleting_comments) !== |
| Boolean(this.prefs?.allow_autocompleting_comments) || |
| 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; |
| } |
| } |