Merge "Do not grant Revert permission to Registered Users by default"
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 8ee7669..754a233 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -7,7 +7,7 @@
import '../../shared/gr-button/gr-button';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
import {
AutocompleteSuggestion,
AutocompleteQuery,
@@ -222,7 +222,7 @@
</h3>
<fieldset>
<div>
- <gr-textarea
+ <gr-suggestion-textarea
class="description"
autocomplete="on"
rows="4"
@@ -230,7 +230,7 @@
?disabled=${this.computeGroupDisabled()}
.text=${this.groupConfig?.description ?? ''}
@text-changed=${this.handleDescriptionTextChanged}
- ></gr-textarea>
+ ></gr-suggestion-textarea>
</div>
<span class="value">
<gr-button
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index 256c6a9..5cf71f8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -97,14 +97,14 @@
<h3 class="heading-3">Description</h3>
<fieldset>
<div>
- <gr-textarea
+ <gr-suggestion-textarea
autocomplete="on"
class="description monospace"
disabled=""
monospace=""
rows="4"
>
- </gr-textarea>
+ </gr-suggestion-textarea>
</div>
<span class="value">
<gr-button
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 90277534..4e8841e 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -9,7 +9,7 @@
import '../../shared/gr-button/gr-button';
import '../../shared/gr-download-commands/gr-download-commands';
import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
import '../gr-repo-plugin-config/gr-repo-plugin-config';
import {
ConfigInfo,
@@ -244,7 +244,7 @@
return html`
<h3 id="Description" class="heading-3">Description</h3>
<fieldset>
- <gr-textarea
+ <gr-suggestion-textarea
id="descriptionInput"
class="description"
autocomplete="on"
@@ -254,7 +254,7 @@
?disabled=${this.readOnly}
.text=${this.repoConfig.description ?? ''}
@text-changed=${this.handleDescriptionTextChanged}
- ></gr-textarea>
+ ></gr-suggestion-textarea>
</fieldset>
`;
}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 4deb99a..0d30933 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -42,7 +42,7 @@
import {PageErrorEvent} from '../../../types/events';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrSelect} from '../../shared/gr-select/gr-select';
-import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
import {IronInputElement} from '@polymer/iron-input/iron-input';
import {fixture, html, assert} from '@open-wc/testing';
@@ -199,7 +199,7 @@
<fieldset>
<h3 class="heading-3" id="Description">Description</h3>
<fieldset>
- <gr-textarea
+ <gr-suggestion-textarea
autocomplete="on"
class="description monospace"
disabled=""
@@ -208,7 +208,7 @@
placeholder="<Insert repo description here>"
rows="4"
>
- </gr-textarea>
+ </gr-suggestion-textarea>
</fieldset>
<h3 class="heading-3" id="Options">Repository Options</h3>
<fieldset id="options">
@@ -728,7 +728,7 @@
'#Title'
).classList.contains('edited')
);
- queryAndAssert<GrTextarea>(element, '#descriptionInput').text =
+ queryAndAssert<GrSuggestionTextarea>(element, '#descriptionInput').text =
configInputObj.description;
queryAndAssert<GrSelect>(element, '#stateSelect').bindValue =
configInputObj.state;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index ad3e15c..30f4bf8 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -458,7 +458,9 @@
// app.
assign(
window.location,
- '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+ `${basePath}/login/${encodeURIComponent(
+ returnUrl.substring(basePath.length)
+ )}`
);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 039a616..863f4f8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -10,7 +10,7 @@
import '../gr-dialog/gr-dialog';
import '../gr-formatted-text/gr-formatted-text';
import '../gr-icon/gr-icon';
-import '../gr-textarea/gr-textarea';
+import '../gr-suggestion-textarea/gr-suggestion-textarea';
import '../gr-tooltip-content/gr-tooltip-content';
import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
import '../gr-account-label/gr-account-label';
@@ -20,7 +20,7 @@
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {provide, resolve} from '../../../models/dependency';
-import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../gr-suggestion-textarea/gr-suggestion-textarea';
import {
AccountDetailInfo,
DraftInfo,
@@ -87,6 +87,8 @@
import {getFileExtension} from '../../../utils/file-util';
import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
import {deepEqual} from '../../../utils/deep-util';
+import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
+import {waitUntil} from '../../../utils/async-util';
// visible for testing
export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
@@ -141,7 +143,7 @@
*/
@query('#editTextarea')
- textarea?: GrTextarea;
+ textarea?: GrSuggestionTextarea;
@query('#container')
container?: HTMLElement;
@@ -155,6 +157,9 @@
@query('#confirmDeleteCommentDialog')
confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
+ @query('#suggestionDiffPreview')
+ suggestionDiffPreview?: GrSuggestionDiffPreview;
+
@property({type: Object})
comment?: Comment;
@@ -859,7 +864,7 @@
private renderEditingTextarea() {
if (!this.editing || this.collapsed) return;
return html`
- <gr-textarea
+ <gr-suggestion-textarea
id="editTextarea"
class="editMessage"
autocomplete="on"
@@ -876,7 +881,7 @@
this.autoSaveTrigger$.next();
this.generateSuggestionTrigger$.next();
}}
- ></gr-textarea>
+ ></gr-suggestion-textarea>
`;
}
@@ -1059,6 +1064,7 @@
if (this.generatedFixSuggestion) {
return html`<gr-suggestion-diff-preview
+ id="suggestionDiffPreview"
.fixSuggestionInfo=${this.generatedFixSuggestion}
></gr-suggestion-diff-preview>`;
} else if (this.generatedSuggestion) {
@@ -1269,7 +1275,13 @@
return;
}
this.generatedFixSuggestion = suggestion;
- this.autoSaveTrigger$.next();
+ try {
+ await waitUntil(() => this.getFixSuggestions() !== undefined);
+ this.autoSaveTrigger$.next();
+ } catch (error) {
+ // Error is ok in some cases like quick save by user.
+ console.warn(error);
+ }
}
private renderRobotActions() {
@@ -1682,7 +1694,7 @@
isError(this.comment) ||
this.messageText.trimEnd() !== this.comment.message ||
this.unresolved !== this.comment.unresolved ||
- !deepEqual(this.comment.fix_suggestions, this.getFixSuggestions())
+ this.isFixSuggestionChanged()
);
}
@@ -1690,15 +1702,22 @@
private rawSave(options: {showToast: boolean}) {
assert(isDraft(this.comment), 'only drafts are editable');
assert(!isSaving(this.comment), 'saving already in progress');
- return this.getCommentsModel().saveDraft(
- {
- ...this.comment,
- message: this.messageText.trimEnd(),
- unresolved: this.unresolved,
- fix_suggestions: this.getFixSuggestions(),
- },
- options.showToast
- );
+ const draft: DraftInfo = {
+ ...this.comment,
+ message: this.messageText.trimEnd(),
+ unresolved: this.unresolved,
+ };
+ if (this.isFixSuggestionChanged()) {
+ draft.fix_suggestions = this.getFixSuggestions();
+ }
+ return this.getCommentsModel().saveDraft(draft, options.showToast);
+ }
+
+ isFixSuggestionChanged(): boolean {
+ // Check to not change fix suggestion when draft is not being edited only
+ // when user quickly disable generating suggestions and click save
+ if (!this.editing && this.generateSuggestion) return false;
+ return !deepEqual(this.comment?.fix_suggestions, this.getFixSuggestions());
}
getFixSuggestions(): FixSuggestionInfo[] | undefined {
@@ -1708,6 +1727,13 @@
if (!this.generatedFixSuggestion) return undefined;
// Disable fix suggestions when the comment already has a user suggestion
if (this.comment && hasUserSuggestion(this.comment)) return undefined;
+ // we ignore fixSuggestions until they are previewed.
+ if (
+ this.suggestionDiffPreview &&
+ !this.suggestionDiffPreview?.previewed &&
+ !this.suggestionLoading
+ )
+ return undefined;
return [this.generatedFixSuggestion];
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 098b79a..ef02c95 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -406,7 +406,7 @@
</div>
</div>
<div class="body">
- <gr-textarea
+ <gr-suggestion-textarea
autocomplete="on"
class="code editMessage"
code=""
@@ -414,7 +414,7 @@
rows="4"
text="This is the test comment message."
>
- </gr-textarea>
+ </gr-suggestion-textarea>
<gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
<div class="actions">
<div class="leftActions">
@@ -1081,7 +1081,7 @@
await element.updateComplete;
assert.dom.equal(
queryAndAssert(element, 'gr-suggestion-diff-preview'),
- /* HTML */ '<gr-suggestion-diff-preview> </gr-suggestion-diff-preview>'
+ /* HTML */ '<gr-suggestion-diff-preview id="suggestionDiffPreview"> </gr-suggestion-diff-preview>'
);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 923a00e..8314912 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -62,6 +62,9 @@
@property({type: Boolean})
showAddSuggestionButton = false;
+ @property({type: Boolean, attribute: 'previewed', reflect: true})
+ previewed = false;
+
@property({type: String})
uuid?: string;
@@ -270,6 +273,7 @@
)
return;
+ this.previewed = false;
this.reporting.time(Timing.PREVIEW_FIX_LOAD);
const res = await this.restApiService.getFixPreview(
this.changeNum,
@@ -287,6 +291,7 @@
if (currentPreviews.length > 0) {
this.preview = currentPreviews[0];
this.previewLoadedFor = this.fixSuggestionInfo;
+ this.previewed = true;
}
return res;
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
similarity index 98%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 7f70911..78b7610 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -72,8 +72,8 @@
}
}
-@customElement('gr-textarea')
-export class GrTextarea extends LitElement {
+@customElement('gr-suggestion-textarea')
+export class GrSuggestionTextarea extends LitElement {
/**
* @event bind-value-changed
*/
@@ -669,6 +669,6 @@
declare global {
interface HTMLElementTagNameMap {
- 'gr-textarea': GrTextarea;
+ 'gr-suggestion-textarea': GrSuggestionTextarea;
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
similarity index 96%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
index d84f5a7..e73f685 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-import './gr-textarea';
-import {GrTextarea} from './gr-textarea';
+import './gr-suggestion-textarea';
+import {GrSuggestionTextarea} from './gr-suggestion-textarea';
import {
Item,
ItemSelectedEventDetail,
@@ -20,11 +20,13 @@
import {createAccountWithEmail} from '../../../test/test-data-generators';
import {Key} from '../../../utils/dom-util';
-suite('gr-textarea tests', () => {
- let element: GrTextarea;
+suite('gr-suggestion-textarea tests', () => {
+ let element: GrSuggestionTextarea;
setup(async () => {
- element = await fixture<GrTextarea>(html`<gr-textarea></gr-textarea>`);
+ element = await fixture<GrSuggestionTextarea>(
+ html`<gr-suggestion-textarea></gr-suggestion-textarea>`
+ );
sinon.stub(element.reporting, 'reportInteraction');
await element.updateComplete;
});
@@ -706,12 +708,12 @@
});
});
- suite('gr-textarea monospace', () => {
- let element: GrTextarea;
+ suite('gr-suggestion-textarea monospace', () => {
+ let element: GrSuggestionTextarea;
setup(async () => {
- element = await fixture<GrTextarea>(
- html`<gr-textarea monospace></gr-textarea>`
+ element = await fixture<GrSuggestionTextarea>(
+ html`<gr-suggestion-textarea monospace></gr-suggestion-textarea>`
);
await element.updateComplete;
});
@@ -721,12 +723,12 @@
});
});
- suite('gr-textarea hideBorder', () => {
- let element: GrTextarea;
+ suite('gr-suggestion-textarea hideBorder', () => {
+ let element: GrSuggestionTextarea;
setup(async () => {
- element = await fixture<GrTextarea>(
- html`<gr-textarea hide-border></gr-textarea>`
+ element = await fixture<GrSuggestionTextarea>(
+ html`<gr-suggestion-textarea hide-border></gr-suggestion-textarea>`
);
await element.updateComplete;
});
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6de43ed..cbb2d8c 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -13,6 +13,7 @@
import '../api/embed';
import '../scripts/bundled-polymer';
import './diff/gr-diff/gr-diff';
+import './gr-textarea';
import './diff/gr-diff-cursor/gr-diff-cursor';
import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
new file mode 100644
index 0000000..35dc2d1
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -0,0 +1,788 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, queryAsync} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+
+/**
+ * Waits for the next animation frame.
+ */
+async function animationFrame(): Promise<void> {
+ return new Promise(resolve => {
+ requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+}
+
+/**
+ * Whether the current browser supports `plaintext-only` for contenteditable
+ * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only
+ */
+function supportsPlainTextEditing() {
+ const div = document.createElement('div');
+ try {
+ div.contentEditable = 'PLAINTEXT-ONLY';
+ return div.contentEditable === 'plaintext-only';
+ } catch (e) {
+ return false;
+ }
+}
+
+/** Input custom event detail object. */
+export interface InputEventDetail {
+ value: string;
+}
+
+/** Cursor position change custom event detail object.
+ *
+ * The current position of the cursor.
+ */
+export interface CursorPositionChangeEventDetail {
+ position: number;
+}
+
+/** hint shown custom event detail object */
+export interface HintShownEventDetail {
+ hint: string;
+}
+
+/** hint dismissed custom event detail object */
+export interface HintDismissedEventDetail {
+ hint: string;
+}
+
+/** hint applied custom event detail object */
+export interface HintAppliedEventDetail {
+ hint: string;
+ oldValue: string;
+}
+
+/** Class for autocomplete hint */
+export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint';
+
+const ACCEPT_PLACEHOLDER_HINT_LABEL =
+ 'Press TAB to accept the placeholder hint.';
+
+/**
+ * A custom textarea component which allows autocomplete functionality.
+ * This component is only supported in Chrome. Other browsers are not supported.
+ *
+ * Example usage:
+ * <gr-textarea></gr-textarea>
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends LitElement {
+ // editableDivElement is available right away where it may be undefined. This
+ // is used for calls for scrollTop as if it is undefined then we can fallback
+ // to 0. For other usecases use editableDiv.
+ @query('.editableDiv')
+ private readonly editableDivElement?: HTMLDivElement;
+
+ @queryAsync('.editableDiv')
+ private readonly editableDiv?: Promise<HTMLDivElement>;
+
+ @property({type: Boolean, reflect: true}) disabled = false;
+
+ @property({type: String, reflect: true}) placeholder: string | undefined;
+
+ /**
+ * The hint is shown as a autocomplete string which can be added by pressing
+ * TAB.
+ *
+ * The hint is shown
+ * 1. At the cursor position, only when cursor position is at the end of
+ * textarea content.
+ * 2. When textarea has focus.
+ * 3. When selection inside the textarea is collapsed.
+ *
+ * When hint is applied listen for hintApplied event and remove the hint
+ * as component property to avoid showing the hint again.
+ */
+ @property({type: String})
+ set hint(newHint) {
+ if (this.hint !== newHint) {
+ this.innerHint = newHint;
+ this.updateHintInDomIfRendered();
+ }
+ }
+
+ get hint() {
+ return this.innerHint;
+ }
+
+ /**
+ * Show hint is shown as placeholder which people can autocomplete to.
+ *
+ * This takes precedence over hint property.
+ * It is shown even when textarea has no focus.
+ * This is shown only when textarea is blank.
+ */
+ @property({type: String}) placeholderHint: string | undefined;
+
+ /**
+ * Sets the value for textarea and also renders it in dom if it is different
+ * from last rendered value.
+ *
+ * To prevent cursor position from jumping to front of text even when value
+ * remains same, Check existing value before triggering the update and only
+ * update when there is a change.
+ *
+ * Also .innerText binding can't be used for security reasons.
+ */
+ @property({type: String})
+ set value(newValue) {
+ if (this.ignoreValue && this.ignoreValue === newValue) {
+ return;
+ }
+ const oldVal = this.value;
+ if (oldVal !== newValue) {
+ this.innerValue = newValue;
+ this.updateValueInDom();
+ }
+ }
+
+ get value() {
+ return this.innerValue;
+ }
+
+ /**
+ * This value will be ignored by textarea and is not set.
+ */
+ @property({type: String}) ignoreValue: string | undefined;
+
+ /**
+ * Sets cursor at the end of content on focus.
+ */
+ @property({type: Boolean}) putCursorAtEndOnFocus = false;
+
+ /**
+ * Enables save shortcut.
+ *
+ * On S key down with control or meta key enabled is exposed with output event
+ * 'saveShortcut'.
+ */
+ @property({type: Boolean}) enableSaveShortcut = false;
+
+ /*
+ * Is textarea focused. This is a readonly property.
+ */
+ get isFocused(): boolean {
+ return this.focused;
+ }
+
+ /**
+ * Native element for editable div.
+ */
+ get nativeElement() {
+ return this.editableDivElement;
+ }
+
+ /**
+ * Scroll Top for editable div.
+ */
+ override get scrollTop() {
+ return this.editableDivElement?.scrollTop ?? 0;
+ }
+
+ private innerValue: string | undefined;
+
+ private innerHint: string | undefined;
+
+ private focused = false;
+
+ private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
+
+ static override get styles() {
+ return [
+ css`
+ :host {
+ display: inline-block;
+ position: relative;
+ width: 100%;
+ }
+
+ :host([disabled]) {
+ .editableDiv {
+ background-color: var(--input-field-disabled-bg, lightgrey);
+ color: var(--text-disabled, black);
+ cursor: default;
+ }
+ }
+
+ .editableDiv {
+ background-color: var(--input-field-bg, white);
+ border: 2px solid var(--onedev-textarea-border-color, white);
+ border-radius: 4px;
+ box-sizing: border-box;
+ color: var(--text-default, black);
+ max-height: var(--onedev-textarea-max-height, 16em);
+ min-height: var(--onedev-textarea-min-height, 4em);
+ overflow-x: auto;
+ padding: 12px;
+ white-space: pre-wrap;
+ width: 100%;
+
+ &:focus-visible {
+ border-color: var(--onedev-textarea-focus-outline-color, black);
+ outline: none;
+ }
+
+ &:empty::before {
+ content: attr(data-placeholder);
+ color: var(--text-secondary, lightgrey);
+ display: inline;
+ pointer-events: none;
+ }
+
+ &.hintShown:empty::after,
+ .autocomplete-hint:empty::after {
+ background-color: var(--secondary-bg-color, white);
+ border: 1px solid var(--text-secondary, lightgrey);
+ border-radius: 2px;
+ content: 'tab';
+ color: var(--text-secondary, lightgrey);
+ display: inline;
+ pointer-events: none;
+ font-size: 10px;
+ line-height: 10px;
+ margin-left: 4px;
+ padding: 1px 3px;
+ }
+
+ .autocomplete-hint {
+ &:empty::before {
+ content: attr(data-hint);
+ color: var(--text-secondary, lightgrey);
+ }
+ }
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ const isHintShownAsPlaceholder =
+ (!this.disabled && this.placeholderHint) ?? false;
+
+ const placeholder = isHintShownAsPlaceholder
+ ? this.placeholderHint
+ : this.placeholder;
+ const ariaPlaceholder = isHintShownAsPlaceholder
+ ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL
+ : placeholder;
+
+ const classes = classMap({
+ editableDiv: true,
+ hintShown: isHintShownAsPlaceholder,
+ });
+
+ // Chrome supports non-standard "contenteditable=plaintext-only",
+ // which prevents HTML from being inserted into a contenteditable element.
+ // https://github.com/w3c/editing/issues/162
+ return html`<div
+ aria-disabled=${this.disabled}
+ aria-multiline="true"
+ aria-placeholder=${ifDefined(ariaPlaceholder)}
+ data-placeholder=${ifDefined(placeholder)}
+ class=${classes}
+ contenteditable=${this.contentEditableAttributeValue}
+ dir="ltr"
+ role="textbox"
+ @input=${this.onInput}
+ @focus=${this.onFocus}
+ @blur=${this.onBlur}
+ @keydown=${this.handleKeyDown}
+ @keyup=${this.handleKeyUp}
+ @mouseup=${this.handleMouseUp}
+ @scroll=${this.handleScroll}
+ ></div>`;
+ }
+
+ /**
+ * Focuses the textarea.
+ */
+ override async focus() {
+ const editableDivElement = await this.editableDiv;
+ const isFocused = this.isFocused;
+ editableDivElement?.focus?.();
+ // If already focused, do not change the cursor position.
+ if (this.putCursorAtEndOnFocus && !isFocused) {
+ await this.putCursorAtEnd();
+ }
+ }
+
+ /**
+ * Puts the cursor at the end of existing content.
+ * Scrolls the content of textarea towards the end.
+ */
+ async putCursorAtEnd() {
+ const editableDivElement = await this.editableDiv;
+ const selection = this.getSelection();
+
+ if (!editableDivElement || !selection) {
+ return;
+ }
+
+ const range = document.createRange();
+ editableDivElement.focus();
+ range.setStart(editableDivElement, editableDivElement.childNodes.length);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ this.scrollToCursorPosition(range);
+
+ range.detach();
+
+ await this.onCursorPositionChange(null);
+ }
+
+ /**
+ * Sets cursor position to given position and scrolls the content to cursor
+ * position.
+ *
+ * If position is out of bounds of value of textarea then cursor is places at
+ * end of content of textarea.
+ */
+ async setCursorPosition(position: number) {
+ // This will keep track of remaining offset to place the cursor.
+ let remainingOffset = position;
+ let isOnFreshLine = true;
+ let nodeToFocusOn: Node | null = null;
+ const editableDivElement = await this.editableDiv;
+ const selection = this.getSelection();
+
+ if (!editableDivElement || !selection) {
+ return;
+ }
+ editableDivElement.focus();
+ const findNodeToFocusOn = (childNodes: Node[]) => {
+ for (let i = 0; i < childNodes.length; i++) {
+ const childNode = childNodes[i];
+ let currentNodeLength = 0;
+
+ if (childNode.nodeName === 'BR') {
+ currentNodeLength++;
+ isOnFreshLine = true;
+ }
+
+ if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+ currentNodeLength++;
+ }
+
+ isOnFreshLine = false;
+
+ if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) {
+ currentNodeLength += childNode.textContent.length;
+ }
+
+ if (remainingOffset <= currentNodeLength) {
+ nodeToFocusOn = childNode;
+ break;
+ } else {
+ remainingOffset -= currentNodeLength;
+ }
+
+ if (childNode.childNodes?.length > 0) {
+ findNodeToFocusOn(Array.from(childNode.childNodes));
+ }
+ }
+ };
+
+ // Find the node to focus on.
+ findNodeToFocusOn(Array.from(editableDivElement.childNodes));
+
+ await this.setFocusOnNode(
+ selection,
+ editableDivElement,
+ nodeToFocusOn,
+ remainingOffset
+ );
+ }
+
+ /**
+ * Replaces text from start and end cursor position.
+ */
+ setRangeText(replacement: string, start: number, end: number) {
+ const pre = this.value?.substring(0, start) ?? '';
+ const post = this.value?.substring(end, this.value?.length ?? 0) ?? '';
+
+ this.value = pre + replacement + post;
+ this.setCursorPosition(pre.length + replacement.length);
+ }
+
+ private get contentEditableAttributeValue() {
+ return this.disabled
+ ? 'false'
+ : this.isPlaintextOnlySupported
+ ? ('plaintext-only' as unknown as 'true')
+ : 'true';
+ }
+
+ private async setFocusOnNode(
+ selection: Selection,
+ editableDivElement: Node,
+ nodeToFocusOn: Node | null,
+ remainingOffset: number
+ ) {
+ const range = document.createRange();
+ // If node is null or undefined then fallback to focus event which will put
+ // cursor at the end of content.
+ if (nodeToFocusOn === null) {
+ range.setStart(editableDivElement, editableDivElement.childNodes.length);
+ }
+ // If node to focus is BR then focus offset is number of nodes.
+ else if (nodeToFocusOn.nodeName === 'BR') {
+ const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn;
+ range.setEnd(nextNode, 0);
+ } else {
+ range.setStart(nodeToFocusOn, remainingOffset);
+ }
+
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Scroll the content to cursor position.
+ this.scrollToCursorPosition(range);
+
+ range.detach();
+
+ await this.onCursorPositionChange(null);
+ }
+
+ private async onInput(event: Event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ const value = await this.getValue();
+ this.innerValue = value;
+
+ this.dispatchEvent(
+ new CustomEvent('input', {
+ detail: {
+ value: this.value,
+ },
+ })
+ );
+ }
+
+ private async onFocus(event: Event) {
+ this.focused = true;
+ await this.onCursorPositionChange(event);
+ }
+
+ private async onBlur(event: Event) {
+ this.focused = false;
+ this.removeHintSpanIfShown();
+ await this.onCursorPositionChange(event);
+ }
+
+ private async handleKeyDown(event: KeyboardEvent) {
+ if (
+ event.key === 'Tab' &&
+ !event.shiftKey &&
+ !event.ctrlKey &&
+ !event.metaKey
+ ) {
+ await this.handleTabKeyPress(event);
+ return;
+ }
+ if (
+ this.enableSaveShortcut &&
+ event.key === 's' &&
+ (event.ctrlKey || event.metaKey)
+ ) {
+ event.preventDefault();
+ this.dispatchEvent(new CustomEvent('saveShortcut'));
+ }
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private async handleKeyUp(event: KeyboardEvent) {
+ await this.onCursorPositionChange(event);
+ }
+
+ private async handleMouseUp(event: MouseEvent) {
+ await this.onCursorPositionChange(event);
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private handleScroll() {
+ this.dispatchEvent(new CustomEvent('scroll'));
+ }
+
+ private async handleTabKeyPress(event: KeyboardEvent) {
+ const oldValue = this.value;
+ if (this.placeholderHint && !oldValue) {
+ event.preventDefault();
+ await this.appendHint(this.placeholderHint, event);
+ } else if (this.hasHintSpan()) {
+ event.preventDefault();
+ await this.appendHint(this.hint!, event);
+ }
+ }
+
+ private async appendHint(hint: string, event: Event) {
+ const oldValue = this.value ?? '';
+ const newValue = oldValue + hint;
+
+ this.value = newValue;
+ await this.putCursorAtEnd();
+ await this.onInput(event);
+
+ this.dispatchEvent(
+ new CustomEvent('hintApplied', {
+ detail: {
+ hint,
+ oldValue,
+ },
+ })
+ );
+ }
+
+ private async toggleHintVisibilityIfAny() {
+ // Wait for the next animation frame so that entered key is processed and
+ // available in dom.
+ await animationFrame();
+
+ const editableDivElement = await this.editableDiv;
+ const currentValue = (await this.getValue()) ?? '';
+ const cursorPosition = await this.getCursorPosition();
+ if (
+ !editableDivElement ||
+ (this.placeholderHint && !currentValue) ||
+ !this.hint ||
+ !this.isFocused ||
+ cursorPosition !== currentValue.length
+ ) {
+ this.removeHintSpanIfShown();
+ return;
+ }
+
+ const hintSpan = this.hintSpan();
+ if (!hintSpan) {
+ this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+ return;
+ }
+
+ const oldHint = (hintSpan as HTMLElement).dataset['hint'];
+ if (oldHint !== this.hint) {
+ this.removeHintSpanIfShown();
+ this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+ }
+ }
+
+ private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
+ const hintSpan = document.createElement('span');
+ hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
+ hintSpan.setAttribute('role', 'alert');
+ hintSpan.setAttribute(
+ 'aria-label',
+ 'Suggestion: ' + hint + ' Press TAB to accept it.'
+ );
+ hintSpan.dataset['hint'] = hint;
+ editableDivElement.appendChild(hintSpan);
+ this.dispatchEvent(
+ new CustomEvent('hintShown', {
+ detail: {
+ hint,
+ },
+ })
+ );
+ }
+
+ private removeHintSpanIfShown() {
+ const hintSpan = this.hintSpan();
+ if (hintSpan) {
+ hintSpan.remove();
+ this.dispatchEvent(
+ new CustomEvent('hintDismissed', {
+ detail: {
+ hint: (hintSpan as HTMLElement).dataset['hint'],
+ },
+ })
+ );
+ }
+ }
+
+ private hasHintSpan() {
+ return !!this.hintSpan();
+ }
+
+ private hintSpan() {
+ return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
+ }
+
+ private async onCursorPositionChange(event: Event | null) {
+ event?.preventDefault();
+ event?.stopImmediatePropagation();
+
+ this.dispatchEvent(
+ new CustomEvent('cursorPositionChange', {
+ detail: {
+ position: await this.getCursorPosition(),
+ },
+ })
+ );
+ }
+
+ private async updateValueInDom() {
+ const editableDivElement = await this.editableDiv;
+ if (editableDivElement) {
+ editableDivElement.innerText = this.value || '';
+ }
+ }
+
+ private async updateHintInDomIfRendered() {
+ // Wait for editable div to render then process the hint.
+ await this.editableDiv;
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private async getValue() {
+ const editableDivElement = await this.editableDiv;
+ if (editableDivElement) {
+ const [output] = this.parseText(editableDivElement, false, true);
+ return output;
+ }
+ return '';
+ }
+
+ private parseText(
+ node: Node,
+ isLastBr: boolean,
+ isFirst: boolean
+ ): [string, boolean] {
+ let textValue = '';
+ let output = '';
+ if (node.nodeName === 'BR') {
+ return ['\n', true];
+ }
+
+ if (node.nodeType === Node.TEXT_NODE && node.textContent) {
+ return [node.textContent, false];
+ }
+
+ if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
+ textValue = '\n';
+ }
+
+ isLastBr = false;
+
+ for (let i = 0; i < node.childNodes?.length; i++) {
+ [output, isLastBr] = this.parseText(
+ node.childNodes[i],
+ isLastBr,
+ i === 0
+ );
+ textValue += output;
+ }
+ return [textValue, isLastBr];
+ }
+
+ private async getCursorPosition() {
+ const selection = this.getSelection();
+ const editableDivElement = await this.editableDiv;
+
+ // Cursor position is -1 (not available) if
+ //
+ // If textarea is not rendered.
+ // If textarea is not focused
+ // There is no accessible selection object.
+ // This is not a collapsed selection.
+ if (
+ !editableDivElement ||
+ !this.focused ||
+ !selection ||
+ selection.focusNode === null ||
+ !selection.isCollapsed
+ ) {
+ return -1;
+ }
+
+ let cursorPosition = 0;
+ let isOnFreshLine = true;
+
+ const findCursorPosition = (childNodes: Node[]) => {
+ for (let i = 0; i < childNodes.length; i++) {
+ const childNode = childNodes[i];
+
+ if (childNode.nodeName === 'BR') {
+ cursorPosition++;
+ isOnFreshLine = true;
+ continue;
+ }
+
+ if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+ cursorPosition++;
+ }
+
+ isOnFreshLine = false;
+
+ if (childNode === selection.focusNode) {
+ cursorPosition += selection.focusOffset;
+ break;
+ } else if (childNode.nodeType === 3 && childNode.textContent) {
+ cursorPosition += childNode.textContent.length;
+ }
+
+ if (childNode.childNodes?.length > 0) {
+ findCursorPosition(Array.from(childNode.childNodes));
+ }
+ }
+ };
+
+ if (editableDivElement === selection.focusNode) {
+ // If focus node is the top textarea then focusOffset is the number of
+ // child nodes before the cursor position.
+ const partOfNodes = Array.from(editableDivElement.childNodes).slice(
+ 0,
+ selection.focusOffset
+ );
+ findCursorPosition(partOfNodes);
+ } else {
+ findCursorPosition(Array.from(editableDivElement.childNodes));
+ }
+
+ return cursorPosition;
+ }
+
+ /** Gets the current selection, preferring the shadow DOM selection. */
+ private getSelection(): Selection | undefined | null {
+ // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection()
+ return this.shadowRoot?.getSelection?.();
+ }
+
+ private scrollToCursorPosition(range: Range) {
+ const tempAnchorEl = document.createElement('br');
+ range.insertNode(tempAnchorEl);
+
+ tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+
+ tempAnchorEl.remove();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-textarea': GrTextarea;
+ }
+ interface HTMLElementEventMap {
+ // prettier-ignore
+ 'saveShortcut': CustomEvent<{}>;
+ // prettier-ignore
+ 'hintApplied': CustomEvent<HintAppliedEventDetail>;
+ // prettier-ignore
+ 'hintShown': CustomEvent<HintShownEventDetail>;
+ // prettier-ignore
+ 'hintDismissed': CustomEvent<HintDismissedEventDetail>;
+ // prettier-ignore
+ 'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>;
+ }
+}
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
new file mode 100644
index 0000000..7af3697
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-textarea';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitForEventOnce} from '../utils/event-util';
+import {
+ AUTOCOMPLETE_HINT_CLASS,
+ CursorPositionChangeEventDetail,
+ GrTextarea,
+} from './gr-textarea';
+
+async function rafPromise() {
+ return new Promise(res => {
+ requestAnimationFrame(res);
+ });
+}
+
+suite('gr-textarea test', () => {
+ let element: GrTextarea;
+
+ setup(async () => {
+ element = await fixture(html` <gr-textarea> </gr-textarea>`);
+ });
+
+ test('text area is registered correctly', () => {
+ assert.instanceOf(element, GrTextarea);
+ });
+
+ test('when disabled textarea have contenteditable set to false', async () => {
+ element.disabled = true;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.getAttribute('contenteditable'), 'false');
+ });
+
+ test('when disabled textarea have aria-disabled set', async () => {
+ element.disabled = true;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.isDefined(editableDiv?.getAttribute('aria-disabled'));
+ });
+
+ test('when textarea has placeholder, set aria-placeholder to placeholder text', async () => {
+ const placeholder = 'A sample placehodler...';
+ element.placeholder = placeholder;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.getAttribute('aria-placeholder'), placeholder);
+ });
+
+ test('renders the value', async () => {
+ const value = 'Some value';
+ element.value = value;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.innerText, value);
+ });
+
+ test('streams change event when editable div has input event', async () => {
+ const value = 'Some value \n other value';
+ const INPUT_EVENT = 'input';
+ let changeCalled = false;
+
+ element.addEventListener(INPUT_EVENT, () => {
+ changeCalled = true;
+ });
+
+ const changeEventPromise = waitForEventOnce(element, INPUT_EVENT);
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+
+ editableDiv.innerText = value;
+ editableDiv.dispatchEvent(new Event('input'));
+ await changeEventPromise;
+
+ assert.isTrue(changeCalled);
+ });
+
+ test('does not have focus by default', async () => {
+ assert.isFalse(element.isFocused);
+ });
+
+ test('when focused, isFocused is set to true', async () => {
+ await element.focus();
+ assert.isTrue(element.isFocused);
+ });
+
+ test('when cursor position is set to 0', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ await element.setCursorPosition(0);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 0);
+ });
+
+ test('when cursor position is set to 1', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ element.value = 'Some value';
+ await element.updateComplete;
+ await element.setCursorPosition(1);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 1);
+ });
+
+ test('when cursor position is set to new line', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ element.value = 'Some \n\n\n value';
+ await element.updateComplete;
+ await element.setCursorPosition(7);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 7);
+ });
+
+ test('when textarea is empty, placeholder hint is shown', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const placeholderHint = 'Some value';
+
+ element.placeholderHint = placeholderHint;
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.dataset['placeholder'], placeholderHint);
+ });
+
+ test('when TAB is pressed, placeholder hint is added as content', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const placeholderHint = 'Some value';
+
+ element.placeholderHint = placeholderHint;
+ await element.updateComplete;
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+ await element.updateComplete;
+
+ assert.equal(element.value, placeholderHint);
+ });
+
+ test('when cursor is at end, hint is shown', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const oldValue = 'Hola';
+ const hint = 'amigos';
+
+ element.hint = hint;
+ await element.updateComplete;
+ element.value = oldValue;
+ await element.putCursorAtEnd();
+ await element.updateComplete;
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+ await element.updateComplete;
+ await rafPromise();
+
+ const spanHintElement = editableDiv?.querySelector(
+ '.' + AUTOCOMPLETE_HINT_CLASS
+ ) as HTMLSpanElement;
+ const styles = window.getComputedStyle(spanHintElement, ':before');
+ assert.equal(styles['content'], '"' + hint + '"');
+ });
+
+ test('when TAB is pressed, hint is added as content', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const oldValue = 'Hola';
+ const hint = 'amigos';
+
+ element.hint = hint;
+ element.value = oldValue;
+ await element.updateComplete;
+ await element.putCursorAtEnd();
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+ await rafPromise();
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+ await element.updateComplete;
+
+ assert.equal(element.value, oldValue + hint);
+ });
+});
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 49b8edf..603e6c8 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-acceptance-framework</artifactId>
- <version>3.10.0-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Acceptance Test Framework</name>
<description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ac3dbac..241d59b 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-extension-api</artifactId>
- <version>3.10.0-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Extension API</name>
<description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index b3408d1..00132bd 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-plugin-api</artifactId>
- <version>3.10.0-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Plugin API</name>
<description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 2f72352..5a21ab7 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-war</artifactId>
- <version>3.10.0-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>Gerrit Code Review - WAR</name>
<description>Gerrit WAR</description>
diff --git a/version.bzl b/version.bzl
index 23ccefb..181159e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
# Used by :api_install and :api_deploy targets
# when talking to the destination repository.
#
-GERRIT_VERSION = "3.10.0-SNAPSHOT"
+GERRIT_VERSION = "3.11.0-SNAPSHOT"