Merge "Make file list test slightly smaller" into stable-3.7
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 234389e..be6e140 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2543,6 +2543,26 @@
by the target version of the upgrade. Refer to the release notes and check whether
the rolling upgrade is possible or not and the associated constraints.
+[[gerrit.importedServerId]]gerrit.importedServerId::
++
+ServerId of the repositories imported from other Gerrit servers. Changes coming
+associated with the imported serverIds are indexed and displayed in the UI.
++
+Specify multiple `gerrit.importedServerId` for allowing the import from multiple
+Gerrit servers with different serverIds.
++
+[NOTE]
+The account-ids referenced in the imported changes are used for looking up the
+associated account-id locally, using the `imported:` external-id.
+Example: the account-id 1000 from the imported server-id 59a4964e-6376-4ed9-beef
+will be looked up in the local accounts using the `imported:1000@59a4964e-6376-4ed9-beef`
+external-id.
++
+If this value is not set, all changes imported from other Gerrit servers will be
+ignored.
++
+By default empty.
+
[[gerrit.serverId]]gerrit.serverId::
+
Used by NoteDb to, amongst other things, identify author identities from
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 303e79f..85dbdeb 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -46,6 +46,10 @@
*/
@AutoValue
public abstract class Account {
+
+ /** Placeholder for indicating an account-id that does not correspond to any local account */
+ public static final Id UNKNOWN_ACCOUNT_ID = id(0);
+
public static Id id(int id) {
return new AutoValue_Account_Id(id);
}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 0a51171..1616198 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -135,6 +135,9 @@
/** Scheme used for GPG public keys. */
public static final String SCHEME_GPGKEY = "gpgkey";
+ /** Scheme for imported accounts from other servers with different GerritServerId */
+ public static final String SCHEME_IMPORTED = "imported";
+
/** Scheme for external auth used during authentication, e.g. OAuth Token */
public static final String SCHEME_EXTERNAL = "external";
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 9f766d0..fe8feac 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -30,7 +30,7 @@
*
* <p>All returned collections are unmodifiable.
*/
-interface ExternalIdCache {
+public interface ExternalIdCache {
Optional<ExternalId> byKey(ExternalId.Key key) throws IOException;
ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIds.java b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
new file mode 100644
index 0000000..c47d3be
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * List of ServerIds of the Gerrit data imported from other servers.
+ *
+ * <p>This values correspond to the {@code GerritServerId} of other servers.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritImportedServerIds {}
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
new file mode 100644
index 0000000..f3f7645
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+
+public class GerritImportedServerIdsProvider implements Provider<ImmutableSet<String>> {
+ public static final String SECTION = "gerrit";
+ public static final String KEY = "importedServerId";
+
+ private final ImmutableSet<String> importedIds;
+
+ @Inject
+ public GerritImportedServerIdsProvider(@GerritServerConfig Config cfg) {
+ importedIds = ImmutableSet.copyOf(cfg.getStringList(SECTION, null, KEY));
+ }
+
+ @Override
+ public ImmutableSet<String> get() {
+ return importedIds;
+ }
+}
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 158972f..3757244 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -17,6 +17,7 @@
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Change;
@@ -24,6 +25,7 @@
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritImportedServerIds;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
@@ -51,6 +53,7 @@
public final AllUsersName allUsers;
public final NoteDbMetrics metrics;
public final String serverId;
+ public final ImmutableSet<String> importedServerIds;
// Providers required to avoid dependency cycles.
@@ -64,7 +67,8 @@
ChangeNoteJson changeNoteJson,
NoteDbMetrics metrics,
Provider<ChangeNotesCache> cache,
- @GerritServerId String serverId) {
+ @GerritServerId String serverId,
+ @GerritImportedServerIds ImmutableSet<String> importedServerIds) {
this.failOnLoadForTest = new AtomicBoolean();
this.repoManager = repoManager;
this.allUsers = allUsers;
@@ -72,6 +76,7 @@
this.metrics = metrics;
this.cache = cache;
this.serverId = serverId;
+ this.importedServerIds = importedServerIds;
}
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 26d5933..31934d5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -406,6 +406,10 @@
return state.metaId();
}
+ public String getServerId() {
+ return state.serverId();
+ }
+
public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
if (patchSets == null) {
ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
@@ -655,7 +659,9 @@
* be to bump the cache version, but that would invalidate all persistent cache entries, what we
* rather try to avoid.
*/
- if (!Strings.isNullOrEmpty(stateServerId) && !args.serverId.equals(stateServerId)) {
+ if (!Strings.isNullOrEmpty(stateServerId)
+ && !args.serverId.equals(stateServerId)
+ && !args.importedServerIds.contains(stateServerId)) {
throw new InvalidServerIdException(args.serverId, stateServerId);
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 19044e6..0f2c877 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -26,6 +26,7 @@
import com.google.gerrit.proto.Protos;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -366,7 +367,13 @@
"Load change notes for change %s of project %s", key.changeId(), key.project());
ChangeNotesParser parser =
new ChangeNotesParser(
- key.changeId(), key.id(), walkSupplier.get(), args.changeNoteJson, args.metrics);
+ key.changeId(),
+ key.id(),
+ walkSupplier.get(),
+ args.changeNoteJson,
+ args.metrics,
+ args.serverId,
+ externalIdCache);
ChangeNotesState result = parser.parseAll();
// This assignment only happens if call() was actually called, which only
// happens when Cache#get(K, Callable<V>) incurs a cache miss.
@@ -377,11 +384,16 @@
private final Cache<Key, ChangeNotesState> cache;
private final Args args;
+ private final ExternalIdCache externalIdCache;
@Inject
- ChangeNotesCache(@Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache, Args args) {
+ ChangeNotesCache(
+ @Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache,
+ Args args,
+ ExternalIdCache externalIdCache) {
this.cache = cache;
this.args = args;
+ this.externalIdCache = externalIdCache;
}
Value get(
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 6e5e19c..f2a659d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -79,6 +79,7 @@
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
import com.google.gerrit.server.notedb.ChangeNoteUtil.ParsedPatchSetApproval;
import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
import com.google.gerrit.server.util.LabelVote;
@@ -199,18 +200,24 @@
// the latest record unsets the field).
private Optional<PatchSet.Id> cherryPickOf;
private Instant mergedOn;
+ private final ExternalIdCache externalIdCache;
+ private final String gerritServerId;
ChangeNotesParser(
Change.Id changeId,
ObjectId tip,
ChangeNotesRevWalk walk,
ChangeNoteJson changeNoteJson,
- NoteDbMetrics metrics) {
+ NoteDbMetrics metrics,
+ String gerritServerId,
+ ExternalIdCache externalIdCache) {
this.id = changeId;
this.tip = tip;
this.walk = walk;
this.changeNoteJson = changeNoteJson;
this.metrics = metrics;
+ this.externalIdCache = externalIdCache;
+ this.gerritServerId = gerritServerId;
approvals = new LinkedHashMap<>();
bufferedApprovals = new ArrayList<>();
reviewers = HashBasedTable.create();
@@ -1406,7 +1413,7 @@
}
private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
- return NoteDbUtil.parseIdent(ident)
+ return NoteDbUtil.parseIdent(ident, gerritServerId, externalIdCache)
.orElseThrow(
() -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 396e29b..64bf430 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -20,8 +20,12 @@
import com.google.common.primitives.Ints;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import java.io.IOException;
import java.sql.Timestamp;
import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.util.GitDateFormatter;
import org.eclipse.jgit.util.GitDateFormatter.Format;
@@ -48,6 +52,45 @@
return Optional.empty();
}
+ /**
+ * Returns an AccountId for the given email address and the current serverId. Reverse lookup the
+ * AccountId using the ExternalIdCache if the account has a foreign serverId.
+ *
+ * @param ident the accountId@serverId identity
+ * @param serverId the Gerrit's serverId
+ * @param externalIdCache reference to the cache for looking up the external ids
+ * @return a defined accountId if the account was found, {@link Account#UNKNOWN_ACCOUNT_ID} if the
+ * lookup via external-id did not return any account, or an empty value if the identity was
+ * malformed.
+ * @throws ConfigInvalidException when the lookup of the external-id failed
+ */
+ public static Optional<Account.Id> parseIdent(
+ PersonIdent ident, String serverId, ExternalIdCache externalIdCache)
+ throws ConfigInvalidException {
+ String email = ident.getEmailAddress();
+ int at = email.indexOf('@');
+ if (at >= 0) {
+ Integer id = Ints.tryParse(email.substring(0, at));
+ String accountServerId = email.substring(at + 1);
+ if (id != null) {
+ if (accountServerId.equals(serverId)) {
+ return Optional.of(Account.id(id));
+ }
+
+ ExternalId.Key extIdKey = ExternalId.Key.create(ExternalId.SCHEME_IMPORTED, email, false);
+ try {
+ return externalIdCache
+ .byKey(extIdKey)
+ .map(ExternalId::accountId)
+ .or(() -> Optional.of(Account.UNKNOWN_ACCOUNT_ID));
+ } catch (IOException e) {
+ throw new ConfigInvalidException("Unable to lookup external id from cache", e);
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
public static String extractHostPartFromPersonIdent(PersonIdent ident) {
String email = ident.getEmailAddress();
int at = email.indexOf('@');
diff --git a/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
index ff2073d..e0e64a3 100644
--- a/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -16,6 +16,7 @@
import static com.google.inject.Scopes.SINGLETON;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.extensions.config.FactoryModule;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -25,9 +26,12 @@
import com.google.gerrit.server.config.AllUsersNameProvider;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.config.GerritServerIdProvider;
import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.inject.TypeLiteral;
import org.eclipse.jgit.lib.PersonIdent;
/** Bindings for low-level Gerrit schema data. */
@@ -51,6 +55,11 @@
.toProvider(GerritServerIdProvider.class)
.in(SINGLETON);
+ bind(new TypeLiteral<ImmutableSet<String>>() {})
+ .annotatedWith(GerritImportedServerIds.class)
+ .toProvider(GerritImportedServerIdsProvider.class)
+ .in(SINGLETON);
+
// It feels wrong to have this binding in a seemingly unrelated module, but it's a dependency of
// SchemaCreatorImpl, so it's needed.
// TODO(dborowitz): Is there any way to untangle this?
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b00cadb..b1ab6b1 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -19,6 +19,7 @@
import static com.google.inject.Scopes.SINGLETON;
import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
@@ -61,6 +62,8 @@
import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
import com.google.gerrit.server.config.GerritInstanceIdModule;
import com.google.gerrit.server.config.GerritInstanceNameModule;
import com.google.gerrit.server.config.GerritOptions;
@@ -311,6 +314,17 @@
@Provides
@Singleton
+ @GerritImportedServerIds
+ public ImmutableSet<String> createImportedServerIds() {
+ ImmutableSet<String> serverIds =
+ ImmutableSet.copyOf(
+ cfg.getStringList(
+ GerritServerIdProvider.SECTION, null, GerritImportedServerIdsProvider.KEY));
+ return serverIds;
+ }
+
+ @Provides
+ @Singleton
@SendEmailExecutor
public ExecutorService createSendEmailExecutor() {
return newDirectExecutorService();
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index d7c779e..1b286d1 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -18,6 +18,7 @@
import static java.util.concurrent.TimeUnit.SECONDS;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
@@ -39,6 +40,8 @@
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.Realm;
import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
import com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator;
import com.google.gerrit.server.config.AllUsersName;
@@ -48,11 +51,13 @@
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.gerrit.server.config.GerritImportedServerIds;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitModule;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.project.NullProjectCache;
import com.google.gerrit.server.project.ProjectCache;
@@ -67,9 +72,12 @@
import com.google.inject.Guice;
import com.google.inject.Inject;
import com.google.inject.Injector;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
import java.time.Instant;
import java.time.ZoneId;
import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
@@ -84,10 +92,13 @@
@Ignore
@RunWith(ConfigSuite.class)
public abstract class AbstractChangeNotesTest {
+ protected static final String LOCAL_SERVER_ID = "gerrit";
+
private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
@ConfigSuite.Parameter public Config testConfig;
+ protected Account.Id changeOwnerId;
protected Account.Id otherUserId;
protected FakeAccountCache accountCache;
protected IdentifiedUser changeOwner;
@@ -111,11 +122,21 @@
@Inject @GerritServerId protected String serverId;
+ @Inject protected ExternalIdCache externalIdCache;
+
protected Injector injector;
private String systemTimeZone;
@Before
public void setUpTestEnvironment() throws Exception {
+ setupTestPrerequisites();
+
+ injector = createTestInjector(LOCAL_SERVER_ID);
+ createAllUsers(injector);
+ injector.injectMembers(this);
+ }
+
+ protected void setupTestPrerequisites() throws Exception {
setTimeForTesting();
serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.now(), ZONE_ID);
@@ -134,60 +155,77 @@
ou.setPreferredEmail("other@account.com");
accountCache.put(ou.build());
assertableFanOutExecutor = new AssertableExecutorService();
-
- injector =
- Guice.createInjector(
- new FactoryModule() {
- @Override
- public void configure() {
- install(new GitModule());
-
- install(new DefaultUrlFormatterModule());
- install(NoteDbModule.forTest());
- bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
- bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
- bind(GitRepositoryManager.class).toInstance(repoManager);
- bind(ProjectCache.class).to(NullProjectCache.class);
- bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
- bind(String.class)
- .annotatedWith(AnonymousCowardName.class)
- .toProvider(AnonymousCowardNameProvider.class);
- bind(String.class)
- .annotatedWith(CanonicalWebUrl.class)
- .toInstance("http://localhost:8080/");
- bind(Boolean.class)
- .annotatedWith(EnablePeerIPInReflogRecord.class)
- .toInstance(Boolean.FALSE);
- bind(Realm.class).to(FakeRealm.class);
- bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
- bind(AccountCache.class).toInstance(accountCache);
- bind(PersonIdent.class)
- .annotatedWith(GerritPersonIdent.class)
- .toInstance(serverIdent);
- bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
- bind(MetricMaker.class).to(DisabledMetricMaker.class);
- bind(ExecutorService.class)
- .annotatedWith(FanOutExecutor.class)
- .toInstance(assertableFanOutExecutor);
- bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
- bind(InternalChangeQuery.class)
- .toProvider(
- () -> {
- throw new UnsupportedOperationException();
- });
- bind(PatchSetApprovalUuidGenerator.class)
- .to(TestPatchSetApprovalUuidGenerator.class);
- }
- });
-
- injector.injectMembers(this);
- repoManager.createRepository(allUsers);
- changeOwner = userFactory.create(co.id());
- otherUser = userFactory.create(ou.id());
- otherUserId = otherUser.getAccountId();
+ changeOwnerId = co.id();
+ otherUserId = ou.id();
internalUser = new InternalUser();
}
+ protected Injector createTestInjector(String serverId, String... importedServerIds)
+ throws Exception {
+ return createTestInjector(DisabledExternalIdCache.module(), serverId, importedServerIds);
+ }
+
+ protected Injector createTestInjector(
+ Module extraGuiceModule, String serverId, String... importedServerIds) throws Exception {
+
+ return Guice.createInjector(
+ new FactoryModule() {
+ @Override
+ public void configure() {
+ install(extraGuiceModule);
+ install(new GitModule());
+
+ install(new DefaultUrlFormatterModule());
+ install(NoteDbModule.forTest());
+ bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+ bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
+ bind(new TypeLiteral<ImmutableSet<String>>() {})
+ .annotatedWith(GerritImportedServerIds.class)
+ .toInstance(new ImmutableSet.Builder<String>().add(importedServerIds).build());
+ bind(GitRepositoryManager.class).toInstance(repoManager);
+ bind(ProjectCache.class).to(NullProjectCache.class);
+ bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
+ bind(String.class)
+ .annotatedWith(AnonymousCowardName.class)
+ .toProvider(AnonymousCowardNameProvider.class);
+ bind(String.class)
+ .annotatedWith(CanonicalWebUrl.class)
+ .toInstance("http://localhost:8080/");
+ bind(Boolean.class)
+ .annotatedWith(EnablePeerIPInReflogRecord.class)
+ .toInstance(Boolean.FALSE);
+ bind(Realm.class).to(FakeRealm.class);
+ bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
+ bind(AccountCache.class).toInstance(accountCache);
+ bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class).toInstance(serverIdent);
+ bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+ bind(MetricMaker.class).to(DisabledMetricMaker.class);
+ bind(ExecutorService.class)
+ .annotatedWith(FanOutExecutor.class)
+ .toInstance(assertableFanOutExecutor);
+ bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
+ bind(InternalChangeQuery.class)
+ .toProvider(
+ () -> {
+ throw new UnsupportedOperationException();
+ });
+ bind(PatchSetApprovalUuidGenerator.class).to(TestPatchSetApprovalUuidGenerator.class);
+ }
+ });
+ }
+
+ protected void createAllUsers(Injector injector)
+ throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+ AllUsersName allUsersName = injector.getInstance(AllUsersName.class);
+
+ repoManager.createRepository(allUsersName);
+
+ IdentifiedUser.GenericFactory identifiedUserFactory =
+ injector.getInstance(IdentifiedUser.GenericFactory.class);
+ changeOwner = identifiedUserFactory.create(changeOwnerId);
+ otherUser = identifiedUserFactory.create(otherUserId);
+ }
+
private void setTimeForTesting() {
systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -200,8 +238,12 @@
}
protected Change newChange(boolean workInProgress) throws Exception {
+ return newChange(injector, workInProgress);
+ }
+
+ protected Change newChange(Injector injector, boolean workInProgress) throws Exception {
Change c = TestChanges.newChange(project, changeOwner.getAccountId());
- ChangeUpdate u = newUpdateForNewChange(c, changeOwner);
+ ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
u.setChangeId(c.getKey().get());
u.setBranch(c.getDest().branch());
u.setWorkInProgress(workInProgress);
@@ -218,15 +260,20 @@
}
protected ChangeUpdate newUpdateForNewChange(Change c, CurrentUser user) throws Exception {
- return newUpdate(c, user, false);
+ return newUpdate(injector, c, user, false);
}
protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
- return newUpdate(c, user, true);
+ return newUpdate(injector, c, user, true);
}
protected ChangeUpdate newUpdate(Change c, CurrentUser user, boolean shouldExist)
throws Exception {
+ return newUpdate(injector, c, user, shouldExist);
+ }
+
+ protected ChangeUpdate newUpdate(
+ Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
update.setPatchSetId(c.currentPatchSetId());
update.setAllowWriteToNewRef(true);
@@ -237,6 +284,10 @@
return new ChangeNotes(args, c, true, null).load();
}
+ protected ChangeNotes newNotes(AbstractChangeNotes.Args cArgs, Change c) {
+ return new ChangeNotes(cArgs, c, true, null).load();
+ }
+
protected static SubmitRecord submitRecord(
String status, String errorMessage, SubmitRecord.Label... labels) {
SubmitRecord rec = new SubmitRecord();
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
index 666b8fc..9445f4a 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -98,7 +98,8 @@
private ChangeNotesParser newParser(ObjectId tip) throws Exception {
walk.reset();
ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
- return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+ return new ChangeNotesParser(
+ newChange().getId(), tip, walk, changeNoteJson, args.metrics, serverId, externalIdCache);
}
private RevCommit writeCommit(String body) throws Exception {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 276f093..4543b50 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -201,11 +201,11 @@
+ "Branch: refs/heads/master\n"
+ "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ "Patch-set: 1\n"
- + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@Gerrit>\n"
+ + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@gerrit>\n"
+ "Copied-Label: Label2=+1 Account <1@gerrit>\n"
- + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@Gerrit> :\"tag\"\n"
- + "Copied-Label: Label4=+1 Account <1@Gerrit> :\"tag with characters %^#@^( *::!\"\n"
- + "Copied-Label: -Label1 Account <1@gerrit>,Other Account <2@Gerrit>\\n"
+ + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@gerrit> :\"tag\"\n"
+ + "Copied-Label: Label4=+1 Account <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+ + "Copied-Label: -Label1 Account <1@gerrit>,Other Account <2@gerrit>\\n"
+ "Copied-Label: -Label1 Account <1@gerrit>\n"
+ "Subject: This is a test change\n");
@@ -782,6 +782,7 @@
private ChangeNotesParser newParser(ObjectId tip) throws Exception {
walk.reset();
ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
- return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+ return new ChangeNotesParser(
+ newChange().getId(), tip, walk, changeNoteJson, args.metrics, serverId, externalIdCache);
}
}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 8def660..4edfa8b4 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -2084,7 +2084,13 @@
try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
ChangeNotesParser notesWithComments =
new ChangeNotesParser(
- c.getId(), commitWithComments.copy(), rw, changeNoteJson, args.metrics);
+ c.getId(),
+ commitWithComments.copy(),
+ rw,
+ changeNoteJson,
+ args.metrics,
+ serverId,
+ externalIdCache);
ChangeNotesState state = notesWithComments.parseAll();
assertThat(state.approvals()).isEmpty();
assertThat(state.publishedComments()).hasSize(1);
@@ -2093,7 +2099,13 @@
try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
ChangeNotesParser notesWithApprovals =
new ChangeNotesParser(
- c.getId(), commitWithApprovals.copy(), rw, changeNoteJson, args.metrics);
+ c.getId(),
+ commitWithApprovals.copy(),
+ rw,
+ changeNoteJson,
+ args.metrics,
+ serverId,
+ externalIdCache);
ChangeNotesState state = notesWithApprovals.parseAll();
assertThat(state.approvals()).hasSize(1);
diff --git a/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
new file mode 100644
index 0000000..bb49a6d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.inject.AbstractModule;
+import java.util.Optional;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ImportedChangeNotesTest extends AbstractChangeNotesTest {
+
+ private static final String FOREIGN_SERVER_ID = "foreign-server-id";
+ private static final String IMPORTED_SERVER_ID = "gerrit-imported-1";
+
+ private ExternalIdCache externalIdCacheMock;
+
+ @Before
+ @Override
+ public void setUpTestEnvironment() throws Exception {
+ setupTestPrerequisites();
+ }
+
+ private void initServerIds(String serverId, String... importedServerIds)
+ throws Exception, RepositoryCaseMismatchException, RepositoryNotFoundException {
+ externalIdCacheMock = mock(ExternalIdCache.class);
+ injector =
+ createTestInjector(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(ExternalIdCache.class).toInstance(externalIdCacheMock);
+ }
+ },
+ serverId,
+ importedServerIds);
+ injector.injectMembers(this);
+ createAllUsers(injector);
+ }
+
+ @Test
+ public void allowChangeFromImportedServerId() throws Exception {
+ initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+ ExternalId.Key importedAccountIdKey =
+ ExternalId.Key.create(
+ ExternalId.SCHEME_IMPORTED,
+ changeOwner.getAccountId() + "@" + IMPORTED_SERVER_ID,
+ false);
+ ExternalId importedAccountId =
+ ExternalId.create(importedAccountIdKey, changeOwner.getAccountId(), null, null, null);
+
+ when(externalIdCacheMock.byKey(eq(importedAccountIdKey)))
+ .thenReturn(Optional.of(importedAccountId));
+
+ Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+ Change localChange = newChange();
+
+ assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+ assertThat(newNotes(localChange).getServerId()).isEqualTo(LOCAL_SERVER_ID);
+ }
+
+ @Test
+ public void rejectChangeWithForeignServerId() throws Exception {
+ initServerIds(LOCAL_SERVER_ID);
+ when(externalIdCacheMock.byKey(any())).thenReturn(Optional.empty());
+
+ Change foreignChange = newChange(createTestInjector(FOREIGN_SERVER_ID), false);
+
+ InvalidServerIdException invalidServerIdEx =
+ assertThrows(InvalidServerIdException.class, () -> newNotes(foreignChange));
+
+ String invalidServerIdMessage = invalidServerIdEx.getMessage();
+ assertThat(invalidServerIdMessage).contains("expected " + LOCAL_SERVER_ID);
+ assertThat(invalidServerIdMessage).contains("actual: " + FOREIGN_SERVER_ID);
+ }
+
+ @Test
+ public void changeFromImportedServerIdWithUnknownAccountId() throws Exception {
+ initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+ when(externalIdCacheMock.byKey(any())).thenReturn(Optional.empty());
+
+ Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+ assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+
+ assertThat(newNotes(importedChange).getChange().getOwner())
+ .isEqualTo(Account.UNKNOWN_ACCOUNT_ID);
+ }
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 99f7606..bb7b313 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -270,6 +270,7 @@
change_table: [],
email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
default_base_for_merges: DefaultBase.AUTO_MERGE,
+ allow_browser_notifications: false,
};
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index 0afb273..9c53fea 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
import {fireEvent} from '../../../utils/event-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 77a0eb2..87e9576 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -8,7 +8,6 @@
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-dropdown/gr-dropdown';
import '../../shared/gr-icon/gr-icon';
-import '../../shared/gr-icons/gr-icons';
import '../../shared/gr-overlay/gr-overlay';
import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 8293daa..1000925 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -15,7 +15,7 @@
import '../../shared/gr-change-star/gr-change-star';
import '../../shared/gr-change-status/gr-change-status';
import '../../shared/gr-editable-content/gr-editable-content';
-import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-formatted-text/gr-formatted-text';
import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../gr-change-actions/gr-change-actions';
@@ -104,12 +104,7 @@
import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
-import {
- assertIsDefined,
- assert,
- query as queryEl,
- queryAll,
-} from '../../../utils/common-util';
+import {assertIsDefined, assert, queryAll} from '../../../utils/common-util';
import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
import {
CommentThread,
@@ -121,14 +116,12 @@
import {GrFileList} from '../gr-file-list/gr-file-list';
import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
import {
- ChecksTabState,
CloseFixPreviewEvent,
EditableContentSaveEvent,
EventType,
OpenFixPreviewEvent,
ShowAlertEventDetail,
SwitchTabEvent,
- SwitchTabEventDetail,
TabState,
ValueChangedEvent,
} from '../../../types/events';
@@ -486,9 +479,12 @@
@state()
private changeViewAriaHidden = false;
+ /**
+ * This can be a string only for plugin provided tabs.
+ */
// visible for testing
@state()
- activeTab = Tab.FILES;
+ activeTab: Tab | string = Tab.FILES;
@property({type: Boolean})
unresolvedOnly = true;
@@ -702,6 +698,11 @@
);
subscribe(
this,
+ () => this.getViewModel().tab$,
+ t => (this.activeTab = t ?? Tab.FILES)
+ );
+ subscribe(
+ this,
() => this.getChecksModel().aPluginHasRegistered$,
b => {
this.showChecksTab = b;
@@ -959,7 +960,7 @@
/* Account for border and padding and rounding errors. */
max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
}
- .commitMessage gr-linked-text {
+ .commitMessage gr-formatted-text {
word-break: break-word;
}
#commitMessageEditor {
@@ -1460,12 +1461,10 @@
.commitCollapsible=${this.computeCommitCollapsible()}
remove-zero-width-space=""
>
- <gr-linked-text
- pre=""
- .content=${this.latestCommitMessage}
- .config=${this.projectConfig?.commentlinks}
- remove-zero-width-space=""
- ></gr-linked-text>
+ <gr-formatted-text
+ .content=${this.latestCommitMessage ?? ''}
+ .markdown=${false}
+ ></gr-formatted-text>
</gr-editable-content>
</div>
<h3 class="assistive-tech-only">Comments and Checks Summary</h3>
@@ -1496,7 +1495,10 @@
private renderTabHeaders() {
return html`
- <paper-tabs id="tabs" @selected-changed=${this.setActiveTab}>
+ <paper-tabs
+ id="tabs"
+ @selected-changed=${this.onPaperTabSelectionChanged}
+ >
<paper-tab @click=${this.onPaperTabClick} data-name=${Tab.FILES}
><span>Files</span></paper-tab
>
@@ -1729,38 +1731,38 @@
}
}
- setActiveTab(e: SwitchTabEvent) {
- const paperTabs = queryEl<PaperTabsElement>(this, '#tabs');
- if (!paperTabs) return;
- const tabs = [...queryAll<HTMLElement>(paperTabs, 'paper-tab')];
+ onPaperTabSelectionChanged(e: ValueChangedEvent) {
+ if (!this.tabs) return;
+ const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
if (!tabs) return;
- let tabName = e.detail.tab;
- let tabIndex = e.detail.value;
+ const tabIndex = Number(e.detail.value);
+ assert(
+ Number.isInteger(tabIndex) && 0 <= tabIndex && tabIndex < tabs.length,
+ `${tabIndex} must be integer`
+ );
+ const tab = tabs[tabIndex].dataset['name'];
- if (tabIndex === undefined) {
- assert(tabName !== undefined, 'tabName or tabIndex must be defined');
- tabIndex = tabs.findIndex(t => t.dataset['name'] === tabName);
- assert(tabIndex !== -1, `tab ${tabName} not found`);
+ this.getViewModel().updateState({tab});
+ }
+
+ setActiveTab(e: SwitchTabEvent) {
+ if (!this.tabs) return;
+ const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
+ if (!tabs) return;
+
+ const tab = e.detail.tab;
+ const tabIndex = tabs.findIndex(t => t.dataset['name'] === tab);
+ assert(tabIndex !== -1, `tab ${tab} not found`);
+
+ if (this.tabs.selected !== tabIndex) {
+ this.tabs.selected = tabIndex;
}
- if (tabName === undefined) {
- tabName = tabs[tabIndex].dataset['name'];
- }
-
- if (paperTabs.selected !== tabIndex) {
- // paperTabs.selected is undefined during rendering
- if (paperTabs.selected !== undefined) {
- const src = (e.composedPath()?.[0] as Element | undefined)?.tagName;
- this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src});
- }
- paperTabs.selected = tabIndex;
- }
-
- this.activeTab = tabName as Tab;
+ this.getViewModel().updateState({tab});
if (e.detail.tabState) this.tabState = e.detail.tabState;
- if (e.detail.scrollIntoView) paperTabs.scrollIntoView({block: 'center'});
+ if (e.detail.scrollIntoView) this.tabs.scrollIntoView({block: 'center'});
}
/**
@@ -2246,19 +2248,7 @@
} else if (this.viewState?.commentId) {
tab = Tab.COMMENT_THREADS;
}
- const detail: SwitchTabEventDetail = {
- tab,
- };
- if (tab === Tab.CHECKS) {
- const state: ChecksTabState = {};
- detail.tabState = {checksTab: state};
- }
-
- this.setActiveTab(
- new CustomEvent(EventType.SHOW_TAB, {
- detail,
- })
- );
+ this.setActiveTab(new CustomEvent(EventType.SHOW_TAB, {detail: {tab}}));
}
// Private but used in tests.
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index ad84fb0..f3017f8 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -433,9 +433,7 @@
id="commitMessageEditor"
remove-zero-width-space=""
>
- <gr-linked-text pre="" remove-zero-width-space="">
- <span id="output" slot="insert"></span>
- </gr-linked-text>
+ <gr-formatted-text></gr-formatted-text>
</gr-editable-content>
</div>
<h3 class="assistive-tech-only">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index b540c89..96a2aba 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -854,7 +854,6 @@
.reviewerState=${ReviewerState.CC}
@account-added=${this.handleAccountAdded}
@accounts-changed=${this.handleCcsChanged}
- .removableValues=${this.change?.removable_reviewers}
.filter=${this.filterCCSuggestion}
.pendingConfirmation=${this.ccPendingConfirmation}
@pending-confirmation-changed=${this.handleCcsConfirmationChanged}
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 7fe070c..66ad38c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -30,7 +30,6 @@
import {ReviewInput} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import {assertIsDefined} from '../../../utils/common-util';
-import {CURRENT} from '../../../utils/patch-set-util';
import {fireReload} from '../../../utils/event-util';
import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
import {
@@ -321,7 +320,11 @@
},
};
return this.restApiService
- .saveChangeReview(this.change._number, CURRENT, review)
+ .saveChangeReview(
+ this.change._number,
+ this.change.current_revision,
+ review
+ )
.then(() => {
fireReload(this, true);
});
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index 2117ec5..4e78e8a 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -18,7 +18,7 @@
createSubmitRequirementResultInfo,
} from '../../../test/test-data-generators';
import {ParsedChangeInfo} from '../../../types/types';
-import {query, queryAndAssert} from '../../../test/test-utils';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
import {GrButton} from '../../shared/gr-button/gr-button';
import {ChangeStatus, SubmitRequirementResultInfo} from '../../../api/rest-api';
@@ -321,6 +321,22 @@
assert.isUndefined(query(element, '.quickApprove'));
});
+ test('uses patchset from change', async () => {
+ const saveChangeReview = stubRestApi('saveChangeReview').resolves();
+ const element = await fixture<GrSubmitRequirementHovercard>(
+ html`<gr-submit-requirement-hovercard
+ .requirement=${submitRequirement}
+ .change=${change}
+ .account=${account}
+ ></gr-submit-requirement-hovercard>`
+ );
+
+ queryAndAssert<GrButton>(element, '.quickApprove > gr-button').click();
+
+ assert.equal(saveChangeReview.callCount, 1);
+ assert.equal(saveChangeReview.firstCall.args[1], change.current_revision);
+ });
+
test('override button renders', async () => {
const submitRequirement: SubmitRequirementResultInfo = {
...createSubmitRequirementResultInfo(),
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index a4e26fe..848948f 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -77,6 +77,7 @@
import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
import './gr-checks-attempt';
import {createDiffUrl} from '../../models/views/diff';
+import {changeViewModelToken} from '../../models/views/change';
/**
* Firing this event sets the regular expression of the results filter.
@@ -756,7 +757,7 @@
filterInput?: HTMLInputElement;
@state()
- filterRegExp = new RegExp('');
+ filterRegExp = '';
/** All runs. Shown should only the selected/filtered ones. */
@property({attribute: false})
@@ -766,8 +767,8 @@
* Check names of runs that are selected in the runs panel. When this array
* is empty, then no run is selected and all runs should be shown.
*/
- @property({attribute: false})
- selectedRuns: string[] = [];
+ @state()
+ selectedRuns: Set<string> = new Set();
@state()
actions: Action[] = [];
@@ -808,6 +809,8 @@
*/
private isSectionExpandedByUser = new Map<Category, boolean>();
+ private readonly getViewModel = resolve(this, changeViewModelToken);
+
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getChecksModel = resolve(this, checksModelToken);
@@ -853,6 +856,16 @@
() => this.getChecksModel().someProvidersAreLoadingSelected$,
x => (this.someProvidersAreLoading = x)
);
+ subscribe(
+ this,
+ () => this.getViewModel().checksRunsSelected$,
+ x => (this.selectedRuns = x)
+ );
+ subscribe(
+ this,
+ () => this.getViewModel().checksResultsFilter$,
+ x => (this.filterRegExp = x)
+ );
}
static override get styles() {
@@ -1051,6 +1064,9 @@
protected override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
+ if (changedProperties.has('filterRegExp') && this.filterInput) {
+ this.filterInput.value = this.filterRegExp;
+ }
if (changedProperties.has('tabState') && this.tabState) {
const {statusOrCategory, checkName} = this.tabState;
if (isCategory(statusOrCategory)) {
@@ -1224,11 +1240,10 @@
}
private handleFilter(e: ChecksResultsFilterEvent) {
- if (!this.filterInput) return;
- const oldValue = this.filterInput.value ?? '';
const newValue = e.detail.filterRegExp ?? '';
- this.filterInput.value = oldValue === newValue ? '' : newValue;
- this.onFilterInputChange();
+ this.getViewModel().updateState({
+ checksResultsFilter: this.filterRegExp === newValue ? '' : newValue,
+ });
}
private renderAction(action?: Action) {
@@ -1289,10 +1304,7 @@
}
isRunSelected(run: {checkName: string}) {
- return (
- this.selectedRuns.length === 0 ||
- this.selectedRuns.includes(run.checkName)
- );
+ return this.selectedRuns.size === 0 || this.selectedRuns.has(run.checkName);
}
renderFilter() {
@@ -1300,10 +1312,11 @@
run =>
this.isRunSelected(run) && isAttemptSelected(this.selectedAttempt, run)
);
- if (this.selectedRuns.length === 0 && allResults(runs).length <= 3) {
- if (this.filterRegExp.source.length > 0) {
- this.filterRegExp = new RegExp('');
- }
+ if (
+ this.selectedRuns.size === 0 &&
+ allResults(runs).length <= 3 &&
+ this.filterRegExp === ''
+ ) {
return;
}
return html`
@@ -1325,7 +1338,9 @@
{},
{deduping: Deduping.EVENT_ONCE_PER_CHANGE}
);
- this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+ this.getViewModel().updateState({
+ checksResultsFilter: this.filterInput.value,
+ });
}
renderSection(category: Category) {
@@ -1342,18 +1357,32 @@
],
[]
);
- const isSelection = this.selectedRuns.length > 0;
+ const isSelectionActive = this.selectedRuns.size > 0;
const selected = all.filter(result => this.isRunSelected(result));
- const filtered = selected.filter(result =>
- matches(result, this.filterRegExp)
- );
+ const re = new RegExp(this.filterRegExp, 'i');
+ const filtered = selected.filter(result => matches(result, re));
+ const isFilterActiveWithResults =
+ this.filterRegExp !== '' && filtered.length > 0;
+
+ // The logic for deciding whether to expand a section by default is a bit
+ // complicated, but we want to collapse empty and info/success sections by
+ // default for a clean and focused user experience. However, as soon as the
+ // user starts selecting or filtering we must take this into account and
+ // prefer to expand the sections.
let expanded = this.isSectionExpanded.get(category);
const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
if (!expandedByUser || expanded === undefined) {
- expanded = selected.length > 0 && (isWarningOrError || isSelection);
+ // Note that we are using `selected` for `isEmpty` and not `filtered`,
+ // because if the filter is what makes a section empty, then we want to
+ // show an expanded section, which contains a message about this.
+ const isEmpty = selected.length === 0;
+ expanded =
+ !isEmpty &&
+ (isWarningOrError || isSelectionActive || isFilterActiveWithResults);
this.isSectionExpanded.set(category, expanded);
}
const expandedClass = expanded ? 'expanded' : 'collapsed';
+
const isShowAll = this.isShowAll.get(category) ?? false;
const resultCount = filtered.length;
const empty = resultCount === 0 ? 'empty' : '';
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 09a2558..128a9b0a 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -44,7 +44,7 @@
} from '../../models/checks/checks-fakes';
import {assertIsDefined} from '../../utils/common-util';
import {modifierPressed, whenVisible} from '../../utils/dom-util';
-import {fireRunSelected, fireRunSelectionReset} from './gr-checks-util';
+import {fireRunSelected, RunSelectedEvent} from './gr-checks-util';
import {ChecksTabState} from '../../types/events';
import {charsOnly} from '../../utils/string-util';
import {getAppContext} from '../../services/app-context';
@@ -57,6 +57,7 @@
import {Interaction} from '../../constants/reporting';
import {Deduping} from '../../api/reporting';
import {when} from 'lit/directives/when.js';
+import {changeViewModelToken} from '../../models/views/change';
@customElement('gr-checks-run')
export class GrChecksRun extends LitElement {
@@ -403,8 +404,8 @@
@property({type: Boolean, reflect: true})
collapsed = false;
- @property({attribute: false})
- selectedRuns: string[] = [];
+ @state()
+ selectedRuns: Set<string> = new Set();
@state()
selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
@@ -424,6 +425,8 @@
private getChecksModel = resolve(this, checksModelToken);
+ private readonly getViewModel = resolve(this, changeViewModelToken);
+
private readonly reporting = getAppContext().reportingService;
constructor() {
@@ -453,6 +456,11 @@
() => this.getChecksModel().runFilterRegexp$,
x => (this.filterRegExp = x)
);
+ subscribe(
+ this,
+ () => this.getViewModel().checksRunsSelected$,
+ x => (this.selectedRuns = x)
+ );
this.addEventListener('click', () => {
if (this.collapsed) this.toggleCollapsed();
});
@@ -660,8 +668,8 @@
private renderTitleButtons() {
if (this.collapsed) return;
- if (this.selectedRuns.length < 2) return;
- const actions = this.selectedRuns.map(selected => {
+ if (this.selectedRuns.size < 2) return;
+ const actions = [...this.selectedRuns].map(selected => {
const run = this.runs.find(
run => run.isLatestAttempt && run.checkName === selected
);
@@ -676,7 +684,8 @@
<gr-button
class="font-normal"
link
- @click=${() => fireRunSelectionReset(this)}
+ @click=${() =>
+ this.getViewModel().updateState({checksRunsSelected: undefined})}
>Unselect All</gr-button
>
<gr-tooltip-content
@@ -820,16 +829,23 @@
}
renderRun(run: CheckRun) {
- const selectedRun = this.selectedRuns.includes(run.checkName);
- const deselected = !selectedRun && this.selectedRuns.length > 0;
+ const selectedRun = this.selectedRuns.has(run.checkName);
+ const deselected = !selectedRun && this.selectedRuns.size > 0;
return html`<gr-checks-run
.run=${run}
?condensed=${this.collapsed}
.selected=${selectedRun}
.deselected=${deselected}
+ @run-selected=${this.handleRunSelected}
></gr-checks-run>`;
}
+ handleRunSelected(e: RunSelectedEvent) {
+ if (e.detail.checkName) {
+ this.getViewModel().toggleSelectedCheckRun(e.detail.checkName);
+ }
+ }
+
showFilter(): boolean {
if (this.collapsed) return false;
return this.runs.length > 10 || !!this.filterRegExp;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 82893bc..c1bcdc1 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -14,7 +14,6 @@
import './gr-checks-runs';
import './gr-checks-results';
import {NumericChangeId, PatchSetNumber} from '../../types/common';
-import {RunSelectedEvent} from './gr-checks-util';
import {TabState} from '../../types/events';
import {getAppContext} from '../../services/app-context';
import {subscribe} from '../lit/subscription-controller';
@@ -50,9 +49,6 @@
@state()
changeNum: NumericChangeId | undefined = undefined;
- @state()
- selectedRuns: string[] = [];
-
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getChecksModel = resolve(this, checksModelToken);
@@ -132,41 +128,16 @@
class="runs"
?collapsed=${this.offsetWidth < 1000}
.runs=${this.runs}
- .selectedRuns=${this.selectedRuns}
.tabState=${this.tabState?.checksTab}
- @run-selected=${this.handleRunSelected}
></gr-checks-runs>
<gr-checks-results
class="results"
.tabState=${this.tabState?.checksTab}
.runs=${this.runs}
- .selectedRuns=${this.selectedRuns}
></gr-checks-results>
</div>
`;
}
-
- handleRunSelected(e: RunSelectedEvent) {
- this.reporting.reportInteraction(Interaction.CHECKS_RUN_SELECTED, {
- checkName: e.detail.checkName,
- reset: e.detail.reset,
- });
- if (e.detail.reset) {
- this.selectedRuns = [];
- return;
- }
- if (e.detail.checkName) {
- this.toggleSelected(e.detail.checkName);
- }
- }
-
- toggleSelected(checkName: string) {
- if (this.selectedRuns.includes(checkName)) {
- this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
- } else {
- this.selectedRuns = [...this.selectedRuns, checkName];
- }
- }
}
declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index 939a87e..c7477c4 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -11,7 +11,6 @@
} from '../../models/checks/checks-util';
export interface RunSelectedEventDetail {
- reset: boolean;
checkName?: string;
}
@@ -33,16 +32,6 @@
);
}
-export function fireRunSelectionReset(target: EventTarget) {
- target.dispatchEvent(
- new CustomEvent('run-selected', {
- detail: {reset: true},
- composed: true,
- bubbles: true,
- })
- );
-}
-
export function isAttemptSelected(
selectedAttempt: AttemptChoice,
run: CheckRun
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 a35ca40..f829313 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -315,11 +315,12 @@
// Note that router model view must be updated before view model state.
// So this check is slightly fragile, but should work.
if (this.view !== GerritView.CHANGE) return;
- const browserUrl = window.location.toString();
- const stateUrl = new URL(createChangeUrl(state), browserUrl).toString();
- if (browserUrl !== stateUrl) {
+ const browserUrl = new URL(window.location.toString());
+ const stateUrl = new URL(createChangeUrl(state), browserUrl);
+ stateUrl.hash = browserUrl.hash;
+ if (browserUrl.toString() !== stateUrl.toString()) {
page.replace(
- stateUrl,
+ stateUrl.toString(),
null,
/* init: */ false,
/* dispatch: */ false
@@ -1405,8 +1406,12 @@
}
const filter = queryMap.get('filter');
if (filter) state.filter = filter;
+ const checksResultsFilter = queryMap.get('checksResultsFilter');
+ if (checksResultsFilter) state.checksResultsFilter = checksResultsFilter;
const attempt = stringToAttemptChoice(queryMap.get('attempt'));
if (attempt && attempt !== LATEST_ATTEMPT) state.attempt = attempt;
+ const selected = queryMap.get('checksRunsSelected');
+ if (selected) state.checksRunsSelected = new Set(selected.split(','));
assertIsDefined(state.project, 'project');
this.reporting.setRepoName(state.project);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index ce3ed0d..119ba48 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -1149,6 +1149,8 @@
queryMap.set('filter', 'fff');
queryMap.set('select', 'sss');
queryMap.set('attempt', '1');
+ queryMap.set('checksRunsSelected', 'asdf,qwer');
+ queryMap.set('checksResultsFilter', 'asdf.*qwer');
ctx.querystring = queryMap.toString();
assertctxToParams(ctx, 'handleChangeRoute', {
view: GerritView.CHANGE,
@@ -1159,6 +1161,8 @@
attempt: 1,
filter: 'fff',
tab: 'checks',
+ checksRunsSelected: new Set(['asdf', 'qwer']),
+ checksResultsFilter: 'asdf.*qwer',
});
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 262f1fd..17d7516 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -22,7 +22,7 @@
import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
import {OpenFixPreviewEvent} from '../../../types/events';
import {getAppContext} from '../../../services/app-context';
-import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
+import {fireCloseFixPreview} from '../../../utils/event-util';
import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
import {GrButton} from '../../shared/gr-button/gr-button';
import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
@@ -33,6 +33,7 @@
import {assert} from '../../../utils/common-util';
import {resolve} from '../../../models/dependency';
import {createChangeUrl} from '../../../models/views/change';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
interface FilePreview {
filepath: string;
@@ -44,6 +45,15 @@
@query('#applyFixOverlay')
applyFixOverlay?: GrOverlay;
+ @query('#applyFixDialog')
+ applyFixDialog?: GrDialog;
+
+ /** The currently observed dialog by `dialogOberserver`. */
+ observedDialog?: GrDialog;
+
+ /** The current observer observing the `observedDialog`. */
+ dialogObserver?: ResizeObserver;
+
@query('#nextFix')
nextFix?: GrButton;
@@ -102,9 +112,6 @@
this.diffPrefs = diffPreferences;
}
);
- this.addEventListener('diff-context-expanded', () => {
- if (this.applyFixOverlay) fireEvent(this.applyFixOverlay, 'iron-resize');
- });
}
static override styles = [
@@ -148,6 +155,39 @@
`;
}
+ override updated() {
+ this.updateDialogObserver();
+ }
+
+ override disconnectedCallback() {
+ this.removeDialogObserver();
+ super.disconnectedCallback();
+ }
+
+ private removeDialogObserver() {
+ this.dialogObserver?.disconnect();
+ this.dialogObserver = undefined;
+ this.observedDialog = undefined;
+ }
+
+ private updateDialogObserver() {
+ if (
+ this.applyFixDialog === this.observedDialog &&
+ this.dialogObserver !== undefined
+ ) {
+ return;
+ }
+
+ this.removeDialogObserver();
+ if (!this.applyFixDialog) return;
+
+ this.observedDialog = this.applyFixDialog;
+ this.dialogObserver = new ResizeObserver(() => {
+ this.applyFixOverlay?.refit();
+ });
+ this.dialogObserver.observe(this.observedDialog);
+ }
+
private renderHeader() {
return html`
<div slot="header">${this.currentFix?.description ?? ''}</div>
@@ -202,7 +242,7 @@
* Given event with fixSuggestions, fetch diffs associated with first
* suggested fix and open dialog.
*/
- async open(e: OpenFixPreviewEvent) {
+ open(e: OpenFixPreviewEvent) {
this.patchNum = e.detail.patchNum;
this.fixSuggestions = e.detail.fixSuggestions;
assert(this.fixSuggestions.length > 0, 'no fix in the event');
@@ -212,9 +252,6 @@
this.showSelectedFixSuggestion(this.fixSuggestions[0]),
this.applyFixOverlay?.open()
);
- return Promise.all(promises).then(() => {
- if (this.applyFixOverlay) fireEvent(this.applyFixOverlay, 'iron-resize');
- });
}
private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index b6e4a95..4d2d454 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -52,7 +52,7 @@
}
async function open(detail: OpenFixPreviewEventDetail) {
- await element.open(
+ element.open(
new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
detail,
})
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 33ba1e4..0cec8dd 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -70,7 +70,6 @@
appContext.flagsService,
appContext.userModel
);
- this.serviceWorkerInstaller.init();
}
}
diff --git a/polygerrit-ui/app/elements/lit/fit-controller.ts b/polygerrit-ui/app/elements/lit/fit-controller.ts
new file mode 100644
index 0000000..423a4f0
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/fit-controller.ts
@@ -0,0 +1,213 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ReactiveController, ReactiveControllerHost} from 'lit';
+
+export interface FitControllerHost {
+ /**
+ * This offset will increase or decrease the distance to the left side
+ * of the screen, a negative offset will move the dropdown to the left
+ * a positive one, to the right.
+ *
+ */
+ horizontalOffset: number;
+
+ /**
+ * This offset will increase or decrease the distance to the top
+ * side of the screen: a negative offset will move the dropdown upwards
+ * , a positive one, downwards.
+ *
+ */
+ verticalOffset: number;
+}
+
+export interface PositionStyles {
+ top: string;
+ left: string;
+ position: string;
+ maxWidth: string;
+ maxHeight: string;
+ boxSizing: string;
+}
+
+/**
+ * `FitController` fits an element in another element using `max-height`
+ * and `max-width`.
+ *
+ * FitController overrides all properties defined in PositionStyles for the
+ * host.
+ * The element will only be sized and/or positioned if it has not already been
+ * sized and/or positioned by CSS.
+ * CSS properties | Action
+ * --------------------------|-------------------------------------------
+ * `position` set | Element is not centered horizontally/vertically
+ * `top` or `bottom` set | Element is not vertically centered
+ * `left` or `right` set | Element is not horizontally centered
+ * `max-height` set | Element respects `max-height`
+ * `max-width` set | Element respects `max-width`
+ *
+ * `FitController` positions an element into another element and gives it
+ * a horizontalAlignment = left and verticalAlignment = top.
+ * This will override the element's css position.
+ *
+ * Use `horizontalOffset, verticalOffset` to offset the element from its
+ * `positionTarget`; `FitController` will collapse these in order to
+ * keep the element within `window` boundaries, while preserving the element's
+ * CSS margin values.
+ *
+ */
+export class FitController implements ReactiveController {
+ host: ReactiveControllerHost & HTMLElement & FitControllerHost;
+
+ private originalStyles?: PositionStyles;
+
+ private positionTarget?: HTMLElement;
+
+ constructor(host: ReactiveControllerHost & HTMLElement & FitControllerHost) {
+ (this.host = host).addController(this);
+ }
+
+ hostConnected() {
+ this.positionTarget = this.getPositionTarget();
+ }
+
+ hostDisconnected() {}
+
+ // private but used in tests
+ getPositionTarget() {
+ let parent = this.host.parentNode;
+
+ if (parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
+ parent = (parent as ShadowRoot).host;
+ }
+
+ return parent as HTMLElement;
+ }
+
+ private saveOriginalStyles() {
+ // These properties are changed in position() hence keep the original
+ // values to reset the host styles later.
+ this.originalStyles = {
+ top: this.host.style.top || '',
+ left: this.host.style.left || '',
+ position: this.host.style.position || '',
+ maxWidth: this.host.style.maxWidth || '',
+ maxHeight: this.host.style.maxHeight || '',
+ boxSizing: this.host.style.boxSizing || '',
+ };
+ }
+
+ /**
+ * Reset the host style, and clear the memoized data.
+ */
+ private resetStyles() {
+ // It is necessary to clear the max-width:0px and max-height:0px.
+ // A component may call refit() multiple times, in which case we don't
+ // want the values assigned from the first call which may not be precisely
+ // correct to influence the second call.
+ // Hence we reset the styles here.
+ if (this.originalStyles !== undefined) {
+ Object.assign(this.host.style, this.originalStyles);
+ }
+ this.originalStyles = undefined;
+ }
+
+ setPositionTarget(target: HTMLElement) {
+ this.positionTarget = target;
+ }
+
+ /**
+ * Equivalent to calling `resetStyles()` and `position()`.
+ * Useful to call this after the element or the `window` element has
+ * been resized, or if any of the positioning properties
+ * (e.g. `horizontalOffset, verticalOffset`) are updated.
+ * It preserves the scroll position of the host.
+ */
+ refit() {
+ const scrollLeft = this.host.scrollLeft;
+ const scrollTop = this.host.scrollTop;
+ this.resetStyles();
+ this.position();
+ this.host.scrollLeft = scrollLeft;
+ this.host.scrollTop = scrollTop;
+ }
+
+ private position() {
+ this.saveOriginalStyles();
+
+ this.host.style.position = 'fixed';
+ // Need border-box for margin/padding.
+ this.host.style.boxSizing = 'border-box';
+
+ const hostRect = this.host.getBoundingClientRect();
+ const positionRect = this.getNormalizedRect(this.positionTarget!);
+ const windowRect = this.getNormalizedRect(window);
+
+ this.calculateAndSetPositions(hostRect, positionRect, windowRect);
+ }
+
+ // private but used in tests
+ calculateAndSetPositions(
+ hostRect: DOMRect,
+ positionRect: DOMRect,
+ windowRect: DOMRect
+ ) {
+ const hostStyles = (window as Window).getComputedStyle(this.host);
+ const hostMinWidth = parseInt(hostStyles.minWidth) || 0;
+ const hostMinHeight = parseInt(hostStyles.minHeight) || 0;
+
+ const hostMargin = {
+ top: parseInt(hostStyles.marginTop) || 0,
+ right: parseInt(hostStyles.marginRight) || 0,
+ bottom: parseInt(hostStyles.marginBottom) || 0,
+ left: parseInt(hostStyles.marginLeft) || 0,
+ };
+
+ let leftPosition =
+ positionRect.left + this.host.horizontalOffset + hostMargin.left;
+ let topPosition =
+ positionRect.top + this.host.verticalOffset + hostMargin.top;
+
+ // Limit right/bottom within window respecting the margin.
+ const rightPosition = Math.min(
+ windowRect.right - hostMargin.right,
+ leftPosition + hostRect.width
+ );
+ const bottomPosition = Math.min(
+ windowRect.bottom - hostMargin.bottom,
+ topPosition + hostRect.height
+ );
+
+ // Respect hostMinWidth and hostMinHeight
+ // Current width is rightPosition - leftPosition or hostRect.width
+ // rightPosition - leftPosition >= hostMinWidth
+ // => leftPosition <= rightPosition - hostMinWidth
+ leftPosition = Math.min(leftPosition, rightPosition - hostMinWidth);
+ topPosition = Math.min(topPosition, bottomPosition - hostMinHeight);
+
+ // Limit left/top within window respecting the margin.
+ leftPosition = Math.max(windowRect.left + hostMargin.left, leftPosition);
+ topPosition = Math.max(windowRect.top + hostMargin.top, topPosition);
+
+ // Use right/bottom to set maxWidth/maxHeight and respect
+ // minWidth/minHeight.
+ const maxWidth = Math.max(rightPosition - leftPosition, hostMinWidth);
+ const maxHeight = Math.max(bottomPosition - topPosition, hostMinHeight);
+
+ this.host.style.maxWidth = `${maxWidth}px`;
+ this.host.style.maxHeight = `${maxHeight}px`;
+
+ this.host.style.left = `${leftPosition}px`;
+ this.host.style.top = `${topPosition}px`;
+ }
+
+ private getNormalizedRect(target: Window | HTMLElement): DOMRect {
+ if (target === document.documentElement || target === window) {
+ return new DOMRect(0, 0, window.innerWidth, window.innerHeight);
+ }
+ return (target as HTMLElement).getBoundingClientRect();
+ }
+}
diff --git a/polygerrit-ui/app/elements/lit/fit-controller_test.ts b/polygerrit-ui/app/elements/lit/fit-controller_test.ts
new file mode 100644
index 0000000..2a60b83
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/fit-controller_test.ts
@@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {FitController} from './fit-controller';
+import {LitElement} from 'lit';
+import {customElement} from 'lit/decorators.js';
+
+@customElement('fit-element')
+class FitElement extends LitElement {
+ fitController = new FitController(this);
+
+ horizontalOffset = 0;
+
+ verticalOffset = 0;
+
+ override render() {
+ return html`<div></div>`;
+ }
+}
+
+suite('fit controller', () => {
+ let element: FitElement;
+ setup(async () => {
+ element = await fixture(html`<fit-element></fit-element>`);
+ });
+
+ test('refit positioning', async () => {
+ const hostRect = new DOMRect(0, 0, 50, 50);
+
+ const positionRect = new DOMRect(37, 37, 300, 60);
+
+ const windowRect = new DOMRect(0, 0, 600, 600);
+
+ element.fitController.calculateAndSetPositions(
+ hostRect,
+ positionRect,
+ windowRect
+ );
+
+ assert.equal(element.style.top, '37px');
+ assert.equal(element.style.left, '37px');
+ });
+
+ test('refit positioning with offset', async () => {
+ const elementWithOffset: FitElement = await fixture(
+ html`<fit-element></fit-element>`
+ );
+ elementWithOffset.verticalOffset = 10;
+ elementWithOffset.horizontalOffset = 20;
+
+ const hostRect = new DOMRect(0, 0, 50, 50);
+
+ const positionRect = new DOMRect(37, 37, 300, 60);
+
+ const windowRect = new DOMRect(0, 0, 600, 600);
+
+ elementWithOffset.fitController.calculateAndSetPositions(
+ hostRect,
+ positionRect,
+ windowRect
+ );
+
+ assert.equal(elementWithOffset.style.top, '47px');
+ assert.equal(elementWithOffset.style.left, '57px');
+ });
+
+ test('host margin updates positioning', async () => {
+ const hostRect = new DOMRect(0, 0, 50, 50);
+
+ const positionRect = new DOMRect(37, 37, 300, 60);
+
+ const windowRect = new DOMRect(0, 0, 600, 600);
+
+ element.style.marginLeft = '10px';
+ element.style.marginTop = '10px';
+ element.fitController.calculateAndSetPositions(
+ hostRect,
+ positionRect,
+ windowRect
+ );
+
+ // is 10px extra from the previous test due to host margin
+ assert.equal(element.style.top, '47px');
+ assert.equal(element.style.left, '47px');
+ });
+
+ test('host minWidth, minHeight overrides positioning', async () => {
+ const hostRect = new DOMRect(0, 0, 50, 50);
+
+ const positionRect = new DOMRect(37, 37, 300, 60);
+
+ const windowRect = new DOMRect(0, 0, 600, 600);
+
+ element.style.marginLeft = '10px';
+ element.style.marginTop = '10px';
+
+ element.style.minHeight = '50px';
+ element.style.minWidth = '60px';
+
+ element.fitController.calculateAndSetPositions(
+ hostRect,
+ positionRect,
+ windowRect
+ );
+
+ assert.equal(element.style.top, '47px');
+
+ // Should be 47 like the previous test but that would make it overall
+ // smaller in width than the minWidth defined
+ assert.equal(element.style.left, '37px');
+ assert.equal(element.style.maxWidth, '60px');
+ });
+
+ test('positioning happens within window size ', async () => {
+ const hostRect = new DOMRect(0, 0, 50, 50);
+
+ const positionRect = new DOMRect(37, 37, 300, 60);
+
+ // window size is small hence limits the position
+ const windowRect = new DOMRect(0, 0, 50, 50);
+
+ element.style.marginLeft = '10px';
+ element.style.marginTop = '10px';
+
+ element.fitController.calculateAndSetPositions(
+ hostRect,
+ positionRect,
+ windowRect
+ );
+
+ assert.equal(element.style.top, '47px');
+ assert.equal(element.style.left, '47px');
+ // With the window size being 50, the element is styled with width 3px
+ // width = windowSize - leftPosition = 50 - 47 = 3px
+ // Without the window width restriction, in previous test maxWidth is 60px
+ assert.equal(element.style.maxWidth, '3px');
+ });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index c4ca51e..ff2904a 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -28,7 +28,11 @@
import {GrGroupList} from '../gr-group-list/gr-group-list';
import {GrIdentities} from '../gr-identities/gr-identities';
import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {PreferencesInput, ServerInfo} from '../../../types/common';
+import {
+ AccountDetailInfo,
+ PreferencesInput,
+ ServerInfo,
+} from '../../../types/common';
import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
@@ -58,6 +62,7 @@
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {settingsViewModelToken} from '../../../models/views/settings';
+import {areNotificationsEnabled} from '../../../utils/worker-util';
const GERRIT_DOCS_BASE_URL =
'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -111,6 +116,9 @@
@query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
+ @query('#allowBrowserNotifications')
+ allowBrowserNotifications?: HTMLInputElement;
+
@query('#disableKeyboardShortcuts')
disableKeyboardShortcuts!: HTMLInputElement;
@@ -186,6 +194,8 @@
// private but used in test
@state() showNumber?: boolean;
+ @state() account?: AccountDetailInfo;
+
// private but used in test
public _testOnly_loadingPromise?: Promise<void>;
@@ -193,7 +203,8 @@
private readonly userModel = getAppContext().userModel;
- private readonly flagsService = getAppContext().flagsService;
+ // private but used in test
+ readonly flagsService = getAppContext().flagsService;
private readonly getViewModel = resolve(this, settingsViewModelToken);
@@ -209,6 +220,13 @@
);
subscribe(
this,
+ () => this.userModel.account$,
+ acc => {
+ this.account = acc;
+ }
+ );
+ subscribe(
+ this,
() => this.userModel.preferences$,
prefs => {
if (!prefs) {
@@ -399,7 +417,8 @@
<fieldset id="preferences">
${this.renderTheme()} ${this.renderChangesPerPages()}
${this.renderDateTimeFormat()} ${this.renderEmailNotification()}
- ${this.renderEmailFormat()} ${this.renderDefaultBaseForMerges()}
+ ${this.renderEmailFormat()} ${this.renderBrowserNotifications()}
+ ${this.renderDefaultBaseForMerges()}
${this.renderRelativeDateInChangeTable()} ${this.renderDiffView()}
${this.renderShowSizeBarsInFileList()}
${this.renderPublishCommentsOnPush()}
@@ -869,6 +888,31 @@
`;
}
+ private renderBrowserNotifications() {
+ if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
+ return nothing;
+ if (!areNotificationsEnabled(this.account)) return nothing;
+ return html`
+ <section id="allowBrowserNotificationsSection">
+ <label class="title" for="allowBrowserNotifications"
+ >Allow browser notifications</label
+ >
+ <span class="value">
+ <input
+ id="allowBrowserNotifications"
+ type="checkbox"
+ ?checked=${this.localPrefs.allow_browser_notifications}
+ @change=${() => {
+ this.localPrefs.allow_browser_notifications =
+ this.allowBrowserNotifications!.checked;
+ this.prefsChanged = true;
+ }}
+ />
+ </span>
+ </section>
+ `;
+ }
+
private renderDefaultBaseForMerges() {
if (!this.localPrefs.default_base_for_merges) return nothing;
return nothing;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 72c19b4..0798991 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -6,7 +6,13 @@
import '../../../test/common-test-setup';
import './gr-settings-view';
import {GrSettingsView} from './gr-settings-view';
-import {queryAll, stubRestApi, waitEventLoop} from '../../../test/test-utils';
+import {
+ queryAll,
+ queryAndAssert,
+ stubFlags,
+ stubRestApi,
+ waitEventLoop,
+} from '../../../test/test-utils';
import {
AuthInfo,
AccountDetailInfo,
@@ -510,6 +516,24 @@
);
});
+ test('allow browser notifications', async () => {
+ stubFlags('isEnabled').returns(true);
+ element = await fixture(html`<gr-settings-view></gr-settings-view>`);
+ element.account = createAccountDetailWithId();
+ await element.updateComplete;
+ assert.dom.equal(
+ queryAndAssert(element, '#allowBrowserNotificationsSection'),
+ /* HTML */ `<section id="allowBrowserNotificationsSection">
+ <label class="title" for="allowBrowserNotifications">
+ Allow browser notifications
+ </label>
+ <span class="value">
+ <input checked="" id="allowBrowserNotifications" type="checkbox" />
+ </span>
+ </section>`
+ );
+ });
+
test('calls the title-change event', async () => {
const titleChangedStub = sinon.stub();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 78a1509..9dfbc04 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -73,6 +73,7 @@
import {Interaction} from '../../../constants/reporting';
import {HtmlPatched} from '../../../utils/lit-util';
import {createDiffUrl} from '../../../models/views/diff';
+import {createChangeUrl} from '../../../models/views/change';
declare global {
interface HTMLElementEventMap {
@@ -779,13 +780,20 @@
if (!comment) return;
assertIsDefined(this.changeNum, 'changeNum');
assertIsDefined(this.repoName, 'repoName');
- const url = generateAbsoluteUrl(
- createDiffUrl({
+ let url: string;
+ if (this.isPatchsetLevel()) {
+ url = createChangeUrl({
changeNum: this.changeNum,
project: this.repoName,
commentId: comment.id,
- })
- );
+ });
+ } else {
+ url = createDiffUrl({
+ changeNum: this.changeNum,
+ project: this.repoName,
+ commentId: comment.id,
+ });
+ }
assertIsDefined(url, 'url for comment');
copyToClipbard(generateAbsoluteUrl(url), 'Link');
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index b767a32..10e54c7 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -28,6 +28,8 @@
import {SinonStub} from 'sinon';
import {fixture, html, waitUntil, assert} from '@open-wc/testing';
import {GrButton} from '../gr-button/gr-button';
+import {SpecialFilePath} from '../../../constants/constants';
+import {GrIcon} from '../gr-icon/gr-icon';
const c1 = {
author: {name: 'Kermit'},
@@ -447,4 +449,35 @@
},
]);
});
+
+ test('patchset comments link to /comments URL', async () => {
+ const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+ element.thread = {
+ ...createThread(c1),
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ };
+ await element.updateComplete;
+
+ queryAndAssert<GrIcon>(element, 'gr-icon.copy').click();
+
+ assert.equal(1, clipboardStub.callCount);
+ assert.equal(
+ clipboardStub.firstCall.args[0],
+ 'http://localhost:9876/c/test-repo-name/+/1/comments/the-root'
+ );
+ });
+
+ test('file comments link to /comment URL', async () => {
+ const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+ element.thread = createThread(c1);
+ await element.updateComplete;
+
+ queryAndAssert<GrIcon>(element, 'gr-icon.copy').click();
+
+ assert.equal(1, clipboardStub.callCount);
+ assert.equal(
+ clipboardStub.firstCall.args[0],
+ 'http://localhost:9876/c/test-repo-name/+/1/comment/the-root/'
+ );
+ });
});
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 6ab0ec4..5a1db30 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -16,11 +16,7 @@
import {subscribe} from '../../lit/subscription-controller';
import {configModelToken} from '../../../models/config/config-model';
import {CommentLinks, EmailAddress} from '../../../api/rest-api';
-import {
- applyHtmlRewritesFromConfig,
- applyLinkRewritesFromConfig,
- linkifyNormalUrls,
-} from '../../../utils/link-util';
+import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
import '../gr-account-chip/gr-account-chip';
import {KnownExperimentId} from '../../../services/flags/flags';
import {getAppContext} from '../../../services/app-context';
@@ -125,7 +121,7 @@
}
private renderAsPlaintext() {
- const linkedText = this.rewriteText(
+ const linkedText = linkifyUrlsAndApplyRewrite(
htmlEscape(this.content).toString(),
this.repoCommentLinks
);
@@ -140,7 +136,7 @@
// renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
// closure.
const boundRewriteText = (text: string) =>
- this.rewriteText(text, this.repoCommentLinks);
+ linkifyUrlsAndApplyRewrite(text, this.repoCommentLinks);
// We are overriding some marked-element renderers for a few reasons:
// 1. Disable inline images as a design/policy choice.
@@ -201,24 +197,6 @@
return text;
}
- private rewriteText(text: string, repoCommentLinks: CommentLinks) {
- // Turn universally identifiable URLs into links. Ex: www.google.com. The
- // markdown library inside marked-element does this too, but is more
- // conservative and misses some URLs like "google.com" without "www" prefix.
- text = linkifyNormalUrls(text);
-
- // Apply the host's config-specific regex replacements to create links. Ex:
- // link "Bug 12345" to "google.com/bug/12345"
- text = applyLinkRewritesFromConfig(text, repoCommentLinks);
-
- // Apply the host's config-specific regex replacements to write arbitrary
- // html. Most examples seen in the wild are also used for linking but with
- // finer control over the rendered text. Ex: "Bug 12345" => "#12345"
- text = applyHtmlRewritesFromConfig(text, repoCommentLinks);
-
- return text;
- }
-
override updated() {
// Look for @mentions and replace them with an account-label chip.
if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 6391347..ca498318 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -77,6 +77,76 @@
await element.updateComplete;
});
+ test('does not apply rewrites within links', async () => {
+ element.content = 'google.com/LinkRewriteMe';
+ await element.updateComplete;
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <pre class="plaintext">
+ <a
+ href="http://google.com/LinkRewriteMe"
+ rel="noopener"
+ target="_blank"
+ >
+ google.com/LinkRewriteMe
+ </a>
+ </pre>
+ `
+ );
+ });
+
+ test('does not apply rewrites on rewritten text', async () => {
+ await setCommentLinks({
+ capitalizeFoo: {
+ match: 'foo',
+ html: 'FOO',
+ },
+ lowercaseFoo: {
+ match: 'FOO',
+ html: 'foo',
+ },
+ });
+ element.content = 'foo';
+ await element.updateComplete;
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <pre class="plaintext">
+ FOO
+ </pre
+ >
+ `
+ );
+ });
+
+ test('supports overlapping rewrites', async () => {
+ await setCommentLinks({
+ bracketNum: {
+ match: '(Start:) ([0-9]+)',
+ html: '$1 [$2]',
+ },
+ bracketNum2: {
+ match: '(Start: [0-9]+) ([0-9]+)',
+ html: '$1 [$2]',
+ },
+ });
+ element.content = 'Start: 123 456';
+ await element.updateComplete;
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <pre class="plaintext">
+ Start: [123] [456]
+ </pre
+ >
+ `
+ );
+ });
+
test('renders text with links and rewrites', async () => {
element.content = `text with plain link: google.com
\ntext with config link: LinkRewriteMe
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
deleted file mode 100644
index 16a60e7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../styles/shared-styles';
-import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
-import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {assertIsDefined} from '../../../utils/common-util';
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-linked-text': GrLinkedText;
- }
-}
-
-@customElement('gr-linked-text')
-export class GrLinkedText extends LitElement {
- private outputElement?: HTMLSpanElement;
-
- @property({type: Boolean, attribute: 'remove-zero-width-space'})
- removeZeroWidthSpace?: boolean;
-
- @property({type: String})
- content = '';
-
- @property({type: Boolean, attribute: true})
- pre = false;
-
- @property({type: Boolean, attribute: true})
- disabled = false;
-
- @property({type: Boolean, attribute: true})
- inline = false;
-
- @property({type: Object})
- config?: LinkTextParserConfig;
-
- static override get styles() {
- return css`
- :host {
- display: block;
- }
- :host([inline]) {
- display: inline;
- }
- :host([pre]) ::slotted(span) {
- white-space: var(--linked-text-white-space, pre-wrap);
- word-wrap: var(--linked-text-word-wrap, break-word);
- }
- `;
- }
-
- override render() {
- return html`<slot name="insert"></slot>`;
- }
-
- // NOTE: LinkTextParser dynamically creates HTML fragments based on backend
- // configuration commentLinks. These commentLinks can contain arbitrary HTML
- // fragments. This means that arbitrary HTML needs to be injected into the
- // DOM-tree, where this HTML is is controlled on the server-side in the
- // server-configuration rather than by arbitrary users.
- // To enable this injection of 'unsafe' HTML, LinkTextParser generates
- // HTML fragments. Lit does not support inserting html fragments directly
- // into its DOM-tree as it controls the DOM-tree that it generates.
- // Therefore, to get around this we create a single element that we slot into
- // the Lit-owned DOM. This element will not be part of this LitElement as
- // it's slotted in and thus can be modified on the fly by handleParseResult.
- override firstUpdated(_changedProperties: PropertyValues): void {
- this.outputElement = document.createElement('span');
- this.outputElement.id = 'output';
- this.outputElement.slot = 'insert';
- this.append(this.outputElement);
- }
-
- override updated(changedProperties: PropertyValues): void {
- if (changedProperties.has('content') || changedProperties.has('config')) {
- this._contentOrConfigChanged();
- } else if (changedProperties.has('disabled')) {
- this.styleLinks();
- }
- }
-
- /**
- * Because either the source text or the linkification config has changed,
- * the content should be re-parsed.
- * Private but used in tests.
- *
- * @param content The raw, un-linkified source string to parse.
- * @param config The server config specifying commentLink patterns
- */
- _contentOrConfigChanged() {
- if (!this.config) {
- assertIsDefined(this.outputElement);
- this.outputElement.textContent = this.content;
- return;
- }
-
- assertIsDefined(this.outputElement);
- this.outputElement.textContent = '';
- const parser = new GrLinkTextParser(
- this.config,
- (text: string | null, href: string | null, fragment?: DocumentFragment) =>
- this.handleParseResult(text, href, fragment),
- this.removeZeroWidthSpace
- );
- parser.parse(this.content);
-
- // Ensure that external links originating from HTML commentlink configs
- // open in a new tab. @see Issue 5567
- // Ensure links to the same host originating from commentlink configs
- // open in the same tab. When target is not set - default is _self
- // @see Issue 4616
- this.outputElement.querySelectorAll('a').forEach(anchor => {
- if (anchor.hostname === window.location.hostname) {
- anchor.removeAttribute('target');
- } else {
- anchor.setAttribute('target', '_blank');
- }
- anchor.setAttribute('rel', 'noopener');
- });
-
- this.styleLinks();
- }
-
- /**
- * Styles the links based on whether gr-linked-text is disabled or not
- */
- private styleLinks() {
- assertIsDefined(this.outputElement);
- this.outputElement.querySelectorAll('a').forEach(anchor => {
- anchor.setAttribute('style', this.computeLinkStyle());
- });
- }
-
- private computeLinkStyle() {
- if (this.disabled) {
- return `
- color: inherit;
- text-decoration: none;
- pointer-events: none;
- `;
- } else {
- return 'color: var(--link-color)';
- }
- }
-
- /**
- * This method is called when the GrLikTextParser emits a partial result
- * (used as the "callback" parameter). It will be called in either of two
- * ways:
- * - To create a link: when called with `text` and `href` arguments, a link
- * element should be created and attached to the resulting DOM.
- * - To attach an arbitrary fragment: when called with only the `fragment`
- * argument, the fragment should be attached to the resulting DOM as is.
- */
- private handleParseResult(
- text: string | null,
- href: string | null,
- fragment?: DocumentFragment
- ) {
- assertIsDefined(this.outputElement);
- const output = this.outputElement;
- if (href) {
- const a = document.createElement('a');
- a.setAttribute('href', href);
- // GrLinkTextParser either pass text and href together or
- // only DocumentFragment - see LinkTextParserCallback
- a.textContent = text!;
- a.target = '_blank';
- a.setAttribute('rel', 'noopener');
- output.appendChild(a);
- } else if (fragment) {
- output.appendChild(fragment);
- }
- }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
deleted file mode 100644
index 00e0313..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
+++ /dev/null
@@ -1,471 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-linked-text';
-import {fixture, html, assert} from '@open-wc/testing';
-import {GrLinkedText} from './gr-linked-text';
-import {queryAndAssert} from '../../../test/test-utils';
-
-suite('gr-linked-text tests', () => {
- let element: GrLinkedText;
-
- let originalCanonicalPath: string | undefined;
-
- setup(async () => {
- originalCanonicalPath = window.CANONICAL_PATH;
- element = await fixture<GrLinkedText>(html`
- <gr-linked-text>
- <div id="output"></div>
- </gr-linked-text>
- `);
-
- element.config = {
- ph: {
- match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
- link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
- },
- prefixsameinlinkandpattern: {
- match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
- link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
- },
- changeid: {
- match: '(I[0-9a-f]{8,40})',
- link: '#/q/$1',
- },
- changeid2: {
- match: 'Change-Id: +(I[0-9a-f]{8,40})',
- link: '#/q/$1',
- },
- googlesearch: {
- match: 'google:(.+)',
- link: 'https://bing.com/search?q=$1', // html should supersede link.
- html: '<a href="https://google.com/search?q=$1">$1</a>',
- },
- hashedhtml: {
- match: 'hash:(.+)',
- html: '<a href="#/awesomesauce">$1</a>',
- },
- baseurl: {
- match: 'test (.+)',
- html: '<a href="/r/awesomesauce">$1</a>',
- },
- anotatstartwithbaseurl: {
- match: 'a test (.+)',
- html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
- },
- disabledconfig: {
- match: 'foo:(.+)',
- link: 'https://google.com/search?q=$1',
- enabled: false,
- },
- };
- });
-
- teardown(() => {
- window.CANONICAL_PATH = originalCanonicalPath;
- });
-
- test('render', async () => {
- element.content =
- 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
- await element.updateComplete;
- assert.lightDom.equal(
- element,
- /* HTML */ `
- <div id="output"></div>
- <span id="output" slot="insert">
- <a
- href="https://bugs.chromium.org/p/gerrit/issues/detail?id=3650"
- rel="noopener"
- style="color: var(--link-color)"
- target="_blank"
- >
- https://bugs.chromium.org/p/gerrit/issues/detail?id=3650
- </a>
- </span>
- `
- );
- });
-
- test('URL pattern was parsed and linked.', async () => {
- // Regular inline link.
- const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
- element.content = url;
- await element.updateComplete;
-
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- assert.equal(linkEl.target, '_blank');
- assert.equal(linkEl.rel, 'noopener');
- assert.equal(linkEl.href, url);
- assert.equal(linkEl.textContent, url);
- });
-
- test('Bug pattern was parsed and linked', async () => {
- // "Issue/Bug" pattern.
- element.content = 'Issue 3650';
- await element.updateComplete;
-
- let linkEl = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
- assert.equal(linkEl.target, '_blank');
- assert.equal(linkEl.href, url);
- assert.equal(linkEl.textContent, 'Issue 3650');
-
- element.content = 'Bug 3650';
- await element.updateComplete;
-
- linkEl = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- assert.equal(linkEl.target, '_blank');
- assert.equal(linkEl.rel, 'noopener');
- assert.equal(linkEl.href, url);
- assert.equal(linkEl.textContent, 'Bug 3650');
- });
-
- test('Pattern with same prefix as link was correctly parsed', async () => {
- // Pattern starts with the same prefix (`http`) as the url.
- element.content = 'httpexample 3650';
- await element.updateComplete;
-
- assert.equal(queryAndAssert(element, 'span#output').childNodes.length, 1);
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
- assert.equal(linkEl.target, '_blank');
- assert.equal(linkEl.href, url);
- assert.equal(linkEl.textContent, 'httpexample 3650');
- });
-
- test('Change-Id pattern was parsed and linked', async () => {
- // "Change-Id:" pattern.
- const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
- const prefix = 'Change-Id: ';
- element.content = prefix + changeID;
- await element.updateComplete;
-
- const textNode = queryAndAssert(element, 'span#output').childNodes[0];
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[1] as HTMLAnchorElement;
- assert.equal(textNode.textContent, prefix);
- const url = '/q/' + changeID;
- assert.isFalse(linkEl.hasAttribute('target'));
- // Since url is a path, the host is added automatically.
- assert.isTrue(linkEl.href.endsWith(url));
- assert.equal(linkEl.textContent, changeID);
- });
-
- test('Change-Id pattern was parsed and linked with base url', async () => {
- window.CANONICAL_PATH = '/r';
-
- // "Change-Id:" pattern.
- const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
- const prefix = 'Change-Id: ';
- element.content = prefix + changeID;
- await element.updateComplete;
-
- const textNode = queryAndAssert(element, 'span#output').childNodes[0];
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[1] as HTMLAnchorElement;
- assert.equal(textNode.textContent, prefix);
- const url = '/r/q/' + changeID;
- assert.isFalse(linkEl.hasAttribute('target'));
- // Since url is a path, the host is added automatically.
- assert.isTrue(linkEl.href.endsWith(url));
- assert.equal(linkEl.textContent, changeID);
- });
-
- test('Multiple matches', async () => {
- element.content = 'Issue 3650\nIssue 3450';
- await element.updateComplete;
-
- const linkEl1 = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- const linkEl2 = queryAndAssert(element, 'span#output')
- .childNodes[2] as HTMLAnchorElement;
-
- assert.equal(linkEl1.target, '_blank');
- assert.equal(
- linkEl1.href,
- 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
- );
- assert.equal(linkEl1.textContent, 'Issue 3650');
-
- assert.equal(linkEl2.target, '_blank');
- assert.equal(
- linkEl2.href,
- 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
- );
- assert.equal(linkEl2.textContent, 'Issue 3450');
- });
-
- test('Change-Id pattern parsed before bug pattern', async () => {
- // "Change-Id:" pattern.
- const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
- const prefix = 'Change-Id: ';
-
- // "Issue/Bug" pattern.
- const bug = 'Issue 3650';
-
- const changeUrl = '/q/' + changeID;
- const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-
- element.content = prefix + changeID + bug;
- await element.updateComplete;
-
- const textNode = queryAndAssert(element, 'span#output').childNodes[0];
- const changeLinkEl = queryAndAssert(element, 'span#output')
- .childNodes[1] as HTMLAnchorElement;
- const bugLinkEl = queryAndAssert(element, 'span#output')
- .childNodes[2] as HTMLAnchorElement;
-
- assert.equal(textNode.textContent, prefix);
-
- assert.isFalse(changeLinkEl.hasAttribute('target'));
- assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
- assert.equal(changeLinkEl.textContent, changeID);
-
- assert.equal(bugLinkEl.target, '_blank');
- assert.equal(bugLinkEl.href, bugUrl);
- assert.equal(bugLinkEl.textContent, 'Issue 3650');
- });
-
- test('html field in link config', async () => {
- element.content = 'google:do a barrel roll';
- await element.updateComplete;
-
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- assert.equal(
- linkEl.getAttribute('href'),
- 'https://google.com/search?q=do a barrel roll'
- );
- assert.equal(linkEl.textContent, 'do a barrel roll');
- });
-
- test('removing hash from links', async () => {
- element.content = 'hash:foo';
- await element.updateComplete;
-
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
- assert.equal(linkEl.textContent, 'foo');
- });
-
- test('html with base url', async () => {
- window.CANONICAL_PATH = '/r';
-
- element.content = 'test foo';
- await element.updateComplete;
-
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
- assert.equal(linkEl.textContent, 'foo');
- });
-
- test('a is not at start', async () => {
- window.CANONICAL_PATH = '/r';
-
- element.content = 'a test foo';
- await element.updateComplete;
-
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[1] as HTMLAnchorElement;
- assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
- assert.equal(linkEl.textContent, 'foo');
- });
-
- test('hash html with base url', async () => {
- window.CANONICAL_PATH = '/r';
-
- element.content = 'hash:foo';
- await element.updateComplete;
-
- const linkEl = queryAndAssert(element, 'span#output')
- .childNodes[0] as HTMLAnchorElement;
- assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
- assert.equal(linkEl.textContent, 'foo');
- });
-
- test('disabled config', async () => {
- element.content = 'foo:baz';
- await element.updateComplete;
-
- assert.equal(queryAndAssert(element, 'span#output').innerHTML, 'foo:baz');
- });
-
- test('R=email labels link correctly', async () => {
- element.removeZeroWidthSpace = true;
- element.content = 'R=\u200Btest@google.com';
- await element.updateComplete;
-
- assert.equal(
- queryAndAssert(element, 'span#output').textContent,
- 'R=test@google.com'
- );
- assert.equal(
- queryAndAssert(element, 'span#output').innerHTML.match(/(R=<a)/g)!.length,
- 1
- );
- });
-
- test('CC=email labels link correctly', async () => {
- element.removeZeroWidthSpace = true;
- element.content = 'CC=\u200Btest@google.com';
- await element.updateComplete;
-
- assert.equal(
- queryAndAssert(element, 'span#output').textContent,
- 'CC=test@google.com'
- );
- assert.equal(
- queryAndAssert(element, 'span#output').innerHTML.match(/(CC=<a)/g)!
- .length,
- 1
- );
- });
-
- test('only {http,https,mailto} protocols are linkified', async () => {
- element.content = 'xx mailto:test@google.com yy';
- await element.updateComplete;
-
- let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 1);
- assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
- assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
- element.content = 'xx http://google.com yy';
- await element.updateComplete;
-
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 1);
- assert.equal(links[0].getAttribute('href'), 'http://google.com');
- assert.equal(links[0].innerHTML, 'http://google.com');
-
- element.content = 'xx https://google.com yy';
- await element.updateComplete;
-
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 1);
- assert.equal(links[0].getAttribute('href'), 'https://google.com');
- assert.equal(links[0].innerHTML, 'https://google.com');
-
- element.content = 'xx ssh://google.com yy';
- await element.updateComplete;
-
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 0);
-
- element.content = 'xx ftp://google.com yy';
- await element.updateComplete;
-
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 0);
- });
-
- test('links without leading whitespace are linkified', async () => {
- element.content = 'xx abcmailto:test@google.com yy';
- await element.updateComplete;
-
- assert.equal(
- queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
- 'xx abc'
- );
- let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 1);
- assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
- assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
- element.content = 'xx defhttp://google.com yy';
- await element.updateComplete;
-
- assert.equal(
- queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
- 'xx def'
- );
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 1);
- assert.equal(links[0].getAttribute('href'), 'http://google.com');
- assert.equal(links[0].innerHTML, 'http://google.com');
-
- element.content = 'xx qwehttps://google.com yy';
- await element.updateComplete;
-
- assert.equal(
- queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
- 'xx qwe'
- );
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 1);
- assert.equal(links[0].getAttribute('href'), 'https://google.com');
- assert.equal(links[0].innerHTML, 'https://google.com');
-
- // Non-latin character
- element.content = 'xx абвhttps://google.com yy';
- await element.updateComplete;
-
- assert.equal(
- queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
- 'xx абв'
- );
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 1);
- assert.equal(links[0].getAttribute('href'), 'https://google.com');
- assert.equal(links[0].innerHTML, 'https://google.com');
-
- element.content = 'xx ssh://google.com yy';
- await element.updateComplete;
-
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 0);
-
- element.content = 'xx ftp://google.com yy';
- await element.updateComplete;
-
- links = queryAndAssert(element, 'span#output').querySelectorAll('a');
- assert.equal(links.length, 0);
- });
-
- test('overlapping links', async () => {
- element.config = {
- b1: {
- match: '(B:\\s*)(\\d+)',
- html: '$1<a href="ftp://foo/$2">$2</a>',
- },
- b2: {
- match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
- html: '$1<a href="ftp://foo/$2">$2</a>',
- },
- };
- element.content = '- B: 123, 45';
- await element.updateComplete;
-
- const links = element.querySelectorAll('a');
-
- assert.equal(links.length, 2);
- assert.equal(
- queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
- '- B: 123, 45'
- );
-
- assert.equal(links[0].href, 'ftp://foo/123');
- assert.equal(links[0].textContent, '123');
-
- assert.equal(links[1].href, 'ftp://foo/45');
- assert.equal(links[1].textContent, '45');
- });
-
- test('_contentOrConfigChanged called with config', async () => {
- const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
- element.content = 'some text';
- await element.updateComplete;
-
- assert.isTrue(contentConfigStub.called);
- });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
deleted file mode 100644
index 73cf58b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ /dev/null
@@ -1,415 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import 'ba-linkify/ba-linkify';
-import {getBaseUrl} from '../../../utils/url-util';
-import {CommentLinkInfo} from '../../../types/common';
-
-/**
- * Pattern describing URLs with supported protocols.
- */
-const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
-
-export type LinkTextParserCallback = ((text: string, href: string) => void) &
- ((text: null, href: null, fragment: DocumentFragment) => void);
-
-export interface CommentLinkItem {
- position: number;
- length: number;
- html: HTMLAnchorElement | DocumentFragment;
-}
-
-export type LinkTextParserConfig = {[name: string]: CommentLinkInfo};
-
-export class GrLinkTextParser {
- private readonly baseUrl = getBaseUrl();
-
- /**
- * Construct a parser for linkifying text. Will linkify plain URLs that appear
- * in the text as well as custom links if any are specified in the linkConfig
- * parameter.
- *
- * @param linkConfig Comment links as specified by the commentlinks field on a
- * project config.
- * @param callback The callback to be fired when an intermediate parse result
- * is emitted. The callback is passed text and href strings if a link is to
- * be created, or a document fragment otherwise.
- * @param removeZeroWidthSpace If true, zero-width spaces will be removed from
- * R=<email> and CC=<email> expressions.
- */
- constructor(
- private readonly linkConfig: LinkTextParserConfig,
- private readonly callback: LinkTextParserCallback,
- private readonly removeZeroWidthSpace?: boolean
- ) {
- Object.preventExtensions(this);
- }
-
- /**
- * Emit a callback to create a link element.
- *
- * @param text The text of the link.
- * @param href The URL to use as the href of the link.
- */
- addText(text: string, href: string) {
- if (!text) {
- return;
- }
- this.callback(text, href);
- }
-
- /**
- * Given the source text and a list of CommentLinkItem objects that were
- * generated by the commentlinks config, emit parsing callbacks.
- *
- * @param text The chuml of source text over which the outputArray items range.
- * @param outputArray The list of items to add resulting from commentlink
- * matches.
- */
- processLinks(text: string, outputArray: CommentLinkItem[]) {
- this.sortArrayReverse(outputArray);
- const fragment = document.createDocumentFragment();
- let cursor = text.length;
-
- // Start inserting linkified URLs from the end of the String. That way, the
- // string positions of the items don't change as we iterate through.
- outputArray.forEach(item => {
- // Add any text between the current linkified item and the item added
- // before if it exists.
- if (item.position + item.length !== cursor) {
- fragment.insertBefore(
- document.createTextNode(
- text.slice(item.position + item.length, cursor)
- ),
- fragment.firstChild
- );
- }
- fragment.insertBefore(item.html, fragment.firstChild);
- cursor = item.position;
- });
-
- // Add the beginning portion at the end.
- if (cursor !== 0) {
- fragment.insertBefore(
- document.createTextNode(text.slice(0, cursor)),
- fragment.firstChild
- );
- }
-
- this.callback(null, null, fragment);
- }
-
- /**
- * Sort the given array of CommentLinkItems such that the positions are in
- * reverse order.
- */
- sortArrayReverse(outputArray: CommentLinkItem[]) {
- outputArray.sort((a, b) => b.position - a.position);
- }
-
- addItem(
- text: string,
- href: string,
- html: null,
- position: number,
- length: number,
- outputArray: CommentLinkItem[]
- ): void;
-
- addItem(
- text: null,
- href: null,
- html: string,
- position: number,
- length: number,
- outputArray: CommentLinkItem[]
- ): void;
-
- /**
- * Create a CommentLinkItem and append it to the given output array. This
- * method can be called in either of two ways:
- * - With `text` and `href` parameters provided, and the `html` parameter
- * passed as `null`. In this case, the new CommentLinkItem will be a link
- * element with the given text and href value.
- * - With the `html` paremeter provided, and the `text` and `href` parameters
- * passed as `null`. In this case, the string of HTML will be parsed and the
- * first resulting node will be used as the resulting content.
- *
- * @param text The text to use if creating a link.
- * @param href The href to use as the URL if creating a link.
- * @param html The html to parse and use as the result.
- * @param position The position inside the source text where the item
- * starts.
- * @param length The number of characters in the source text
- * represented by the item.
- * @param outputArray The array to which the
- * new item is to be appended.
- */
- addItem(
- text: string | null,
- href: string | null,
- html: string | null,
- position: number,
- length: number,
- outputArray: CommentLinkItem[]
- ): void {
- if (href) {
- const a = document.createElement('a');
- a.setAttribute('href', href);
- a.textContent = text;
- a.target = '_blank';
- a.rel = 'noopener';
- outputArray.push({
- html: a,
- position,
- length,
- });
- } else if (html) {
- // addItem has 2 overloads. If href is null, then html
- // can't be null.
- // TODO(TS): remove if(html) and keep else block without condition
- const fragment = document.createDocumentFragment();
- // Create temporary div to hold the nodes in.
- const div = document.createElement('div');
- div.innerHTML = html;
- while (div.firstChild) {
- fragment.appendChild(div.firstChild);
- }
- outputArray.push({
- html: fragment,
- position,
- length,
- });
- }
- }
-
- /**
- * Create a CommentLinkItem for a link and append it to the given output
- * array.
- *
- * @param text The text for the link.
- * @param href The href to use as the URL of the link.
- * @param position The position inside the source text where the link
- * starts.
- * @param length The number of characters in the source text
- * represented by the link.
- * @param outputArray The array to which the
- * new item is to be appended.
- */
- addLink(
- text: string,
- href: string,
- position: number,
- length: number,
- outputArray: CommentLinkItem[]
- ) {
- // TODO(TS): remove !test condition
- if (!text || this.hasOverlap(position, length, outputArray)) {
- return;
- }
- if (
- !!this.baseUrl &&
- href.startsWith('/') &&
- !href.startsWith(this.baseUrl)
- ) {
- href = this.baseUrl + href;
- }
- this.addItem(text, href, null, position, length, outputArray);
- }
-
- /**
- * Create a CommentLinkItem specified by an HTMl string and append it to the
- * given output array.
- *
- * @param html The html to parse and use as the result.
- * @param position The position inside the source text where the item
- * starts.
- * @param length The number of characters in the source text
- * represented by the item.
- * @param outputArray The array to which the
- * new item is to be appended.
- */
- addHTML(
- html: string,
- position: number,
- length: number,
- outputArray: CommentLinkItem[]
- ) {
- if (this.hasOverlap(position, length, outputArray)) {
- return;
- }
- if (
- !!this.baseUrl &&
- html.match(/<a href="\//g) &&
- !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)
- ) {
- html = html.replace(/<a href="\//g, `<a href="${this.baseUrl}/`);
- }
- this.addItem(null, null, html, position, length, outputArray);
- }
-
- /**
- * Does the given range overlap with anything already in the item list.
- */
- hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
- const endPosition = position + length;
- for (let i = 0; i < outputArray.length; i++) {
- const arrayItemStart = outputArray[i].position;
- const arrayItemEnd = outputArray[i].position + outputArray[i].length;
- if (
- (position >= arrayItemStart && position < arrayItemEnd) ||
- (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
- (position === arrayItemStart && position === arrayItemEnd)
- ) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Parse the given source text and emit callbacks for the items that are
- * parsed.
- */
- parse(text?: string | null) {
- if (text) {
- window.linkify(text, {
- callback: (text: string, href?: string) => this.parseChunk(text, href),
- });
- }
- }
-
- /**
- * Callback that is pased into the linkify function. ba-linkify will call this
- * method in either of two ways:
- * - With both a `text` and `href` parameter provided: this indicates that
- * ba-linkify has found a plain URL and wants it linkified.
- * - With only a `text` parameter provided: this represents the non-link
- * content that lies between the links the library has found.
- *
- */
- parseChunk(text: string, href?: string) {
- // TODO(wyatta) switch linkify sequence, see issue 5526.
- if (this.removeZeroWidthSpace) {
- // Remove the zero-width space added in gr-change-view.
- text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
- }
-
- // If the href is provided then ba-linkify has recognized it as a URL. If
- // the source text does not include a protocol, the protocol will be added
- // by ba-linkify. Create the link if the href is provided and its protocol
- // matches the expected pattern.
- if (href) {
- const result = URL_PROTOCOL_PATTERN.exec(href);
- if (result) {
- const prefixText = result[1];
- if (prefixText.length > 0) {
- // Fix for simple cases from
- // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
- // When leading whitespace is missed before link,
- // linkify add this text before link as a schema name to href.
- // We suppose, that prefixText just a single word
- // before link and add this word as is, without processing
- // any patterns in it.
- this.parseLinks(prefixText, {});
- text = text.substring(prefixText.length);
- href = href.substring(prefixText.length);
- }
- this.addText(text, href);
- return;
- }
- }
- // For the sections of text that lie between the links found by
- // ba-linkify, we search for the project-config-specified link patterns.
- this.parseLinks(text, this.linkConfig);
- }
-
- /**
- * Walk over the given source text to find matches for comemntlink patterns
- * and emit parse result callbacks.
- *
- * @param text The raw source text.
- * @param config A comment links specification object.
- */
- parseLinks(text: string, config: LinkTextParserConfig) {
- // The outputArray is used to store all of the matches found for all
- // patterns.
- const outputArray: CommentLinkItem[] = [];
- for (const [configName, linkInfo] of Object.entries(config)) {
- // TODO(TS): it seems, the following line can be rewritten as:
- // if(enabled === false || enabled === 0 || enabled === '')
- // Should be double-checked before update
- // eslint-disable-next-line eqeqeq
- if (linkInfo.enabled != null && linkInfo.enabled == false) {
- continue;
- }
- // PolyGerrit doesn't use hash-based navigation like the GWT UI.
- // Account for this.
- const html = linkInfo.html;
- const link = linkInfo.link;
- if (html) {
- linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
- } else if (link) {
- if (link[0] === '#') {
- linkInfo.link = link.substr(1);
- }
- }
-
- const pattern = new RegExp(linkInfo.match, 'g');
-
- let match;
- let textToCheck = text;
- let susbtrIndex = 0;
-
- while ((match = pattern.exec(textToCheck))) {
- textToCheck = textToCheck.substr(match.index + match[0].length);
- let result = match[0].replace(
- pattern,
- // Either html or link has a value. Otherwise an exception is thrown
- // in the code below.
- (linkInfo.html || linkInfo.link)!
- );
-
- if (linkInfo.html) {
- let i;
- // Skip portion of replacement string that is equal to original to
- // allow overlapping patterns.
- for (i = 0; i < result.length; i++) {
- if (result[i] !== match[0][i]) {
- break;
- }
- }
- result = result.slice(i);
-
- this.addHTML(
- result,
- susbtrIndex + match.index + i,
- match[0].length - i,
- outputArray
- );
- } else if (linkInfo.link) {
- this.addLink(
- match[0],
- result,
- susbtrIndex + match.index,
- match[0].length,
- outputArray
- );
- } else {
- throw Error(
- 'linkconfig entry ' +
- configName +
- ' doesn’t contain a link or html attribute.'
- );
- }
-
- // Update the substring location so we know where we are in relation to
- // the initial full text string.
- susbtrIndex = susbtrIndex + match.index + match[0].length;
- }
- }
- this.processLinks(text, outputArray);
- }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index fe4632b..8a92bcc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -13,6 +13,7 @@
import '@polymer/paper-listbox/paper-listbox';
import './gr-overview-image';
import './gr-zoomed-image';
+import '../../../elements/shared/gr-icons/gr-icons';
import {GrLibLoader} from '../../../elements/shared/gr-lib-loader/gr-lib-loader';
import {RESEMBLEJS_LIBRARY_CONFIG} from '../../../elements/shared/gr-lib-loader/resemblejs_config';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index f0697df..53c2780 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -5,7 +5,6 @@
*/
import '../../../styles/shared-styles';
import '../../../elements/shared/gr-button/gr-button';
-import '../../../elements/shared/gr-icons/gr-icons';
import '../../../elements/shared/gr-icon/gr-icon';
import '../gr-diff-builder/gr-diff-builder-element';
import '../gr-diff-highlight/gr-diff-highlight';
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
index 9b69b24..1592cd8 100644
--- a/polygerrit-ui/app/models/browser/browser-model.ts
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -4,12 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Observable, combineLatest} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
import {Finalizable} from '../../services/registry';
import {define} from '../dependency';
import {DiffViewMode} from '../../api/diff';
import {UserModel} from '../user/user-model';
import {Model} from '../model';
+import {select} from '../../utils/observable-util';
// This value is somewhat arbitrary and not based on research or calculations.
const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
@@ -27,30 +27,24 @@
export const browserModelToken = define<BrowserModel>('browser-model');
export class BrowserModel extends Model<BrowserState> implements Finalizable {
- readonly diffViewMode$: Observable<DiffViewMode>;
+ private readonly isScreenTooSmall$ = select(
+ this.state$,
+ state =>
+ !!state.screenWidth &&
+ state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX
+ );
+
+ readonly diffViewMode$: Observable<DiffViewMode> = select(
+ combineLatest([
+ this.isScreenTooSmall$,
+ this.userModel.preferenceDiffViewMode$,
+ ]),
+ ([isScreenTooSmall, preferenceDiffViewMode]) =>
+ isScreenTooSmall ? DiffViewMode.UNIFIED : preferenceDiffViewMode
+ );
constructor(readonly userModel: UserModel) {
super(initialState);
- const screenWidth$ = this.state$.pipe(
- map(
- state =>
- !!state.screenWidth &&
- state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX
- ),
- distinctUntilChanged()
- );
- // TODO; Inject the UserModel once preferenceDiffViewMode$ has moved to
- // the user model.
- this.diffViewMode$ = combineLatest([
- screenWidth$,
- userModel.preferenceDiffViewMode$,
- ]).pipe(
- map(([isScreenTooSmall, preferenceDiffViewMode]) => {
- if (isScreenTooSmall) return DiffViewMode.UNIFIED;
- else return preferenceDiffViewMode;
- }),
- distinctUntilChanged()
- );
}
/* Observe the screen width so that the app can react to changes to it */
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 5cfe670..35a08c6 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -14,20 +14,11 @@
RevisionPatchSetNum,
} from '../../types/common';
import {DefaultBase} from '../../constants/constants';
-import {
- combineLatest,
- from,
- fromEvent,
- Observable,
- Subscription,
- forkJoin,
- of,
-} from 'rxjs';
+import {combineLatest, from, fromEvent, Observable, forkJoin, of} from 'rxjs';
import {
map,
filter,
withLatestFrom,
- distinctUntilChanged,
startWith,
switchMap,
} from 'rxjs/operators';
@@ -234,35 +225,33 @@
* out inconsistent state, e.g. router changeNum already updated, change not
* yet reset to undefined.
*/
- combineLatest([this.routerModel.state$, this.state$, this.userModel.state$])
- .pipe(
+ select(
+ combineLatest([
+ this.routerModel.state$,
+ this.state$,
+ this.userModel.state$,
+ ]).pipe(
filter(([routerState, changeState, _]) => {
const changeNum = changeState.change?._number;
const routerChangeNum = routerState.changeNum;
return changeNum === undefined || changeNum === routerChangeNum;
}),
- distinctUntilChanged()
- )
- .pipe(
withLatestFrom(
this.routerModel.routerBasePatchNum$,
this.patchNum$,
this.change$,
this.userModel.preferences$
- ),
- map(([_, routerBasePatchNum, patchNum, change, preferences]) =>
- computeBase(routerBasePatchNum, patchNum, change, preferences)
- ),
- distinctUntilChanged()
- );
+ )
+ ),
+ ([_, routerBasePatchNum, patchNum, change, preferences]) =>
+ computeBase(routerBasePatchNum, patchNum, change, preferences)
+ );
public readonly isOwner$: Observable<boolean> = select(
combineLatest([this.change$, this.userModel.account$]),
([change, account]) => isOwner(change, account)
);
- private subscriptions: Subscription[] = [];
-
// For usage in `combineLatest` we need `startWith` such that reload$ has an
// initial value.
readonly reload$: Observable<unknown> = fromEvent(document, 'reload').pipe(
@@ -316,13 +305,6 @@
];
}
- override finalize() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions = [];
- }
-
// Temporary workaround until path is derived in the model itself.
updatePath(diffPath?: string) {
this.updateState({diffPath});
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index 0ee508a..fdf9e04 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -22,6 +22,7 @@
CommitId,
EDIT,
NumericChangeId,
+ PARENT,
PatchSetNum,
PatchSetNumber,
} from '../../types/common';
@@ -63,7 +64,7 @@
});
});
-suite('change service tests', () => {
+suite('change model tests', () => {
let changeModel: ChangeModel;
let knownChange: ParsedChangeInfo;
const testCompleted = new Subject<void>();
@@ -279,4 +280,25 @@
message: 'blah blah',
});
});
+
+ // At some point we had forgotten the `select()` wrapper for this selector.
+ // And the missing `replay` led to a bug that was hard to find. That is why
+ // we are testing this explicitly here.
+ test('basePatchNum$ selector', async () => {
+ const spy = sinon.spy();
+ changeModel.basePatchNum$.subscribe(spy);
+
+ // test replay
+ assert.equal(spy.callCount, 1);
+ assert.equal(spy.lastCall.firstArg, PARENT);
+
+ // test update
+ changeModel.routerModel.updateState({basePatchNum: 1 as PatchSetNumber});
+ assert.equal(spy.callCount, 2);
+ assert.equal(spy.lastCall.firstArg, 1 as PatchSetNumber);
+
+ // test distinctUntilChanged
+ changeModel.updateStateChange(createParsedChange());
+ assert.equal(spy.callCount, 2);
+ });
});
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 31e42a6..6922f6d 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -12,7 +12,7 @@
PatchSetNumber,
RevisionPatchSetNum,
} from '../../types/common';
-import {combineLatest, Subscription, of, from} from 'rxjs';
+import {combineLatest, of, from} from 'rxjs';
import {switchMap, map} from 'rxjs/operators';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {Finalizable} from '../../services/registry';
@@ -131,8 +131,6 @@
state => state.filesRightBase
);
- private subscriptions: Subscription[] = [];
-
constructor(
readonly changeModel: ChangeModel,
readonly commentsModel: CommentsModel,
@@ -198,11 +196,4 @@
this.updateState(state);
});
}
-
- override finalize() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions = [];
- }
}
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 2b92529..9c4c45c 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -20,7 +20,6 @@
Observable,
of,
Subject,
- Subscription,
timer,
} from 'rxjs';
import {
@@ -151,7 +150,12 @@
};
}
-const FETCH_RESULT_TIMEOUT_MS = 10000;
+/**
+ * Android's Checks Plugin has a 15s timeout internally. So we are using
+ * something slightly larger, so that we get a proper error from the plugin,
+ * if they run into timeout issues.
+ */
+const FETCH_RESULT_TIMEOUT_MS = 16000;
/**
* Can be used in `reduce()` to collect all results from all runs from all
@@ -185,9 +189,11 @@
private checkToPluginMap = new Map<string, string>();
- private changeNum?: NumericChangeId;
+ // visible for testing
+ changeNum?: NumericChangeId;
- private latestPatchNum?: PatchSetNumber;
+ // visible for testing
+ latestPatchNum?: PatchSetNumber;
private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
@@ -195,9 +201,6 @@
private readonly visibilityChangeListener: () => void;
- private subscriptions: Subscription[] = [];
-
- // TODO: Maybe consider migrating usages to `changeViewModel` directly.
public checksSelectedPatchsetNumber$ = select(
this.changeViewModel.checksPatchset$,
ps => ps
@@ -384,6 +387,9 @@
this.reporting.time(Timing.CHECKS_LOAD);
this.subscriptions = [
this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
+ this.changeModel.latestPatchNum$.subscribe(
+ x => (this.latestPatchNum = x)
+ ),
this.pluginsModel.checksPlugins$.subscribe(plugins => {
for (const plugin of plugins) {
this.register(plugin);
@@ -461,10 +467,7 @@
'visibilitychange',
this.visibilityChangeListener
);
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions = [];
+ super.finalize();
}
// Must only be used by the checks service or whatever is in control of this
@@ -750,8 +753,10 @@
patchset === ChecksPatchset.LATEST
? this.changeModel.latestPatchNum$
: this.checksSelectedPatchsetNumber$,
- this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
- timer(0, pollIntervalMs),
+ this.reloadSubjects[pluginName].pipe(
+ throttleTime(1000, undefined, {trailing: true, leading: true})
+ ),
+ pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
this.documentVisibilityChange$,
])
.pipe(
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 83ed464..3489c5a 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -7,6 +7,7 @@
import './checks-model';
import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
import {
+ Action,
Category,
CheckRun,
ChecksApiConfig,
@@ -22,6 +23,7 @@
import {assert} from '@open-wc/testing';
import {testResolver} from '../../test/common-test-setup';
import {changeViewModelToken} from '../views/change';
+import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
const PLUGIN_NAME = 'test-plugin';
@@ -42,8 +44,12 @@
},
];
-const CONFIG: ChecksApiConfig = {
- fetchPollingIntervalSeconds: 1000,
+const CONFIG_POLLING_5S: ChecksApiConfig = {
+ fetchPollingIntervalSeconds: 5,
+};
+
+const CONFIG_POLLING_NONE: ChecksApiConfig = {
+ fetchPollingIntervalSeconds: 0,
};
function createProvider(): ChecksProvider {
@@ -82,13 +88,67 @@
const provider = createProvider();
const fetchSpy = sinon.spy(provider, 'fetch');
- model.register({pluginName: 'test-plugin', provider, config: CONFIG});
+ model.register({
+ pluginName: 'test-plugin',
+ provider,
+ config: CONFIG_POLLING_NONE,
+ });
await waitUntil(() => change === undefined);
const testChange = createParsedChange();
model.changeModel.updateStateChange(testChange);
await waitUntil(() => change === testChange);
await waitUntilCalled(fetchSpy, 'fetch');
+
+ assert.equal(
+ model.latestPatchNum,
+ testChange.revisions[testChange.current_revision]
+ ._number as PatchSetNumber
+ );
+ assert.equal(model.changeNum, testChange._number);
+ });
+
+ test('reload throttle', async () => {
+ const clock = sinon.useFakeTimers();
+ let change: ParsedChangeInfo | undefined = undefined;
+ model.changeModel.change$.subscribe(c => (change = c));
+ const provider = createProvider();
+ const fetchSpy = sinon.spy(provider, 'fetch');
+
+ model.register({
+ pluginName: 'test-plugin',
+ provider,
+ config: CONFIG_POLLING_NONE,
+ });
+ await waitUntil(() => change === undefined);
+
+ const testChange = createParsedChange();
+ model.changeModel.updateStateChange(testChange);
+ await waitUntil(() => change === testChange);
+ clock.tick(1);
+ assert.equal(fetchSpy.callCount, 1);
+
+ // The second reload call will be processed, but only after a 1s throttle.
+ model.reload('test-plugin');
+ clock.tick(100);
+ assert.equal(fetchSpy.callCount, 1);
+ // 2000 ms is greater than the 1000 ms throttle time.
+ clock.tick(2000);
+ assert.equal(fetchSpy.callCount, 2);
+ });
+
+ test('triggerAction', async () => {
+ model.changeNum = 314 as NumericChangeId;
+ model.latestPatchNum = 13 as PatchSetNumber;
+ const action: Action = {
+ name: 'test action',
+ callback: () => undefined,
+ };
+ const spy = sinon.spy(action, 'callback');
+ model.triggerAction(action, undefined, 'none');
+ assert.isTrue(spy.calledOnce);
+ assert.equal(spy.lastCall.args[0], 314);
+ assert.equal(spy.lastCall.args[1], 13);
});
test('model.updateStateSetProvider', () => {
@@ -204,4 +264,58 @@
assert.lengthOf(current.runs[0].results!, 1);
assert.equal(current.runs[0].results![0].summary, 'new');
});
+
+ test('polls for changes', async () => {
+ const clock = sinon.useFakeTimers();
+ let change: ParsedChangeInfo | undefined = undefined;
+ model.changeModel.change$.subscribe(c => (change = c));
+ const provider = createProvider();
+ const fetchSpy = sinon.spy(provider, 'fetch');
+
+ model.register({
+ pluginName: 'test-plugin',
+ provider,
+ config: CONFIG_POLLING_5S,
+ });
+ await waitUntil(() => change === undefined);
+ clock.tick(1);
+ const testChange = createParsedChange();
+ model.changeModel.updateStateChange(testChange);
+ await waitUntil(() => change === testChange);
+ await waitUntilCalled(fetchSpy, 'fetch');
+ clock.tick(1);
+ const pollCount = fetchSpy.callCount;
+
+ // polling should continue while we wait
+ clock.tick(CONFIG_POLLING_5S.fetchPollingIntervalSeconds * 1000 * 2);
+
+ assert.isTrue(fetchSpy.callCount > pollCount);
+ });
+
+ test('does not poll when config specifies 0 seconds', async () => {
+ const clock = sinon.useFakeTimers();
+ let change: ParsedChangeInfo | undefined = undefined;
+ model.changeModel.change$.subscribe(c => (change = c));
+ const provider = createProvider();
+ const fetchSpy = sinon.spy(provider, 'fetch');
+
+ model.register({
+ pluginName: 'test-plugin',
+ provider,
+ config: CONFIG_POLLING_NONE,
+ });
+ await waitUntil(() => change === undefined);
+ clock.tick(1);
+ const testChange = createParsedChange();
+ model.changeModel.updateStateChange(testChange);
+ await waitUntil(() => change === testChange);
+ await waitUntilCalled(fetchSpy, 'fetch');
+ clock.tick(1);
+ const pollCount = fetchSpy.callCount;
+
+ // polling should not happen
+ clock.tick(60 * 1000);
+
+ assert.equal(fetchSpy.callCount, pollCount);
+ });
});
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 09c9273..7ccdf91 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -8,12 +8,16 @@
Category,
CheckResult as CheckResultApi,
CheckRun as CheckRunApi,
+ Fix,
Link,
LinkIcon,
+ Replacement,
RunStatus,
} from '../../api/checks';
import {PatchSetNumber} from '../../api/rest-api';
+import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
import {OpenFixPreviewEventDetail} from '../../types/events';
+import {notUndefined} from '../../types/types';
import {PROVIDED_FIX_ID} from '../../utils/comment-util';
import {assert, assertNever} from '../../utils/common-util';
import {fire} from '../../utils/event-util';
@@ -86,17 +90,15 @@
target: EventTarget,
result?: RunResult
): Action | undefined {
- const fixes = result?.fixes;
- if (!fixes || fixes?.length === 0 || !result?.patchset) return;
+ if (!result?.patchset) return;
+ if (!result?.fixes) return;
+ const fixSuggestions = result.fixes
+ .map(f => rectifyFix(f, result?.checkName))
+ .filter(notUndefined);
+ if (fixSuggestions.length === 0) return;
const eventDetail: OpenFixPreviewEventDetail = {
- patchNum: result?.patchset as PatchSetNumber,
- fixSuggestions: fixes.map(fix => {
- return {
- description: `Fix provided by ${result?.checkName}`,
- fix_id: PROVIDED_FIX_ID,
- ...fix,
- };
- }),
+ patchNum: result.patchset as PatchSetNumber,
+ fixSuggestions,
};
return {
name: 'Show Fix',
@@ -107,6 +109,36 @@
};
}
+export function rectifyFix(
+ fix: Fix | undefined,
+ checkName: string
+): FixSuggestionInfo | undefined {
+ if (!fix?.replacements) return undefined;
+ const replacements = fix.replacements
+ .map(rectifyReplacement)
+ .filter(notUndefined);
+ if (replacements.length === 0) return undefined;
+
+ return {
+ description: fix.description ?? `Fix provided by ${checkName}`,
+ fix_id: PROVIDED_FIX_ID,
+ replacements,
+ };
+}
+
+export function rectifyReplacement(
+ r: Replacement | undefined
+): FixReplacementInfo | undefined {
+ if (!r?.path) return undefined;
+ if (!r?.range) return undefined;
+ if (r?.replacement === undefined) return undefined;
+ if (!Number.isInteger(r.range.start_line)) return undefined;
+ if (!Number.isInteger(r.range.end_line)) return undefined;
+ if (!Number.isInteger(r.range.start_character)) return undefined;
+ if (!Number.isInteger(r.range.end_character)) return undefined;
+ return r;
+}
+
export function worstCategory(run: CheckRun) {
if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index 5a7bd75..c237c59 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -10,9 +10,13 @@
ALL_ATTEMPTS,
AttemptChoice,
LATEST_ATTEMPT,
+ rectifyFix,
sortAttemptChoices,
stringToAttemptChoice,
} from './checks-util';
+import {Fix, Replacement} from '../../api/checks';
+import {CommentRange} from '../../api/core';
+import {PROVIDED_FIX_ID} from '../../utils/comment-util';
suite('checks-util tests', () => {
setup(() => {});
@@ -33,6 +37,55 @@
assert.equal(stringToAttemptChoice('1x'), undefined);
});
+ test('rectifyFix', () => {
+ assert.isUndefined(rectifyFix(undefined, 'name'));
+ assert.isUndefined(rectifyFix({} as Fix, 'name'));
+ assert.isUndefined(
+ rectifyFix({description: 'asdf', replacements: []}, 'name')
+ );
+ assert.isUndefined(
+ rectifyFix(
+ {description: 'asdf', replacements: [{} as Replacement]},
+ 'test-check-name'
+ )
+ );
+ assert.isUndefined(
+ rectifyFix(
+ {
+ description: 'asdf',
+ replacements: [
+ {
+ path: 'test-path',
+ range: {} as CommentRange,
+ replacement: 'test-replacement-string',
+ },
+ ],
+ },
+ 'test-check-name'
+ )
+ );
+ const rectified = rectifyFix(
+ {
+ replacements: [
+ {
+ path: 'test-path',
+ range: {
+ start_line: 1,
+ end_line: 1,
+ start_character: 0,
+ end_character: 1,
+ } as CommentRange,
+ replacement: 'test-replacement-string',
+ },
+ ],
+ },
+ 'test-check-name'
+ );
+ assert.isDefined(rectified);
+ assert.equal(rectified?.description, 'Fix provided by test-check-name');
+ assert.equal(rectified?.fix_id, PROVIDED_FIX_ID);
+ });
+
test('sortAttemptChoices', () => {
const unsorted: (AttemptChoice | undefined)[] = [
3,
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index 39d9eaa..8ed4b69 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -30,7 +30,7 @@
import {RouterModel} from '../../services/router/router-model';
import {Finalizable} from '../../services/registry';
import {define} from '../dependency';
-import {combineLatest, forkJoin, from, Observable, Subscription} from 'rxjs';
+import {combineLatest, forkJoin, from, Observable} from 'rxjs';
import {fire, fireAlert, fireEvent} from '../../utils/event-util';
import {CURRENT} from '../../utils/patch-set-util';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
@@ -370,8 +370,6 @@
private readonly reloadListener: () => void;
- private readonly subscriptions: Subscription[] = [];
-
private drafts: {[path: string]: DraftInfo[]} = {};
private draftToastTask?: DelayedTask;
@@ -421,10 +419,7 @@
override finalize() {
document.removeEventListener('reload', this.reloadListener);
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions.splice(0, this.subscriptions.length);
+ super.finalize();
}
// Note that this does *not* reload ported comments.
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index c4bd637..6e374d1 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
-import {from, of, Subscription} from 'rxjs';
+import {from, of} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Finalizable} from '../../services/registry';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
@@ -50,8 +50,6 @@
url => url
);
- private subscriptions: Subscription[];
-
constructor(
readonly changeModel: ChangeModel,
readonly restApiService: RestApiService
@@ -83,11 +81,4 @@
updateServerConfig(serverConfig?: ServerInfo) {
this.updateState({serverConfig});
}
-
- override finalize() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions = [];
- }
}
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
index 2e90a5e..19b52fc 100644
--- a/polygerrit-ui/app/models/model.ts
+++ b/polygerrit-ui/app/models/model.ts
@@ -3,7 +3,7 @@
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {BehaviorSubject, Observable} from 'rxjs';
+import {BehaviorSubject, Observable, Subscription} from 'rxjs';
import {Finalizable} from '../services/registry';
import {deepEqual} from '../utils/deep-util';
@@ -22,10 +22,19 @@
* Any new subscriber will immediately receive the current value.
*/
export abstract class Model<T> implements Finalizable {
+ /**
+ * rxjs does not like `next()` being called on a subject during processing of
+ * another `next()` call. So make sure that state updates complete before
+ * starting another one.
+ */
+ private stateUpdateInProgress = false;
+
private subject$: BehaviorSubject<T>;
public state$: Observable<T>;
+ protected subscriptions: Subscription[] = [];
+
constructor(initialState: T) {
this.subject$ = new BehaviorSubject(initialState);
this.state$ = this.subject$.asObservable();
@@ -36,15 +45,32 @@
}
setState(state: T) {
+ if (this.stateUpdateInProgress) {
+ setTimeout(() => this.setState(state));
+ return;
+ }
if (deepEqual(state, this.getState())) return;
- this.subject$.next(state);
+ try {
+ this.stateUpdateInProgress = true;
+ this.subject$.next(state);
+ } finally {
+ this.stateUpdateInProgress = false;
+ }
}
updateState(state: Partial<T>) {
+ if (this.stateUpdateInProgress) {
+ setTimeout(() => this.updateState(state));
+ return;
+ }
this.setState({...this.getState(), ...state});
}
finalize() {
this.subject$.complete();
+ for (const s of this.subscriptions) {
+ s.unsubscribe();
+ }
+ this.subscriptions = [];
}
}
diff --git a/polygerrit-ui/app/models/model_test.ts b/polygerrit-ui/app/models/model_test.ts
new file mode 100644
index 0000000..3fa88e7
--- /dev/null
+++ b/polygerrit-ui/app/models/model_test.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert, waitUntil} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {Model} from './model';
+
+interface TestModelState {
+ prop1?: string;
+ prop2?: string;
+ prop3?: string;
+}
+
+export class TestModel extends Model<TestModelState> {
+ constructor() {
+ super({});
+ }
+}
+
+suite('model tests', () => {
+ test('setState update in progress', async () => {
+ const model = new TestModel();
+ let firstUpdateCompleted = false;
+ let secondUpdateCompleted = false;
+ model.state$.subscribe(s => {
+ if (s.prop2 === 'set') {
+ // Otherwise this would be a clear indication of a nested `setState()`
+ // call, which `stateUpdateInProgress` is supposed to avoid.
+ assert.isTrue(firstUpdateCompleted);
+ secondUpdateCompleted = true;
+ }
+ if (s.prop1 === 'set' && s.prop2 !== 'set')
+ model.setState({prop2: 'set'});
+ });
+
+ // This call should return before the subscriber calls `setState()` again.
+ model.setState({prop1: 'set'});
+ firstUpdateCompleted = true;
+
+ await waitUntil(() => secondUpdateCompleted);
+ });
+
+ test('updateState update in progress', async () => {
+ const model = new TestModel();
+ let completed = false;
+ model.state$.subscribe(s => {
+ if (s.prop1 !== 'go') return;
+ if (s.prop2 !== 'set' && s.prop3 !== 'set')
+ model.updateState({prop2: 'set'});
+ if (s.prop2 === 'set' && s.prop3 === 'set') completed = true;
+ });
+ model.state$.subscribe(s => {
+ if (s.prop1 !== 'go') return;
+ if (s.prop2 !== 'set' && s.prop3 !== 'set')
+ model.updateState({prop3: 'set'});
+ if (s.prop2 === 'set' && s.prop3 === 'set') completed = true;
+ });
+
+ model.updateState({prop1: 'go'});
+
+ await waitUntil(() => completed);
+ });
+});
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index b69c715..fa00a0b 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -3,7 +3,7 @@
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {from, of, Observable, Subscription} from 'rxjs';
+import {from, of, Observable} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {
DiffPreferencesInfo as DiffPreferencesInfoAPI,
@@ -83,8 +83,6 @@
preference => preference.theme
);
- private subscriptions: Subscription[] = [];
-
constructor(readonly restApiService: RestApiService) {
super({
preferences: createDefaultPreferences(),
@@ -140,13 +138,6 @@
];
}
- override finalize() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions = [];
- }
-
updatePreferences(prefs: Partial<PreferencesInfo>) {
return this.restApiService
.savePreferences(prefs)
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 826ec66..100c46b 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -11,8 +11,10 @@
ChangeInfo,
PatchSetNumber,
} from '../../api/rest-api';
+import {Tab} from '../../constants/constants';
import {GerritView} from '../../services/router/router-model';
import {UrlEncodedCommentId} from '../../types/common';
+import {toggleSet} from '../../utils/common-util';
import {select} from '../../utils/observable-util';
import {
encodeURL,
@@ -33,7 +35,8 @@
patchNum?: RevisionPatchSetNum;
basePatchNum?: BasePatchSetNum;
commentId?: UrlEncodedCommentId;
- tab?: string;
+ /** This can be a string only for plugin provided tabs. */
+ tab?: Tab | string;
/** Checks related view state */
@@ -43,6 +46,10 @@
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 */
@@ -107,6 +114,15 @@
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');
}
@@ -130,9 +146,9 @@
}
if (state.project) {
const encodedProject = encodeURL(state.project, true);
- return getBaseUrl() + `/c/${encodedProject}/+/${state.changeNum}${suffix}`;
+ return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
} else {
- return getBaseUrl() + `/c/${state.changeNum}${suffix}`;
+ return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
}
}
@@ -151,6 +167,16 @@
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 => {
@@ -163,4 +189,11 @@
}
});
}
+
+ toggleSelectedCheckRun(checkName: string) {
+ const current = this.getState()?.checksRunsSelected ?? new Set();
+ const next = new Set(current);
+ toggleSet(next, checkName);
+ this.updateState({checksRunsSelected: next});
+ }
}
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index 2f05e9f..24ced82 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -14,13 +14,15 @@
import '../../test/common-test-setup';
import {createChangeUrl, ChangeViewState} from './change';
+const STATE: ChangeViewState = {
+ view: GerritView.CHANGE,
+ changeNum: 1234 as NumericChangeId,
+ project: 'test' as RepoName,
+};
+
suite('change view state tests', () => {
test('createChangeUrl()', () => {
- const state: ChangeViewState = {
- view: GerritView.CHANGE,
- changeNum: 1234 as NumericChangeId,
- project: 'test' as RepoName,
- };
+ const state: ChangeViewState = {...STATE};
assert.equal(createChangeUrl(state), '/c/test/+/1234');
@@ -34,6 +36,37 @@
assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10#123');
});
+ test('createChangeUrl() baseUrl', () => {
+ window.CANONICAL_PATH = '/base';
+ const state: ChangeViewState = {...STATE};
+ assert.equal(createChangeUrl(state).substring(0, 5), '/base');
+ window.CANONICAL_PATH = undefined;
+ });
+
+ test('createChangeUrl() checksRunsSelected', () => {
+ const state: ChangeViewState = {
+ ...STATE,
+ checksRunsSelected: new Set(['asdf']),
+ };
+
+ assert.equal(
+ createChangeUrl(state),
+ '/c/test/+/1234?checksRunsSelected=asdf'
+ );
+ });
+
+ test('createChangeUrl() checksResultsFilter', () => {
+ const state: ChangeViewState = {
+ ...STATE,
+ checksResultsFilter: 'asdf.*qwer',
+ };
+
+ assert.equal(
+ createChangeUrl(state),
+ '/c/test/+/1234?checksResultsFilter=asdf.*qwer'
+ );
+ });
+
test('createChangeUrl() with repo name encoding', () => {
const state: ChangeViewState = {
view: GerritView.CHANGE,
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index 7141637..d9ff2d2 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -7,7 +7,7 @@
import {GerritView} from '../../services/router/router-model';
import {DashboardId} from '../../types/common';
import {DashboardSection} from '../../utils/dashboard-util';
-import {encodeURL} from '../../utils/url-util';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
import {define} from '../dependency';
import {Model} from '../model';
import {ViewState} from './base';
@@ -46,14 +46,14 @@
queryParams.push('title=' + encodeURIComponent(state.title));
}
const user = state.user ? state.user : '';
- return `/dashboard/${user}?${queryParams.join('&')}`;
+ return `${getBaseUrl()}/dashboard/${user}?${queryParams.join('&')}`;
} else if (repoName) {
// Project dashboard.
const encodedRepo = encodeURL(repoName, true);
- return `/p/${encodedRepo}/+/dashboard/${state.dashboard}`;
+ return `${getBaseUrl()}/p/${encodedRepo}/+/dashboard/${state.dashboard}`;
} else {
// User dashboard.
- return `/dashboard/${state.user || 'self'}`;
+ return `${getBaseUrl()}/dashboard/${state.user || 'self'}`;
}
}
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index 9deed72..86bb5c0 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -15,6 +15,12 @@
assert.equal(createDashboardUrl({}), '/dashboard/self');
});
+ test('baseUrl', () => {
+ window.CANONICAL_PATH = '/base';
+ assert.equal(createDashboardUrl({}).substring(0, 5), '/base');
+ window.CANONICAL_PATH = undefined;
+ });
+
test('user dashboard', () => {
assert.equal(createDashboardUrl({user: 'user'}), '/dashboard/user');
});
diff --git a/polygerrit-ui/app/models/views/diff.ts b/polygerrit-ui/app/models/views/diff.ts
index 63df521..3cc107a 100644
--- a/polygerrit-ui/app/models/views/diff.ts
+++ b/polygerrit-ui/app/models/views/diff.ts
@@ -12,7 +12,11 @@
} from '../../api/rest-api';
import {GerritView} from '../../services/router/router-model';
import {UrlEncodedCommentId} from '../../types/common';
-import {encodeURL, getPatchRangeExpression} from '../../utils/url-util';
+import {
+ encodeURL,
+ getBaseUrl,
+ getPatchRangeExpression,
+} from '../../utils/url-util';
import {define} from '../dependency';
import {Model} from '../model';
import {ViewState} from './base';
@@ -85,9 +89,9 @@
if (state.project) {
const encodedProject = encodeURL(state.project, true);
- return `/c/${encodedProject}/+/${state.changeNum}${suffix}`;
+ return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
} else {
- return `/c/${state.changeNum}${suffix}`;
+ return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
}
}
diff --git a/polygerrit-ui/app/models/views/diff_test.ts b/polygerrit-ui/app/models/views/diff_test.ts
index ec20037..b0f91bb 100644
--- a/polygerrit-ui/app/models/views/diff_test.ts
+++ b/polygerrit-ui/app/models/views/diff_test.ts
@@ -25,6 +25,10 @@
};
assert.equal(createDiffUrl(params), '/c/42/12/x%252By/path.cpp');
+ window.CANONICAL_PATH = '/base';
+ assert.equal(createDiffUrl(params).substring(0, 5), '/base');
+ window.CANONICAL_PATH = undefined;
+
params.project = 'test' as RepoName;
assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
diff --git a/polygerrit-ui/app/models/views/edit.ts b/polygerrit-ui/app/models/views/edit.ts
index d8f4770..c63c8ce 100644
--- a/polygerrit-ui/app/models/views/edit.ts
+++ b/polygerrit-ui/app/models/views/edit.ts
@@ -10,7 +10,11 @@
RevisionPatchSetNum,
} from '../../api/rest-api';
import {GerritView} from '../../services/router/router-model';
-import {encodeURL, getPatchRangeExpression} from '../../utils/url-util';
+import {
+ encodeURL,
+ getBaseUrl,
+ getPatchRangeExpression,
+} from '../../utils/url-util';
import {define} from '../dependency';
import {Model} from '../model';
import {ViewState} from './base';
@@ -41,9 +45,9 @@
if (state.project) {
const encodedProject = encodeURL(state.project, true);
- return `/c/${encodedProject}/+/${state.changeNum}${suffix}`;
+ return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
} else {
- return `/c/${state.changeNum}${suffix}`;
+ return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
}
}
diff --git a/polygerrit-ui/app/models/views/edit_test.ts b/polygerrit-ui/app/models/views/edit_test.ts
index e1a05c7..2912063 100644
--- a/polygerrit-ui/app/models/views/edit_test.ts
+++ b/polygerrit-ui/app/models/views/edit_test.ts
@@ -27,5 +27,9 @@
createEditUrl(params),
'/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
);
+
+ window.CANONICAL_PATH = '/base';
+ assert.equal(createEditUrl(params).substring(0, 5), '/base');
+ window.CANONICAL_PATH = undefined;
});
});
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
index 58cb8f7..13de8f3 100644
--- a/polygerrit-ui/app/models/views/search.ts
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -6,7 +6,7 @@
import {RepoName, BranchName, TopicName} from '../../api/rest-api';
import {GerritView} from '../../services/router/router-model';
import {addQuotesWhen} from '../../utils/string-util';
-import {encodeURL} from '../../utils/url-util';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
import {define} from '../dependency';
import {Model} from '../model';
import {ViewState} from './base';
@@ -35,7 +35,7 @@
}
if (params.query) {
- return '/q/' + encodeURL(params.query, true) + offsetExpr;
+ return `${getBaseUrl()}/q/${encodeURL(params.query, true)}${offsetExpr}`;
}
const operators: string[] = [];
@@ -80,7 +80,7 @@
}
}
- return '/q/' + operators.join('+') + offsetExpr;
+ return `${getBaseUrl()}/q/${operators.join('+')}${offsetExpr}`;
}
export const searchViewModelToken =
diff --git a/polygerrit-ui/app/models/views/search_test.ts b/polygerrit-ui/app/models/views/search_test.ts
index 138ce1e..d48667b 100644
--- a/polygerrit-ui/app/models/views/search_test.ts
+++ b/polygerrit-ui/app/models/views/search_test.ts
@@ -23,6 +23,10 @@
'topic:g%2525h+status:op%2525en'
);
+ window.CANONICAL_PATH = '/base';
+ assert.equal(createSearchUrl(options).substring(0, 5), '/base');
+ window.CANONICAL_PATH = undefined;
+
options.offset = 100;
assert.equal(
createSearchUrl(options),
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index 47492a3..be60a63 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -30,6 +30,7 @@
const resolve = requirePlugin('rollup-plugin-node-resolve');
const {terser} = requirePlugin('rollup-plugin-terser');
+const define = requirePlugin('rollup-plugin-define');
// @polymer/font-roboto-local uses import.meta.url value
// as a base path to fonts. We should substitute a correct javascript
@@ -75,5 +76,11 @@
extensions: ['.js'],
moduleDirectory: 'external/ui_npm/node_modules',
},
- }), importLocalFontMetaUrlResolver()],
+ }),
+ define({
+ replacements: {
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ },
+ }),
+ importLocalFontMetaUrlResolver()],
};
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 1d5e577..9ab0f64 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -29,6 +29,7 @@
silent = True,
sourcemap = "hidden",
deps = [
+ "@tools_npm//rollup-plugin-define",
"@tools_npm//rollup-plugin-node-resolve",
],
)
@@ -42,6 +43,7 @@
silent = True,
sourcemap = "hidden",
deps = [
+ "@tools_npm//rollup-plugin-define",
"@tools_npm//rollup-plugin-node-resolve",
],
)
@@ -55,6 +57,7 @@
silent = True,
sourcemap = "hidden",
deps = [
+ "@tools_npm//rollup-plugin-define",
"@tools_npm//rollup-plugin-node-resolve",
],
)
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 99b9870..f8bc778 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
import {Finalizable} from '../registry';
import {
NumericChangeId,
@@ -12,6 +11,7 @@
BasePatchSetNum,
} from '../../types/common';
import {Model} from '../../models/model';
+import {select} from '../../utils/observable-util';
export enum GerritView {
ADMIN = 'admin',
@@ -37,31 +37,23 @@
}
export class RouterModel extends Model<RouterState> implements Finalizable {
- readonly routerView$: Observable<GerritView | undefined>;
+ readonly routerView$: Observable<GerritView | undefined> = select(
+ this.state$,
+ state => state.view
+ );
- readonly routerChangeNum$: Observable<NumericChangeId | undefined>;
+ readonly routerChangeNum$: Observable<NumericChangeId | undefined> = select(
+ this.state$,
+ state => state.changeNum
+ );
- readonly routerPatchNum$: Observable<RevisionPatchSetNum | undefined>;
+ readonly routerPatchNum$: Observable<RevisionPatchSetNum | undefined> =
+ select(this.state$, state => state.patchNum);
- readonly routerBasePatchNum$: Observable<BasePatchSetNum | undefined>;
+ readonly routerBasePatchNum$: Observable<BasePatchSetNum | undefined> =
+ select(this.state$, state => state.basePatchNum);
constructor() {
super({});
- this.routerView$ = this.state$.pipe(
- map(state => state.view),
- distinctUntilChanged()
- );
- this.routerChangeNum$ = this.state$.pipe(
- map(state => state.changeNum),
- distinctUntilChanged()
- );
- this.routerPatchNum$ = this.state$.pipe(
- map(state => state.patchNum),
- distinctUntilChanged()
- );
- this.routerBasePatchNum$ = this.state$.pipe(
- map(state => state.basePatchNum),
- distinctUntilChanged()
- );
}
}
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
index 0eec23a..53cd325 100644
--- a/polygerrit-ui/app/services/service-worker-installer.ts
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -16,6 +16,7 @@
/** Type of incoming messages for ServiceWorker. */
export enum ServiceWorkerMessageType {
TRIGGER_NOTIFICATIONS = 'TRIGGER_NOTIFICATIONS',
+ USER_PREFERENCE_CHANGE = 'USER_PREFERENCE_CHANGE',
}
export const TRIGGER_NOTIFICATION_UPDATES_MS = 5 * 60 * 1000;
@@ -25,33 +26,48 @@
account?: AccountDetailInfo;
+ allowBrowserNotificationsPreference?: boolean;
+
constructor(
private readonly flagsService: FlagsService,
private readonly userModel: UserModel
) {
+ if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+ return;
+ }
this.userModel.account$.subscribe(acc => (this.account = acc));
+ this.userModel.preferences$.subscribe(prefs => {
+ if (
+ this.allowBrowserNotificationsPreference !==
+ prefs.allow_browser_notifications
+ ) {
+ this.allowBrowserNotificationsPreference =
+ prefs.allow_browser_notifications;
+ navigator.serviceWorker.controller?.postMessage({
+ type: ServiceWorkerMessageType.USER_PREFERENCE_CHANGE,
+ allowBrowserNotificationsPreference:
+ this.allowBrowserNotificationsPreference,
+ });
+ }
+ });
+ Promise.all([
+ until(this.userModel.account$, account => !!account),
+ until(
+ this.userModel.preferences$,
+ prefs => !!prefs.allow_browser_notifications
+ ),
+ ]).then(() => {
+ this.init();
+ });
}
- async init() {
+ private async init() {
if (this.initialized) return;
- if (
- !this.flagsService.isEnabled(
- KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
- )
- ) {
- if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
- return;
- }
- const timeout1s = new Promise(resolve => {
- setTimeout(resolve, 1000);
- });
- // We wait for account to be defined, if its not defined in 1s, it's guest
- await Promise.race([
- timeout1s,
- until(this.userModel.account$, account => !!account),
- ]);
- if (!areNotificationsEnabled(this.account)) return;
+ if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+ return;
}
+ if (!this.areNotificationsEnabled()) return;
+
if (!('serviceWorker' in navigator)) {
console.error('Service worker API not available');
return;
@@ -62,6 +78,21 @@
this.initialized = true;
}
+ areNotificationsEnabled() {
+ // Push Notification developer can have notification enabled even if they
+ // are disabled for this.account.
+ if (
+ !this.flagsService.isEnabled(
+ KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+ ) &&
+ !areNotificationsEnabled(this.account)
+ ) {
+ return false;
+ }
+
+ return this.allowBrowserNotificationsPreference;
+ }
+
/**
* Every 5 minutes, we trigger service-worker to get
* latest updates in attention set and service-worker will create
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
index 77dd907d..e8fd233 100644
--- a/polygerrit-ui/app/services/service-worker-installer_test.ts
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -7,23 +7,29 @@
import '../test/common-test-setup';
import {ServiceWorkerInstaller} from './service-worker-installer';
import {assert} from '@open-wc/testing';
+import {createDefaultPreferences} from '../constants/constants';
+import {waitUntilObserved} from '../test/test-utils';
-suite('service woerker installler tests', () => {
- let serviceWorkerInstaller: ServiceWorkerInstaller;
-
- setup(() => {
+suite('service worker installer tests', () => {
+ test('init', async () => {
+ const registerStub = sinon.stub(window.navigator.serviceWorker, 'register');
const flagsService = getAppContext().flagsService;
const userModel = getAppContext().userModel;
sinon.stub(flagsService, 'isEnabled').returns(true);
- serviceWorkerInstaller = new ServiceWorkerInstaller(
- flagsService,
- userModel
+ new ServiceWorkerInstaller(flagsService, userModel);
+ const prefs = {
+ ...createDefaultPreferences(),
+ allow_browser_notifications: true,
+ };
+ userModel.setPreferences(prefs);
+ await waitUntilObserved(
+ userModel.preferences$,
+ pref => pref.allow_browser_notifications === true
);
- });
-
- test('init', () => {
- const registerStub = sinon.stub(window.navigator.serviceWorker, 'register');
- serviceWorkerInstaller.init();
+ await waitUntilObserved(
+ userModel.preferences$,
+ pref => pref.allow_browser_notifications === true
+ );
assert.isTrue(registerStub.called);
});
});
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 117e7cc..e148da9 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -99,6 +99,8 @@
--purple-100: #e9d2fd;
--purple-50: #f3e8fd;
--purple-tonal: #523272;
+ --deep-purple-800: #4527a0;
+ --deep-purple-600: #5e35b1;
--pink-800: #b80672;
--pink-500: #f538a0;
--pink-50: #fde7f3;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index d11e372..2330041 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -219,8 +219,8 @@
--dark-remove-highlight-color: #62110f;
--light-remove-highlight-color: #320404;
- --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
- --light-rebased-add-highlight-color: #487165;
+ --dark-rebased-add-highlight-color: var(--deep-purple-800);
+ --light-rebased-add-highlight-color: var(--deep-purple-600);
--dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
--light-rebased-remove-highlight-color: #2f3f2f;
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 9337500..4e29f53 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -686,6 +686,7 @@
change_table: [],
email_strategy: EmailStrategy.ENABLED,
default_base_for_merges: DefaultBase.AUTO_MERGE,
+ allow_browser_notifications: true,
};
}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 523dadb..008a8de 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1122,6 +1122,7 @@
work_in_progress_by_default?: boolean;
// The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
email_format?: EmailFormat;
+ allow_browser_notifications?: boolean;
}
/**
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 597bb6f..496513d 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -209,9 +209,7 @@
// Type for the custom event to switch tab.
export interface SwitchTabEventDetail {
// name of the tab to set as active, from custom event
- tab?: string;
- // index of tab to set as active, from paper-tabs event
- value?: number;
+ tab: string;
// scroll into the tab afterwards, from custom event
scrollIntoView?: boolean;
// define state of tab after opening
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 07b63d6..9062ac7 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -282,7 +282,9 @@
change?: ChangeInfo,
reviewer?: AccountInfo
): boolean {
- if (!change?.removable_reviewers || !reviewer) return false;
+ if (!reviewer || !change) return false;
+ if (isCc(change, reviewer)) return true;
+ if (!change.removable_reviewers) return false;
return change.removable_reviewers.some(
account =>
account._account_id === reviewer._account_id ||
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 05c054b..183d167 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -117,7 +117,7 @@
/**
* Add value, if the set does not contain it. Otherwise remove it.
*/
-export function toggleSetMembership<T>(set: Set<T>, value: T): void {
+export function toggleSet<T>(set: Set<T>, value: T): void {
if (set.has(value)) {
set.delete(value);
} else {
@@ -125,6 +125,14 @@
}
}
+export function toggle<T>(array: T[], item: T): T[] {
+ if (array.includes(item)) {
+ return array.filter(r => r !== item);
+ } else {
+ return array.concat([item]);
+ }
+}
+
export function unique<T>(item: T, index: number, array: T[]) {
return array.indexOf(item) === index;
}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 0d85f34..76c8a6c 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -11,6 +11,7 @@
containsAll,
intersection,
difference,
+ toggle,
} from './common-util';
suite('common-util tests', () => {
@@ -97,4 +98,11 @@
assert.deepEqual(difference([1, 2, 3], [1, 2, 3]), []);
assert.deepEqual(difference([1, 2, 3], [4, 5, 6]), [1, 2, 3]);
});
+
+ test('toggle', () => {
+ assert.deepEqual(toggle([], 1), [1]);
+ assert.deepEqual(toggle([1], 1), []);
+ assert.deepEqual(toggle([1, 2, 3], 1), [2, 3]);
+ assert.deepEqual(toggle([2, 3], 1), [2, 3, 1]);
+ });
});
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index 9079f4c..b5b9025 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -4,76 +4,164 @@
* SPDX-License-Identifier: Apache-2.0
*/
import 'ba-linkify/ba-linkify';
-import {CommentLinks} from '../types/common';
+import {CommentLinkInfo, CommentLinks} from '../types/common';
import {getBaseUrl} from './url-util';
-export function linkifyNormalUrls(base: string): string {
- // Some tools are known to look for reviewers/CCs by finding lines such as
- // "R=foo@gmail.com, bar@gmail.com". However, "=" is technically a valid email
- // character, so ba-linkify interprets the entire string "R=foo@gmail.com" as
- // an email address. To fix this, we insert a zero width space character
- // \u200B before linking that prevents ba-linkify from associating the prefix
- // with the email. After linking we remove the zero width space.
- const baseWithZeroWidthSpace = base.replace(/^(R=|CC=)/g, '$&\u200B');
+/**
+ * Finds links within the base string and convert them to HTML. Config-based
+ * rewrites are only applied on text that is not linked by the default linking
+ * library.
+ */
+export function linkifyUrlsAndApplyRewrite(
+ base: string,
+ repoCommentLinks: CommentLinks
+): string {
const parts: string[] = [];
- window.linkify(baseWithZeroWidthSpace, {
+ window.linkify(insertZeroWidthSpace(base), {
callback: (text, href) => {
- const result = href ? createLinkTemplate(href, text) : text;
- const resultWithoutZeroWidthSpace = result.replace(/\u200B/g, '');
- parts.push(resultWithoutZeroWidthSpace);
+ if (href) {
+ parts.push(removeZeroWidthSpace(createLinkTemplate(href, text)));
+ } else {
+ const rewriteResults = getRewriteResultsFromConfig(
+ text,
+ repoCommentLinks
+ );
+ parts.push(removeZeroWidthSpace(applyRewrites(text, rewriteResults)));
+ }
},
});
return parts.join('');
}
-export function applyLinkRewritesFromConfig(
+/**
+ * Generates a list of rewrites that would be applied to a base string. They are
+ * not applied immediately to the base text because one rewrite may interfere or
+ * overlap with a later rewrite. Only after all rewrites are known they are
+ * carefully merged with `applyRewrites`.
+ */
+function getRewriteResultsFromConfig(
base: string,
repoCommentLinks: CommentLinks
-) {
- const linkRewritesFromConfig = Object.values(repoCommentLinks).filter(
- commentLinkInfo => commentLinkInfo.enabled !== false && commentLinkInfo.link
+): RewriteResult[] {
+ const enabledRewrites = Object.values(repoCommentLinks).filter(
+ commentLinkInfo =>
+ commentLinkInfo.enabled !== false &&
+ (commentLinkInfo.link !== undefined || commentLinkInfo.html !== undefined)
);
- const rewrites = linkRewritesFromConfig.map(rewrite => {
- const replacementHref = rewrite.link!.startsWith('/')
- ? `${getBaseUrl()}${rewrite.link!}`
- : rewrite.link!;
- return {
- match: new RegExp(rewrite.match, 'g'),
- replace: createLinkTemplate(
+ return enabledRewrites.flatMap(rewrite => {
+ const regexp = new RegExp(rewrite.match, 'g');
+ const partialResults: RewriteResult[] = [];
+ let match: RegExpExecArray | null;
+
+ while ((match = regexp.exec(base)) !== null) {
+ const fullReplacementText = getReplacementText(match[0], rewrite);
+ // The replacement may not be changing the entire matched substring so we
+ // "trim" the replacement position and text to the part that is actually
+ // different. This makes sure that unchanged portions are still eligible
+ // for other rewrites without being rejected as overlaps during
+ // `applyRewrites`. The new `replacementText` is not eligible for other
+ // rewrites since it would introduce unexpected interactions between
+ // rewrites depending on their order of definition/execution.
+ const sharedPrefixLength = getSharedPrefixLength(
+ match[0],
+ fullReplacementText
+ );
+ const sharedSuffixLength = getSharedSuffixLength(
+ match[0],
+ fullReplacementText
+ );
+ const prefixIndex = sharedPrefixLength;
+ const matchSuffixIndex = match[0].length - sharedSuffixLength;
+ const fullReplacementSuffixIndex =
+ fullReplacementText.length - sharedSuffixLength;
+ partialResults.push({
+ replacedTextStartPosition: match.index + prefixIndex,
+ replacedTextEndPosition: match.index + matchSuffixIndex,
+ replacementText: fullReplacementText.substring(
+ prefixIndex,
+ fullReplacementSuffixIndex
+ ),
+ });
+ }
+ return partialResults;
+ });
+}
+
+/**
+ * Applies all the rewrites to the given base string. To resolve cases where
+ * multiple rewrites target overlapping pieces of the base string, the rewrite
+ * that ends latest is kept and the rest are not applied and discarded.
+ */
+function applyRewrites(base: string, rewriteResults: RewriteResult[]): string {
+ const rewritesByEndPosition = [...rewriteResults].sort((a, b) => {
+ if (b.replacedTextEndPosition !== a.replacedTextEndPosition) {
+ return b.replacedTextEndPosition - a.replacedTextEndPosition;
+ }
+ return a.replacedTextStartPosition - b.replacedTextStartPosition;
+ });
+ const filteredSortedRewrites: RewriteResult[] = [];
+ let latestReplace = base.length;
+ for (const rewrite of rewritesByEndPosition) {
+ // Only accept rewrites that do not overlap with any previously accepted
+ // rewrites.
+ if (rewrite.replacedTextEndPosition <= latestReplace) {
+ filteredSortedRewrites.push(rewrite);
+ latestReplace = rewrite.replacedTextStartPosition;
+ }
+ }
+ return filteredSortedRewrites.reduce(
+ (text, rewrite) =>
+ text
+ .substring(0, rewrite.replacedTextStartPosition)
+ .concat(rewrite.replacementText)
+ .concat(text.substring(rewrite.replacedTextEndPosition)),
+ base
+ );
+}
+
+/**
+ * For a given regexp match, apply the rewrite based on the rewrite's type and
+ * return the resulting string.
+ */
+function getReplacementText(
+ matchedText: string,
+ rewrite: CommentLinkInfo
+): string {
+ if (rewrite.link !== undefined) {
+ const replacementHref = rewrite.link.startsWith('/')
+ ? `${getBaseUrl()}${rewrite.link}`
+ : rewrite.link;
+ const regexp = new RegExp(rewrite.match, 'g');
+ return matchedText.replace(
+ regexp,
+ createLinkTemplate(
replacementHref,
rewrite.text ?? '$&',
rewrite.prefix,
rewrite.suffix
- ),
- };
- });
- return applyRewrites(base, rewrites);
+ )
+ );
+ } else if (rewrite.html !== undefined) {
+ return matchedText.replace(new RegExp(rewrite.match, 'g'), rewrite.html);
+ } else {
+ throw new Error('commentLinkInfo is not a link or html rewrite');
+ }
}
-export function applyHtmlRewritesFromConfig(
- base: string,
- repoCommentLinks: CommentLinks
-) {
- const htmlRewritesFromConfig = Object.values(repoCommentLinks).filter(
- commentLinkInfo => commentLinkInfo.enabled !== false && commentLinkInfo.html
- );
- const rewrites = htmlRewritesFromConfig.map(rewrite => {
- return {
- match: new RegExp(rewrite.match, 'g'),
- replace: rewrite.html!,
- };
- });
- return applyRewrites(base, rewrites);
+/**
+ * Some tools are known to look for reviewers/CCs by finding lines such as
+ * "R=foo@gmail.com, bar@gmail.com". However, "=" is technically a valid email
+ * character, so ba-linkify interprets the entire string "R=foo@gmail.com" as an
+ * email address. To fix this, we insert a zero width space character \u200B
+ * before linking that prevents ba-linkify from associating the prefix with the
+ * email. After linking we remove the zero width space.
+ */
+function insertZeroWidthSpace(base: string) {
+ return base.replace(/^(R=|CC=)/g, '$&\u200B');
}
-function applyRewrites(
- base: string,
- rewrites: {match: RegExp | string; replace: string}[]
-) {
- return rewrites.reduce(
- (text, rewrite) => text.replace(rewrite.match, rewrite.replace),
- base
- );
+function removeZeroWidthSpace(base: string) {
+ return base.replace(/\u200B/g, '');
}
function createLinkTemplate(
@@ -88,3 +176,41 @@
suffix ?? ''
}`;
}
+
+/**
+ * Returns the number of characters that are identical at the start of both
+ * strings.
+ *
+ * For example, `getSharedPrefixLength('12345678', '1234zz78')` would return 4
+ */
+function getSharedPrefixLength(a: string, b: string) {
+ let i = 0;
+ for (; i < a.length && i < b.length; ++i) {
+ if (a[i] !== b[i]) {
+ return i;
+ }
+ }
+ return i;
+}
+
+/**
+ * Returns the number of characters that are identical at the end of both
+ * strings.
+ *
+ * For example, `getSharedSuffixLength('12345678', '1234zz78')` would return 2
+ */
+function getSharedSuffixLength(a: string, b: string) {
+ let i = a.length;
+ for (let j = b.length; i !== 0 && j !== 0; --i, --j) {
+ if (a[i] !== b[j]) {
+ return a.length - 1 - i;
+ }
+ }
+ return a.length - i;
+}
+
+interface RewriteResult {
+ replacedTextStartPosition: number;
+ replacedTextEndPosition: number;
+ replacementText: string;
+}
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index a1ec2fa..61d6bff 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -3,11 +3,7 @@
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- applyHtmlRewritesFromConfig,
- applyLinkRewritesFromConfig,
- linkifyNormalUrls,
-} from './link-util';
+import {linkifyUrlsAndApplyRewrite} from './link-util';
import {assert} from '@open-wc/testing';
suite('link-util tests', () => {
@@ -15,55 +11,220 @@
return `<a href="${href}" rel="noopener" target="_blank">${text}</a>`;
}
- test('applyHtmlRewritesFromConfig', () => {
- assert.equal(
- applyHtmlRewritesFromConfig('#12345 foo', {
- 'number-emphasizer': {
- match: '#(\\d+)',
- html: '<h1>Change $1 is the best change</h1>',
- },
- 'foo-capitalizer': {
- match: 'foo',
- html: '<div>FOO</div>',
- },
- }),
- '<h1>Change 12345 is the best change</h1> <div>FOO</div>'
- );
+ suite('link rewrites', () => {
+ test('without text', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foo', {
+ fooLinkWithoutText: {
+ match: 'foo',
+ link: 'foo.gov',
+ },
+ }),
+ link('foo', 'foo.gov')
+ );
+ });
+
+ test('with text', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foo', {
+ fooLinkWithText: {
+ match: 'foo',
+ link: 'foo.gov',
+ text: 'foo site',
+ },
+ }),
+ link('foo site', 'foo.gov')
+ );
+ });
+
+ test('with prefix and suffix', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('there are 12 foos here', {
+ fooLinkWithText: {
+ match: '(.*)(bug|foo)s(.*)',
+ link: '$2.gov',
+ text: '$2 list',
+ prefix: '$1on the ',
+ suffix: '$3',
+ },
+ }),
+ `there are 12 on the ${link('foo list', 'foo.gov')} here`
+ );
+ });
+
+ test('multiple matches', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foo foo', {
+ foo: {
+ match: 'foo',
+ link: 'foo.gov',
+ },
+ }),
+ `${link('foo', 'foo.gov')} ${link('foo', 'foo.gov')}`
+ );
+ });
+
+ test('does not apply within normal links', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('google.com', {
+ ogle: {
+ match: 'ogle',
+ link: 'gerritcodereview.com',
+ },
+ }),
+ link('google.com', 'http://google.com')
+ );
+ });
+ });
+ suite('html rewrites', () => {
+ test('basic case', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foo', {
+ foo: {
+ match: '(foo)',
+ html: '<div>$1</div>',
+ },
+ }),
+ '<div>foo</div>'
+ );
+ });
+
+ test('only inserts', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foo', {
+ foo: {
+ match: 'foo',
+ html: 'foo bar',
+ },
+ }),
+ 'foo bar'
+ );
+ });
+
+ test('only deletes', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foo bar baz', {
+ bar: {
+ match: 'bar',
+ html: '',
+ },
+ }),
+ 'foo baz'
+ );
+ });
+
+ test('multiple matches', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foo foo', {
+ foo: {
+ match: '(foo)',
+ html: '<div>$1</div>',
+ },
+ }),
+ '<div>foo</div> <div>foo</div>'
+ );
+ });
+
+ test('does not apply within normal links', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('google.com', {
+ ogle: {
+ match: 'ogle',
+ html: '<div>gerritcodereview.com<div>',
+ },
+ }),
+ link('google.com', 'http://google.com')
+ );
+ });
});
- test('applyLinkRewritesFromConfig', () => {
- const linkedNumber = link('#12345', 'google.com/12345');
- const linkedFoo = link('foo', 'foo.gov');
- const linkedBar = link('Bar Page: 300', 'bar.com/page?id=300');
+ test('for overlapping rewrites prefer the latest ending', () => {
assert.equal(
- applyLinkRewritesFromConfig('#12345 foo crowbar:12 bar:300', {
- 'number-linker': {
- match: '#(\\d+)',
- link: 'google.com/$1',
- },
- 'foo-linker': {
+ linkifyUrlsAndApplyRewrite('foobarbaz', {
+ foo: {
match: 'foo',
link: 'foo.gov',
},
- 'advanced-link': {
- match: '(^|\\s)bar:(\\d+)($|\\s)',
- link: 'bar.com/page?id=$2',
- text: 'Bar Page: $2',
- prefix: '$1',
- suffix: '$3',
+ foobarbaz: {
+ match: 'foobarbaz',
+ html: '<div>foobarbaz.gov</div>',
+ },
+ foobar: {
+ match: 'foobar',
+ link: 'foobar.gov',
},
}),
- `${linkedNumber} ${linkedFoo} crowbar:12 ${linkedBar}`
+ '<div>foobarbaz.gov</div>'
);
});
- suite('linkifyNormalUrls', () => {
+ test('overlapping rewrites with same ending prefers earliest start', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foobarbaz', {
+ foo: {
+ match: 'baz',
+ link: 'Baz.gov',
+ },
+ foobarbaz: {
+ match: 'foobarbaz',
+ html: '<div>FooBarBaz.gov</div>',
+ },
+ foobar: {
+ match: 'barbaz',
+ link: 'BarBaz.gov',
+ },
+ }),
+ '<div>FooBarBaz.gov</div>'
+ );
+ });
+
+ test('removed overlapping rewrites do not prevent other rewrites', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('foobarbaz', {
+ foo: {
+ match: 'foo',
+ html: 'FOO',
+ },
+ oobarba: {
+ match: 'oobarba',
+ html: 'OOBARBA',
+ },
+ baz: {
+ match: 'baz',
+ html: 'BAZ',
+ },
+ }),
+ 'FOObarBAZ'
+ );
+ });
+
+ test('rewrites do not interfere with each other matching', () => {
+ assert.equal(
+ linkifyUrlsAndApplyRewrite('bugs: 123 234 345', {
+ bug1: {
+ match: '(bugs:) (\\d+)',
+ html: '$1 <div>bug/$2</div>',
+ },
+ bug2: {
+ match: '(bugs:) (\\d+) (\\d+)',
+ html: '$1 $2 <div>bug/$3</div>',
+ },
+ bug3: {
+ match: '(bugs:) (\\d+) (\\d+) (\\d+)',
+ html: '$1 $2 $3 <div>bug/$4</div>',
+ },
+ }),
+ 'bugs: <div>bug/123</div> <div>bug/234</div> <div>bug/345</div>'
+ );
+ });
+
+ suite('normal links', () => {
test('links urls', () => {
const googleLink = link('google.com', 'http://google.com');
const mapsLink = link('maps.google.com', 'http://maps.google.com');
assert.equal(
- linkifyNormalUrls('google.com, maps.google.com'),
+ linkifyUrlsAndApplyRewrite('google.com, maps.google.com', {}),
`${googleLink}, ${mapsLink}`
);
});
@@ -72,7 +233,7 @@
const fooEmail = link('foo@gmail.com', 'mailto:foo@gmail.com');
const barEmail = link('bar@gmail.com', 'mailto:bar@gmail.com');
assert.equal(
- linkifyNormalUrls('R=foo@gmail.com, bar@gmail.com'),
+ linkifyUrlsAndApplyRewrite('R=foo@gmail.com, bar@gmail.com', {}),
`R=${fooEmail}, ${barEmail}`
);
});
@@ -81,7 +242,7 @@
const fooEmail = link('foo@gmail.com', 'mailto:foo@gmail.com');
const barEmail = link('bar@gmail.com', 'mailto:bar@gmail.com');
assert.equal(
- linkifyNormalUrls('CC=foo@gmail.com, bar@gmail.com'),
+ linkifyUrlsAndApplyRewrite('CC=foo@gmail.com, bar@gmail.com', {}),
`CC=${fooEmail}, ${barEmail}`
);
});
@@ -92,7 +253,7 @@
'mailto:fooR=barCC=baz@gmail.com'
);
assert.equal(
- linkifyNormalUrls('fooR=barCC=baz@gmail.com'),
+ linkifyUrlsAndApplyRewrite('fooR=barCC=baz@gmail.com', {}),
fooBarBazEmail
);
});
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index 0a7ee27..c6c65b1 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -12,7 +12,7 @@
diffFilePaths,
} from './string-util';
-suite('formatter util tests', () => {
+suite('string-util tests', () => {
test('pluralize', () => {
const noun = 'comment';
assert.equal(pluralize(0, noun), '');
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
index c257d8d..218744d 100644
--- a/polygerrit-ui/app/workers/service-worker-class.ts
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -27,6 +27,8 @@
// private but used in test
latestUpdateTimestampMs = Date.now();
+ allowBrowserNotificationsPreference = false;
+
/**
* We cannot rely on a state in a service worker, because every time
* service worker starts or stops, new instance is created. So every time
@@ -44,6 +46,8 @@
saveState() {
return putServiceWorkerState({
latestUpdateTimestampMs: this.latestUpdateTimestampMs,
+ allowBrowserNotificationsPreference:
+ this.allowBrowserNotificationsPreference,
});
}
@@ -51,19 +55,27 @@
const state = await getServiceWorkerState();
if (state) {
this.latestUpdateTimestampMs = state.latestUpdateTimestampMs;
+ this.allowBrowserNotificationsPreference =
+ state.allowBrowserNotificationsPreference;
}
}
private onMessage(e: ExtendableMessageEvent) {
- if (e.data?.type !== ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS) {
- // Only this notification message type exists, but we do not throw error.
- return;
+ if (e.data?.type === ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS) {
+ e.waitUntil(
+ this.showLatestAttentionChangeNotification(
+ e.data?.account as AccountDetailInfo | undefined
+ )
+ );
+ } else if (
+ e.data?.type === ServiceWorkerMessageType.USER_PREFERENCE_CHANGE
+ ) {
+ e.waitUntil(
+ this.allowBrowserNotificationsPreferenceChanged(
+ e.data?.allowBrowserNotificationsPreference as boolean
+ )
+ );
}
- e.waitUntil(
- this.showLatestAttentionChangeNotification(
- e.data?.account as AccountDetailInfo | undefined
- )
- );
}
private onNotificationClick(e: NotificationEvent) {
@@ -71,10 +83,16 @@
e.waitUntil(this.openWindow(e.notification.data.url));
}
+ async allowBrowserNotificationsPreferenceChanged(preference: boolean) {
+ this.allowBrowserNotificationsPreference = preference;
+ await this.saveState();
+ }
+
// private but used in test
async showLatestAttentionChangeNotification(account?: AccountDetailInfo) {
// Message always contains account, but we do not throw error.
if (!account?._account_id) return;
+ if (!this.allowBrowserNotificationsPreference) return;
const latestAttentionChanges = await this.getChangesToNotify(account);
const numOfChangesToNotifyAbout = latestAttentionChanges.length;
if (numOfChangesToNotifyAbout === 1) {
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
index 8b508e8..4cbd7bb 100644
--- a/polygerrit-ui/app/workers/service-worker-class_test.ts
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -25,6 +25,7 @@
},
} as {} as ServiceWorkerGlobalScope;
serviceWorker = new ServiceWorker(moctCtx);
+ serviceWorker.allowBrowserNotificationsPreference = true;
});
test('notify about attention in t2 when time is t3', async () => {
diff --git a/polygerrit-ui/app/workers/service-worker-indexdb.ts b/polygerrit-ui/app/workers/service-worker-indexdb.ts
index f540d59..6d5ab40 100644
--- a/polygerrit-ui/app/workers/service-worker-indexdb.ts
+++ b/polygerrit-ui/app/workers/service-worker-indexdb.ts
@@ -6,6 +6,7 @@
export interface GerritServiceWorkerState {
latestUpdateTimestampMs: number;
+ allowBrowserNotificationsPreference: boolean;
}
const SERVICE_WORKER_DB = 'service-worker-db-1';
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index fc00451..3765575 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -15,6 +15,7 @@
"polymer-bundler": "^4.0.10",
"rollup": "^2.3.4",
"rollup-plugin-commonjs": "^10.1.0",
+ "rollup-plugin-define": "^1.0.1",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^5.1.3",
"typescript": "^4.7.2"
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 3914e17..c6b3eab 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -216,6 +216,14 @@
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
+"@rollup/pluginutils@^4.0.0":
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
+ integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==
+ dependencies:
+ estree-walker "^2.0.1"
+ picomatch "^2.2.2"
+
"@sindresorhus/is@^4.0.0":
version "4.6.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
@@ -576,6 +584,11 @@
resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+ast-matcher@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/ast-matcher/-/ast-matcher-1.1.1.tgz#95a6dc72318319507024fff438b7839e4e280813"
+ integrity sha512-wQPAp09kPFRQsOijM2Blfg4lH6B9MIhIUrhFtDdhD/1JFhPmfg2/+WAjViVYl3N7EwleHI+q/enTHjaDrv+wEw==
+
async@^2.0.1:
version "2.6.4"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
@@ -985,6 +998,11 @@
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+escape-string-regexp@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+ integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
espree@^3.5.2:
version "3.5.4"
resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
@@ -998,6 +1016,11 @@
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==
+estree-walker@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
+ integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
+
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
@@ -1325,7 +1348,7 @@
dependencies:
vlq "^0.2.2"
-magic-string@^0.25.2:
+magic-string@^0.25.2, magic-string@^0.25.7:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
@@ -1458,6 +1481,11 @@
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
+picomatch@^2.2.2:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+ integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
plist@^2.0.1:
version "2.1.0"
resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
@@ -1652,6 +1680,16 @@
resolve "^1.11.0"
rollup-pluginutils "^2.8.1"
+rollup-plugin-define@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/rollup-plugin-define/-/rollup-plugin-define-1.0.1.tgz#45b027cec9d2e30df71118efa156170e3846fd3d"
+ integrity sha512-SM/CKFpLvWq5xBEf84ff/ooT3KodXPVITCkRliyNccuq8SZMpzthN/Bp7JkWScbGTX5lo1SF3cjxKKDjnnFCuA==
+ dependencies:
+ "@rollup/pluginutils" "^4.0.0"
+ ast-matcher "^1.1.1"
+ escape-string-regexp "^4.0.0"
+ magic-string "^0.25.7"
+
rollup-plugin-node-resolve@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz#730f93d10ed202473b1fb54a5997a7db8c6d8523"