Merge "Fix repositioning the reply dialog when the attention section expands"
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index a1197ef..404f5e4 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -108,6 +108,7 @@
import com.google.gerrit.server.RequestInfo;
import com.google.gerrit.server.RequestListener;
import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
+import com.google.gerrit.server.cache.PerThreadCache;
import com.google.gerrit.server.change.ChangeFinder;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.group.GroupAuditService;
@@ -327,7 +328,7 @@
try (TraceContext traceContext = enableTracing(req, res)) {
List<IdString> path = splitPath(req);
- try {
+ try (PerThreadCache ignored = PerThreadCache.create()) {
RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
diff --git a/java/com/google/gerrit/server/AnonymousUser.java b/java/com/google/gerrit/server/AnonymousUser.java
index c96d61a..91d2d05 100644
--- a/java/com/google/gerrit/server/AnonymousUser.java
+++ b/java/com/google/gerrit/server/AnonymousUser.java
@@ -27,6 +27,12 @@
}
@Override
+ public Object getCacheKey() {
+ // Treat all anonymous users as a single user
+ return "anonymous";
+ }
+
+ @Override
public String toString() {
return "ANONYMOUS";
}
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 43d3c7b..825b34f 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -91,6 +91,12 @@
*/
public abstract GroupMembership getEffectiveGroups();
+ /**
+ * Returns a unique identifier for this user that is intended to be used as a cache key. Returned
+ * object should to implement {@code equals()} and {@code hashCode()} for effective caching.
+ */
+ public abstract Object getCacheKey();
+
/** Unique name of the user on this server, if one has been assigned. */
public Optional<String> getUserName() {
return Optional.empty();
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index ec2eb81..75c7cda 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -413,6 +413,11 @@
return effectiveGroups;
}
+ @Override
+ public Object getCacheKey() {
+ return getAccountId();
+ }
+
public PersonIdent newRefLogIdent() {
return newRefLogIdent(new Date(), TimeZone.getDefault());
}
diff --git a/java/com/google/gerrit/server/InternalUser.java b/java/com/google/gerrit/server/InternalUser.java
index 821a0c6..381819d 100644
--- a/java/com/google/gerrit/server/InternalUser.java
+++ b/java/com/google/gerrit/server/InternalUser.java
@@ -36,6 +36,11 @@
}
@Override
+ public String getCacheKey() {
+ return "internal";
+ }
+
+ @Override
public boolean isInternalUser() {
return true;
}
diff --git a/java/com/google/gerrit/server/PeerDaemonUser.java b/java/com/google/gerrit/server/PeerDaemonUser.java
index 8a8b67a..b27e05c 100644
--- a/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -40,6 +40,11 @@
return GroupMembership.EMPTY;
}
+ @Override
+ public Object getCacheKey() {
+ return getRemoteAddress();
+ }
+
public SocketAddress getRemoteAddress() {
return peer;
}
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
new file mode 100644
index 0000000..b4f79d1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PerThreadCache.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2018 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.cache;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
+ *
+ * <p>This class is intended to cache objects that have a high instantiation cost, are specific to
+ * the current request and potentially need to be instantiated multiple times while serving a
+ * request.
+ *
+ * <p>This is different from the key-value storage in {@code CurrentUser}: {@code CurrentUser}
+ * offers a key-value storage by providing thread-safe {@code get} and {@code put} methods. Once the
+ * value is retrieved through {@code get} there is not thread-safety anymore - apart from the
+ * retrieved object guarantees. Depending on the implementation of {@code CurrentUser}, it might be
+ * shared between the request serving thread as well as sub- or background treads.
+ *
+ * <p>In comparison to that, this class guarantees thread safety even on non-thread-safe objects as
+ * its cache is tied to the serving thread only. While allowing to cache non-thread-safe objects, it
+ * has the downside of not sharing any objects with background threads or executors.
+ *
+ * <p>Lastly, this class offers a cache, that requires callers to also provide a {@code Supplier} in
+ * case the object is not present in the cache, while {@code CurrentUser} provides a storage where
+ * just retrieving stored values is a valid operation.
+ *
+ * <p>To prevent OOM errors on requests that would cache a lot of objects, this class enforces an
+ * internal limit after which no new elements are cached. All {@code get} calls are served by
+ * invoking the {@code Supplier} after that.
+ */
+public class PerThreadCache implements AutoCloseable {
+ private static final ThreadLocal<PerThreadCache> CACHE = new ThreadLocal<>();
+ /**
+ * Cache at maximum 25 values per thread. This value was chosen arbitrarily. Some endpoints (like
+ * ListProjects) break the assumption that the data cached in a request is limited. To prevent
+ * this class from accumulating an unbound number of objects, we enforce this limit.
+ */
+ private static final int PER_THREAD_CACHE_SIZE = 25;
+
+ /**
+ * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
+ * class and a list of identifiers that in combination uniquely set the object apart form others
+ * of the same class.
+ */
+ public static final class Key<T> {
+ private final Class<T> clazz;
+ private final ImmutableList<Object> identifiers;
+
+ /**
+ * Returns a key based on the value's class and an identifier that uniquely identify the value.
+ * The identifier needs to implement {@code equals()} and {@hashCode()}.
+ */
+ public static <T> Key<T> create(Class<T> clazz, Object identifier) {
+ return new Key<>(clazz, ImmutableList.of(identifier));
+ }
+
+ /**
+ * Returns a key based on the value's class and a set of identifiers that uniquely identify the
+ * value. Identifiers need to implement {@code equals()} and {@hashCode()}.
+ */
+ public static <T> Key<T> create(Class<T> clazz, Object... identifiers) {
+ return new Key<>(clazz, ImmutableList.copyOf(identifiers));
+ }
+
+ private Key(Class<T> clazz, ImmutableList<Object> identifiers) {
+ this.clazz = clazz;
+ this.identifiers = identifiers;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(clazz, identifiers);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof Key)) {
+ return false;
+ }
+ Key<?> other = (Key<?>) o;
+ return this.clazz == other.clazz && this.identifiers.equals(other.identifiers);
+ }
+ }
+
+ public static PerThreadCache create() {
+ checkState(CACHE.get() == null, "called create() twice on the same request");
+ PerThreadCache cache = new PerThreadCache();
+ CACHE.set(cache);
+ return cache;
+ }
+
+ @Nullable
+ public static PerThreadCache get() {
+ return CACHE.get();
+ }
+
+ public static <T> T getOrCompute(Key<T> key, Supplier<T> loader) {
+ PerThreadCache cache = get();
+ return cache != null ? cache.get(key, loader) : loader.get();
+ }
+
+ private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(PER_THREAD_CACHE_SIZE);
+
+ private PerThreadCache() {}
+
+ /**
+ * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
+ * provided {@link Supplier}.
+ */
+ public <T> T get(Key<T> key, Supplier<T> loader) {
+ @SuppressWarnings("unchecked")
+ T value = (T) cache.get(key);
+ if (value == null) {
+ value = loader.get();
+ if (cache.size() < PER_THREAD_CACHE_SIZE) {
+ cache.put(key, value);
+ }
+ }
+ return value;
+ }
+
+ @Override
+ public void close() {
+ CACHE.remove();
+ }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 49df653..d10c139 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -34,6 +34,7 @@
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PeerDaemonUser;
import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.cache.PerThreadCache;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.inject.Inject;
@@ -80,8 +81,15 @@
@Override
public WithUser absentUser(Account.Id id) {
- IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
- return new WithUserImpl(identifiedUser);
+ requireNonNull(id, "user");
+ CurrentUser user = currentUser.get();
+ if (user.isIdentifiedUser() && id.equals(user.asIdentifiedUser().getAccountId())) {
+ // What looked liked an absent user is actually the current caller. Use the per-request
+ // singleton IdentifiedUser instead of constructing a new object to leverage caching in member
+ // variables of IdentifiedUser.
+ return new WithUserImpl(user.asIdentifiedUser());
+ }
+ return new WithUserImpl(identifiedUserFactory.create(requireNonNull(id, "user")));
}
@Override
@@ -101,7 +109,11 @@
public ForProject project(Project.NameKey project) {
try {
ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
- return projectControlFactory.create(user, state).asForProject();
+ ProjectControl control =
+ PerThreadCache.getOrCompute(
+ PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
+ () -> projectControlFactory.create(user, state));
+ return control.asForProject();
} catch (Exception e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
return FailedPermissionBackend.project(
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
index dac555d..3960813 100644
--- a/java/com/google/gerrit/server/query/change/GroupBackedUser.java
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -64,4 +64,9 @@
public String getLoggableName() {
return "GroupBackedUser with memberships: " + groups.getKnownGroups();
}
+
+ @Override
+ public Object getCacheKey() {
+ return groups.getKnownGroups();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/BUILD b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
new file mode 100644
index 0000000..e89e8d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+ srcs = glob(["*IT.java"]),
+ group = "server_permissions",
+ labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
new file mode 100644
index 0000000..d68d681
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2020 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.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.GroupBackedUser;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link GroupBackedUser} works as expected. */
+public class GroupBackedUserPermissionIT extends AbstractDaemonTest {
+ @Inject private ChangeOperations changeOperations;
+ @Inject private ProjectOperations projectOperations;
+ @Inject private PermissionBackend permissionBackend;
+ @Inject private ChangeNotes.Factory changeNotesFactory;
+
+ private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+ private final AccountGroup.UUID externalGroup = AccountGroup.uuid("testbackend:test");
+
+ @Before
+ public void setUp() {
+ // Allow only read on refs/heads/master by default
+ projectOperations
+ .project(allProjects)
+ .forUpdate()
+ .remove(permissionKey(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+ .update();
+ }
+
+ @Override
+ public Module createModule() {
+ /** Binding a {@link TestGroupBackend} to test adding external groups * */
+ return new AbstractModule() {
+ @Override
+ protected void configure() {
+ DynamicSet.bind(binder(), GroupBackend.class).toInstance(testGroupBackend);
+ }
+ };
+ }
+
+ @Test
+ public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+ Change.Id changeOnRefsMetaConfig =
+ changeOperations.newChange().project(project).branch("refs/meta/config").create();
+ // Check that only the change on the default branch is visible
+ assertThat(getVisibleRefNames(user))
+ .containsExactly(
+ "HEAD",
+ "refs/heads/master",
+ RefNames.changeMetaRef(changeOnMaster),
+ RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+ // Grant access
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+ .update();
+ // Check that both changes are visible now
+ assertThat(getVisibleRefNames(user))
+ .containsExactly(
+ "HEAD",
+ "refs/heads/master",
+ "refs/meta/config",
+ RefNames.changeMetaRef(changeOnMaster),
+ RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+ RefNames.changeMetaRef(changeOnRefsMetaConfig),
+ RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+ }
+
+ @Test
+ public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ // Check that refs/meta/config isn't visible by default
+ assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+ // Grant access
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+ .update();
+ // Check that refs/meta/config became visible
+ assertThat(getVisibleRefNames(user))
+ .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+ }
+
+ @Test
+ public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+ // Create a change that is not visible to members of 'externalGroup'
+ Change.Id invisibleChange =
+ changeOperations.newChange().project(project).branch("refs/meta/config").create();
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ AuthException thrown =
+ assertThrows(
+ AuthException.class,
+ () ->
+ permissionBackend
+ .user(user)
+ .change(changeNotesFactory.create(project, invisibleChange))
+ .check(ChangePermission.READ));
+ assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+ }
+
+ @Test
+ public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+ Change.Id changeId = changeOperations.newChange().project(project).create();
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ permissionBackend
+ .user(user)
+ .change(changeNotesFactory.create(project, changeId))
+ .check(ChangePermission.READ);
+ }
+
+ @Test
+ public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+ Change.Id changeId = changeOperations.newChange().project(project).create();
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ blockAnonymousRead();
+ permissionBackend
+ .user(user)
+ .change(changeNotesFactory.create(project, changeId))
+ .check(ChangePermission.READ);
+ }
+
+ private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+ try (Repository repo = repoManager.openRepository(project)) {
+ return permissionBackend.user(user).project(project)
+ .filter(
+ repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+ .stream()
+ .map(Ref::getName)
+ .collect(toImmutableList());
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
new file mode 100644
index 0000000..5d420d3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2018 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.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.util.function.Supplier;
+import org.junit.Test;
+
+public class PerThreadCacheTest {
+
+ @SuppressWarnings("TruthIncompatibleType")
+ @Test
+ public void key_respectsClass() {
+ assertThat(PerThreadCache.Key.create(String.class))
+ .isEqualTo(PerThreadCache.Key.create(String.class));
+ assertThat(PerThreadCache.Key.create(String.class))
+ .isNotEqualTo(
+ /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
+ Integer.class));
+ }
+
+ @Test
+ public void key_respectsIdentifiers() {
+ assertThat(PerThreadCache.Key.create(String.class, "id1"))
+ .isEqualTo(PerThreadCache.Key.create(String.class, "id1"));
+ assertThat(PerThreadCache.Key.create(String.class, "id1"))
+ .isNotEqualTo(PerThreadCache.Key.create(String.class, "id2"));
+ }
+
+ @Test
+ public void endToEndCache() {
+ try (PerThreadCache ignored = PerThreadCache.create()) {
+ PerThreadCache cache = PerThreadCache.get();
+ PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
+
+ String value1 = cache.get(key1, () -> "value1");
+ assertThat(value1).isEqualTo("value1");
+
+ Supplier<String> neverCalled =
+ () -> {
+ throw new IllegalStateException("this method must not be called");
+ };
+ assertThat(cache.get(key1, neverCalled)).isEqualTo("value1");
+ }
+ }
+
+ @Test
+ public void cleanUp() {
+ PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
+ try (PerThreadCache ignored = PerThreadCache.create()) {
+ PerThreadCache cache = PerThreadCache.get();
+ String value1 = cache.get(key, () -> "value1");
+ assertThat(value1).isEqualTo("value1");
+ }
+
+ // Create a second cache and assert that it is not connected to the first one.
+ // This ensures that the cleanup is actually working.
+ try (PerThreadCache ignored = PerThreadCache.create()) {
+ PerThreadCache cache = PerThreadCache.get();
+ String value1 = cache.get(key, () -> "value2");
+ assertThat(value1).isEqualTo("value2");
+ }
+ }
+
+ @Test
+ public void doubleInstantiationFails() {
+ try (PerThreadCache ignored = PerThreadCache.create()) {
+ IllegalStateException thrown =
+ assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
+ assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
+ }
+ }
+
+ @Test
+ public void enforceMaxSize() {
+ try (PerThreadCache cache = PerThreadCache.create()) {
+ // Fill the cache
+ for (int i = 0; i < 50; i++) {
+ PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, i);
+ cache.get(key, () -> "cached value");
+ }
+ // Assert that the value was not persisted
+ PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, 1000);
+ cache.get(key, () -> "new value");
+ String value = cache.get(key, () -> "directly served");
+ assertThat(value).isEqualTo("directly served");
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 1cdca1b..de23ef4 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -101,6 +101,11 @@
}
@Override
+ public Object getCacheKey() {
+ return new Object();
+ }
+
+ @Override
public boolean isIdentifiedUser() {
return true;
}
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 64f9392..81cb732 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -1198,6 +1198,11 @@
}
@Override
+ public Object getCacheKey() {
+ return new Object();
+ }
+
+ @Override
public Optional<String> getUserName() {
return Optional.ofNullable(username);
}
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index d6a3381..00e5794 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit d6a33818440eb20aca64a761f79652525b3eb060
+Subproject commit 00e57948f4f112c226028bc5c8d8fe60f770038f
diff --git a/plugins/replication b/plugins/replication
index 63fb2b4..a6a6ec5 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 63fb2b4ba85380d798acbdc076e8673353507569
+Subproject commit a6a6ec5982e41a0ee9bfe24a46be96d4f13fcaaa
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 8e8eaf3..807adef 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -190,6 +190,17 @@
INHERIT = 'INHERIT',
}
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export enum MergeStrategy {
+ RECURSIVE = 'recursive',
+ RESOLVE = 'resolve',
+ SIMPLE_TWO_WAY_IN_CORE = 'simple-two-way-in-core',
+ OURS = 'ours',
+ THEIRS = 'theirs',
+}
+
/*
* Enum for possible configured value in InheritedBooleanInfo.
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
deleted file mode 100644
index 20fe1a6..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ /dev/null
@@ -1,2353 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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.
- */
-import '@polymer/paper-tabs/paper-tabs.js';
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-change-star/gr-change-star.js';
-import '../../shared/gr-change-status/gr-change-status.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-editable-content/gr-editable-content.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-change-actions/gr-change-actions.js';
-import '../gr-change-metadata/gr-change-metadata.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-download-dialog/gr-download-dialog.js';
-import '../gr-file-list-header/gr-file-list-header.js';
-import '../gr-included-in-dialog/gr-included-in-dialog.js';
-import '../gr-messages-list/gr-messages-list.js';
-import '../gr-related-changes-list/gr-related-changes-list.js';
-import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-reply-dialog/gr-reply-dialog.js';
-import '../gr-thread-list/gr-thread-list.js';
-import '../gr-upload-help-dialog/gr-upload-help-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrEditConstants} from '../../edit/gr-edit-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {PrimaryTab, SecondaryTab} from '../../../constants/constants.js';
-import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {
- computeAllPatchSets,
- computeLatestPatchNum,
- fetchChangeUpdates,
- hasEditBasedOnCurrentPatchSet,
- hasEditPatchsetLoaded,
- patchNumEquals,
- SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
-import {EventType} from '../../plugins/gr-plugin-types.js';
-import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list.js';
-
-const CHANGE_ID_ERROR = {
- MISMATCH: 'mismatch',
- MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN =
- /^(Change-Id\:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
-
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-
-const REVIEWERS_REGEX = /^(R|CC)=/gm;
-const MIN_CHECK_INTERVAL_SECS = 0;
-
-// These are the same as the breakpoint set in CSS. Make sure both are changed
-// together.
-const BREAKPOINT_RELATED_SMALL = '50em';
-const BREAKPOINT_RELATED_MED = '75em';
-
-// In the event that the related changes medium width calculation is too close
-// to zero, provide some height.
-const MINIMUM_RELATED_MAX_HEIGHT = 100;
-
-const SMALL_RELATED_HEIGHT = 400;
-
-const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
-
-const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
-
-const MSG_PREFIX = '#message-';
-
-const ReloadToastMessage = {
- NEWER_REVISION: 'A newer patch set has been uploaded',
- RESTORED: 'This change has been restored',
- ABANDONED: 'This change has been abandoned',
- MERGED: 'This change has been merged',
- NEW_MESSAGE: 'There are new messages on this change',
-};
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-
-const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
-const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
-const SEND_REPLY_TIMING_LABEL = 'SendReply';
-// Making the tab names more unique in case a plugin adds one with same name
-const ROBOT_COMMENTS_LIMIT = 10;
-
-// types used in this file
-/**
- * Type for the custom event to switch tab.
- *
- * @typedef {Object} SwitchTabEventDetail
- * @property {?string} tab - name of the tab to set as active, from custom event
- * @property {?boolean} scrollIntoView - scroll into the tab afterwards, from custom event
- * @property {?number} value - index of tab to set as active, from paper-tabs event
- */
-
-/**
- * @extends PolymerElement
- */
-class GrChangeView extends KeyboardShortcutMixin(
- GestureEventListeners(LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
- * Fired if an error occurs when fetching the change data.
- *
- * @event page-error
- */
-
- /**
- * Fired if being logged in is required.
- *
- * @event show-auth-required
- */
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
- /** @type {?} */
- viewState: {
- type: Object,
- notify: true,
- value() { return {}; },
- observer: '_viewStateChanged',
- },
- backPage: String,
- hasParent: Boolean,
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- disableEdit: {
- type: Boolean,
- value: false,
- },
- disableDiffPrefs: {
- type: Boolean,
- value: false,
- },
- _diffPrefsDisabled: {
- type: Boolean,
- computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
- },
- _commentThreads: Array,
- // TODO(taoalpha): Consider replacing diffDrafts
- // with _draftCommentThreads everywhere, currently only
- // replaced in reply-dialoig
- _draftCommentThreads: {
- type: Array,
- },
- _robotCommentThreads: {
- type: Array,
- computed: '_computeRobotCommentThreads(_commentThreads,'
- + ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
- },
- /** @type {?} */
- _serverConfig: {
- type: Object,
- observer: '_startUpdateCheckTimer',
- },
- _diffPrefs: Object,
- _numFilesShown: {
- type: Number,
- value: DEFAULT_NUM_FILES_SHOWN,
- observer: '_numFilesShownChanged',
- },
- _account: {
- type: Object,
- value: {},
- },
- _prefs: Object,
- /** @type {?} */
- _changeComments: Object,
- _canStartReview: {
- type: Boolean,
- computed: '_computeCanStartReview(_change)',
- },
- /** @type {?} */
- _change: {
- type: Object,
- observer: '_changeChanged',
- },
- _revisionInfo: {
- type: Object,
- computed: '_getRevisionInfo(_change)',
- },
- /** @type {?} */
- _commitInfo: Object,
- _currentRevision: {
- type: Object,
- computed: '_computeCurrentRevision(_change.current_revision, ' +
- '_change.revisions)',
- observer: '_handleCurrentRevisionUpdate',
- },
- _files: Object,
- _changeNum: String,
- _diffDrafts: {
- type: Object,
- value() { return {}; },
- },
- _editingCommitMessage: {
- type: Boolean,
- value: false,
- },
- _hideEditCommitMessage: {
- type: Boolean,
- computed: '_computeHideEditCommitMessage(_loggedIn, ' +
- '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
- '_commitCollapsible)',
- },
- _diffAgainst: String,
- /** @type {?string} */
- _latestCommitMessage: {
- type: String,
- value: '',
- },
- _constants: {
- type: Object,
- value: {
- SecondaryTab,
- PrimaryTab,
- },
- },
- _messages: {
- type: Object,
- value: {
- NO_ROBOT_COMMENTS_THREADS_MSG,
- },
- },
- _lineHeight: Number,
- _changeIdCommitMessageError: {
- type: String,
- computed:
- '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
- },
- /** @type {?} */
- _patchRange: {
- type: Object,
- },
- _filesExpanded: String,
- _basePatchNum: String,
- _selectedRevision: Object,
- _currentRevisionActions: Object,
- _allPatchSets: {
- type: Array,
- computed: '_computeAllPatchSets(_change, _change.revisions.*)',
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- _loading: Boolean,
- /** @type {?} */
- _projectConfig: Object,
- _replyButtonLabel: {
- type: String,
- value: 'Reply',
- computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
- },
- _selectedPatchSet: String,
- _shownFileCount: Number,
- _initialLoadComplete: {
- type: Boolean,
- value: false,
- },
- _replyDisabled: {
- type: Boolean,
- value: true,
- computed: '_computeReplyDisabled(_serverConfig)',
- },
- _changeStatus: {
- type: String,
- computed: '_changeStatusString(_change)',
- },
- _changeStatuses: {
- type: String,
- computed:
- '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
- },
- /** If false, then the "Show more" button was used to expand. */
- _commitCollapsed: {
- type: Boolean,
- value: true,
- },
- /** Is the "Show more/less" button visible? */
- _commitCollapsible: {
- type: Boolean,
- computed: '_computeCommitCollapsible(_latestCommitMessage)',
- },
- _relatedChangesCollapsed: {
- type: Boolean,
- value: true,
- },
- /** @type {?number} */
- _updateCheckTimerHandle: Number,
- _editMode: {
- type: Boolean,
- computed: '_computeEditMode(_patchRange.*, params.*)',
- },
- _showRelatedToggle: {
- type: Boolean,
- value: false,
- observer: '_updateToggleContainerClass',
- },
- _parentIsCurrent: {
- type: Boolean,
- computed: '_isParentCurrent(_currentRevisionActions)',
- },
- _submitEnabled: {
- type: Boolean,
- computed: '_isSubmitEnabled(_currentRevisionActions)',
- },
-
- /** @type {?} */
- _mergeable: {
- type: Boolean,
- value: undefined,
- },
- _showFileTabContent: {
- type: Boolean,
- value: true,
- },
- /** @type {Array<string>} */
- _dynamicTabHeaderEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicTabContentEndpoints: {
- type: Array,
- },
- // The dynamic content of the plugin added tab
- _selectedTabPluginEndpoint: {
- type: String,
- },
- // The dynamic heading of the plugin added tab
- _selectedTabPluginHeader: {
- type: String,
- },
- _robotCommentsPatchSetDropdownItems: {
- type: Array,
- value() { return []; },
- computed: '_computeRobotCommentsPatchSetDropdownItems(_change, ' +
- '_commentThreads)',
- },
- _currentRobotCommentsPatchSet: {
- type: Number,
- },
-
- /**
- * @type {Array<string>} this is a two-element tuple to always
- * hold the current active tab for both primary and secondary tabs
- */
- _activeTabs: {
- type: Array,
- value: [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG],
- },
- _showAllRobotComments: {
- type: Boolean,
- value: false,
- },
- _showRobotCommentsButton: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_labelsChanged(_change.labels.*)',
- '_paramsAndChangeChanged(params, _change)',
- '_patchNumChanged(_patchRange.patchNum)',
- ];
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
- [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
- [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
- [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
- [Shortcut.OPEN_DOWNLOAD_DIALOG]:
- '_handleOpenDownloadDialogShortcut',
- [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
- [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
- [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
- [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
- [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
- [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
- [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
- [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
- [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
- [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
- [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
- '_handleDiffRightAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LATEST]:
- '_handleDiffBaseAgainstLatest',
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- connectedCallback() {
- super.connectedCallback();
- this._throttledToggleChangeStar = this._throttleWrap(e =>
- this._handleToggleChangeStar(e));
- }
-
- /** @override */
- created() {
- super.created();
-
- this.addEventListener('topic-changed',
- () => this._handleTopicChanged());
-
- this.addEventListener(
- // When an overlay is opened in a mobile viewport, the overlay has a full
- // screen view. When it has a full screen view, we do not want the
- // background to be scrollable. This will eliminate background scroll by
- // hiding most of the contents on the screen upon opening, and showing
- // again upon closing.
- 'fullscreen-overlay-opened',
- () => this._handleHideBackgroundContent());
-
- this.addEventListener('fullscreen-overlay-closed',
- () => this._handleShowBackgroundContent());
-
- this.addEventListener('diff-comments-modified',
- () => this._handleReloadCommentThreads());
-
- this.addEventListener('open-reply-dialog',
- e => this._openReplyDialog());
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getServerConfig().then(config => {
- this._serverConfig = config;
- });
-
- this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- if (loggedIn) {
- this.$.restAPI.getAccount().then(acct => {
- this._account = acct;
- });
- }
- this._setDiffViewMode();
- });
-
- getPluginLoader().awaitPluginsLoaded()
- .then(() => {
- this._dynamicTabHeaderEndpoints =
- getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
- this._dynamicTabContentEndpoints =
- getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
- if (this._dynamicTabContentEndpoints.length !==
- this._dynamicTabHeaderEndpoints.length) {
- console.warn('Different number of tab headers and tab content.');
- }
- })
- .then(() => this._initActiveTabs(this.params));
-
- this.addEventListener('comment-save', e => this._handleCommentSave(e));
- this.addEventListener('comment-refresh', e => this._reloadDrafts(e));
- this.addEventListener('comment-discard',
- e => this._handleCommentDiscard(e));
- this.addEventListener('change-message-deleted',
- () => this._reload());
- this.addEventListener('editable-content-save',
- e => this._handleCommitMessageSave(e));
- this.addEventListener('editable-content-cancel',
- e => this._handleCommitMessageCancel(e));
- this.addEventListener('open-fix-preview',
- e => this._onOpenFixPreview(e));
- this.addEventListener('close-fix-preview',
- e => this._onCloseFixPreview(e));
- this.listen(window, 'scroll', '_handleScroll');
- this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-
- this.addEventListener('show-primary-tab',
- e => this._setActivePrimaryTab(e));
- this.addEventListener('show-secondary-tab',
- e => this._setActiveSecondaryTab(e));
- this.addEventListener('reload', e => {
- e.stopPropagation();
- this._reload(/* opt_isLocationChange= */false,
- /* opt_clearPatchset= */e.detail && e.detail.clearPatchset);
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(window, 'scroll', '_handleScroll');
- this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-
- if (this._updateCheckTimerHandle) {
- this._cancelUpdateCheckTimer();
- }
- }
-
- get messagesList() {
- return this.shadowRoot.querySelector('gr-messages-list');
- }
-
- get threadList() {
- return this.shadowRoot.querySelector('gr-thread-list');
- }
-
- _changeStatusString(change) {
- return changeStatusString(change);
- }
-
- /**
- * @param {boolean=} opt_reset
- */
- _setDiffViewMode(opt_reset) {
- if (!opt_reset && this.viewState.diffViewMode) { return; }
-
- return this._getPreferences()
- .then( prefs => {
- if (!this.viewState.diffMode) {
- this.set('viewState.diffMode', prefs.default_diff_view);
- }
- })
- .then(() => {
- if (!this.viewState.diffMode) {
- this.set('viewState.diffMode', 'SIDE_BY_SIDE');
- }
- });
- }
-
- _onOpenFixPreview(e) {
- this.$.applyFixDialog.open(e);
- }
-
- _onCloseFixPreview(e) {
- this._reload();
- }
-
- _handleToggleDiffMode(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
- this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
- } else {
- this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
- }
- }
-
- _isTabActive(tab, activeTabs) {
- return activeTabs.includes(tab);
- }
-
- /**
- * Actual implementation of switching a tab
- *
- * @param {!HTMLElement} paperTabs - the parent tabs container
- * @param {!SwitchTabEventDetail} activeDetails
- */
- _setActiveTab(paperTabs, activeDetails) {
- const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
- const tabs = paperTabs.querySelectorAll('paper-tab');
- let activeIndex = -1;
- if (activeTabIndex !== undefined) {
- activeIndex = activeTabIndex;
- } else {
- for (let i = 0; i <= tabs.length; i++) {
- const tab = tabs[i];
- if (tab.dataset['name'] === activeTabName) {
- activeIndex = i;
- break;
- }
- }
- }
- if (activeIndex === -1) {
- console.warn('tab not found with given info', activeDetails);
- return;
- }
- const tabName = tabs[activeIndex].dataset['name'];
- if (scrollIntoView) {
- paperTabs.scrollIntoView();
- }
- if (paperTabs.selected !== activeIndex) {
- paperTabs.selected = activeIndex;
- this.reporting.reportInteraction('show-tab', {tabName});
- }
- return tabName;
- }
-
- /**
- * Changes active primary tab.
- *
- * @param {CustomEvent<SwitchTabEventDetail>} e
- */
- _setActivePrimaryTab(e) {
- const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
- const activeTabName = this._setActiveTab(primaryTabs, {
- activeTabName: e.detail.tab,
- activeTabIndex: e.detail.value,
- scrollIntoView: e.detail.scrollIntoView,
- });
- if (activeTabName) {
- this._activeTabs = [activeTabName, this._activeTabs[1]];
-
- // update plugin endpoint if its a plugin tab
- const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
- activeTabName);
- if (pluginIndex !== -1) {
- this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
- pluginIndex];
- this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
- pluginIndex];
- } else {
- this._selectedTabPluginEndpoint = '';
- this._selectedTabPluginHeader = '';
- }
- }
- }
-
- /**
- * Changes active secondary tab.
- *
- * @param {CustomEvent<SwitchTabEventDetail>} e
- */
- _setActiveSecondaryTab(e) {
- const secondaryTabs = this.shadowRoot.querySelector('#secondaryTabs');
- const activeTabName = this._setActiveTab(secondaryTabs, {
- activeTabName: e.detail.tab,
- activeTabIndex: e.detail.value,
- scrollIntoView: e.detail.scrollIntoView,
- });
- if (activeTabName) {
- this._activeTabs = [this._activeTabs[0], activeTabName];
- }
- }
-
- _handleEditCommitMessage() {
- this._editingCommitMessage = true;
- this.$.commitMessageEditor.focusTextarea();
- }
-
- _handleCommitMessageSave(e) {
- // Trim trailing whitespace from each line.
- const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
-
- this.$.jsAPI.handleCommitMessage(this._change, message);
-
- this.$.commitMessageEditor.disabled = true;
- this.$.restAPI.putChangeCommitMessage(
- this._changeNum, message)
- .then(resp => {
- this.$.commitMessageEditor.disabled = false;
- if (!resp.ok) { return; }
-
- this._latestCommitMessage = this._prepareCommitMsgForLinkify(
- message);
- this._editingCommitMessage = false;
- this._reloadWindow();
- })
- .catch(err => {
- this.$.commitMessageEditor.disabled = false;
- });
- }
-
- _reloadWindow() {
- window.location.reload();
- }
-
- _handleCommitMessageCancel(e) {
- this._editingCommitMessage = false;
- }
-
- _computeChangeStatusChips(change, mergeable, submitEnabled) {
- // Polymer 2: check for undefined
- if ([
- change,
- mergeable,
- ].includes(undefined)) {
- // To keep consistent with Polymer 1, we are returning undefined
- // if not all dependencies are defined
- return undefined;
- }
-
- // Show no chips until mergeability is loaded.
- if (mergeable === null) {
- return [];
- }
-
- const options = {
- includeDerived: true,
- mergeable: !!mergeable,
- submitEnabled: !!submitEnabled,
- };
- return changeStatuses(change, options);
- }
-
- _computeHideEditCommitMessage(
- loggedIn, editing, change, editMode, collapsed, collapsible) {
- if (!loggedIn || editing ||
- (change && change.status === ChangeStatus.MERGED) ||
- editMode ||
- (collapsed && collapsible)) {
- return true;
- }
-
- return false;
- }
-
- _robotCommentCountPerPatchSet(threads) {
- return threads.reduce((robotCommentCountMap, thread) => {
- const comments = thread.comments;
- const robotCommentsCount = comments.reduce((acc, comment) =>
- (comment.robot_id ? acc + 1 : acc), 0);
- robotCommentCountMap[comments[0].patch_set] =
- (robotCommentCountMap[comments[0].patch_set] || 0) +
- robotCommentsCount;
- return robotCommentCountMap;
- }, {});
- }
-
- _computeText(patch, commentThreads) {
- const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
- const commentCnt = commentCount[patch._number] || 0;
- if (commentCnt === 0) return `Patchset ${patch._number}`;
- const findingsText = commentCnt === 1 ? 'finding' : 'findings';
- return `Patchset ${patch._number}`
- + ` (${commentCnt} ${findingsText})`;
- }
-
- _computeRobotCommentsPatchSetDropdownItems(change, commentThreads) {
- if (!change || !commentThreads || !change.revisions) return [];
-
- return Object.values(change.revisions)
- .filter(patch => patch._number !== 'edit')
- .map(patch => {
- return {
- text: this._computeText(patch, commentThreads),
- value: patch._number,
- };
- })
- .sort((a, b) => b.value - a.value);
- }
-
- _handleCurrentRevisionUpdate(currentRevision) {
- this._currentRobotCommentsPatchSet = currentRevision._number;
- }
-
- _handleRobotCommentPatchSetChanged(e) {
- const patchSet = parseInt(e.detail.value);
- if (patchSet === this._currentRobotCommentsPatchSet) return;
- this._currentRobotCommentsPatchSet = patchSet;
- }
-
- _computeShowText(showAllRobotComments) {
- return showAllRobotComments ? 'Show Less' : 'Show more';
- }
-
- _toggleShowRobotComments() {
- this._showAllRobotComments = !this._showAllRobotComments;
- }
-
- _computeRobotCommentThreads(commentThreads, currentRobotCommentsPatchSet,
- showAllRobotComments) {
- if (!commentThreads || !currentRobotCommentsPatchSet) return [];
- const threads = commentThreads.filter(thread => {
- const comments = thread.comments || [];
- return comments.length && comments[0].robot_id && (comments[0].patch_set
- === currentRobotCommentsPatchSet);
- });
- this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
- return threads.slice(0, showAllRobotComments ? undefined :
- ROBOT_COMMENTS_LIMIT);
- }
-
- _handleReloadCommentThreads() {
- // Get any new drafts that have been saved in the diff view and show
- // in the comment thread view.
- this._reloadDrafts().then(() => {
- this._commentThreads = this._changeComments.getAllThreadsForChange();
- flush();
- });
- }
-
- _handleReloadDiffComments(e) {
- // Keeps the file list counts updated.
- this._reloadDrafts().then(() => {
- // Get any new drafts that have been saved in the thread view and show
- // in the diff view.
- this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
- e.detail.path);
- flush();
- });
- }
-
- _computeTotalCommentCounts(unresolvedCount, changeComments) {
- if (!changeComments) return undefined;
- const draftCount = changeComments.computeDraftCount();
- const unresolvedString = GrCountStringFormatter.computeString(
- unresolvedCount, 'unresolved');
- const draftString = GrCountStringFormatter.computePluralString(
- draftCount, 'draft');
-
- return unresolvedString +
- // Add a comma and space if both unresolved and draft comments exist.
- (unresolvedString && draftString ? ', ' : '') +
- draftString;
- }
-
- _handleCommentSave(e) {
- const draft = e.detail.comment;
- if (!draft.__draft) { return; }
-
- draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
- // The use of path-based notification helpers (set, push) can’t be used
- // because the paths could contain dots in them. A new object must be
- // created to satisfy Polymer’s dirty checking.
- // https://github.com/Polymer/polymer/issues/3127
- const diffDrafts = {...this._diffDrafts};
- if (!diffDrafts[draft.path]) {
- diffDrafts[draft.path] = [draft];
- this._diffDrafts = diffDrafts;
- return;
- }
- for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
- if (this._diffDrafts[draft.path][i].id === draft.id) {
- diffDrafts[draft.path][i] = draft;
- this._diffDrafts = diffDrafts;
- return;
- }
- }
- diffDrafts[draft.path].push(draft);
- diffDrafts[draft.path].sort((c1, c2) =>
- // No line number means that it’s a file comment. Sort it above the
- // others.
- (c1.line || -1) - (c2.line || -1)
- );
- this._diffDrafts = diffDrafts;
- }
-
- _handleCommentDiscard(e) {
- const draft = e.detail.comment;
- if (!draft.__draft) { return; }
-
- if (!this._diffDrafts[draft.path]) {
- return;
- }
- let index = -1;
- for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
- if (this._diffDrafts[draft.path][i].id === draft.id) {
- index = i;
- break;
- }
- }
- if (index === -1) {
- // It may be a draft that hasn’t been added to _diffDrafts since it was
- // never saved.
- return;
- }
-
- draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
- // The use of path-based notification helpers (set, push) can’t be used
- // because the paths could contain dots in them. A new object must be
- // created to satisfy Polymer’s dirty checking.
- // https://github.com/Polymer/polymer/issues/3127
- const diffDrafts = {...this._diffDrafts};
- diffDrafts[draft.path].splice(index, 1);
- if (diffDrafts[draft.path].length === 0) {
- delete diffDrafts[draft.path];
- }
- this._diffDrafts = diffDrafts;
- }
-
- _handleReplyTap(e) {
- e.preventDefault();
- this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
- }
-
- _handleOpenDiffPrefs() {
- this.$.fileList.openDiffPrefs();
- }
-
- _handleOpenIncludedInDialog() {
- this.$.includedInDialog.loadData().then(() => {
- flush();
- this.$.includedInOverlay.refit();
- });
- this.$.includedInOverlay.open();
- }
-
- _handleIncludedInDialogClose(e) {
- this.$.includedInOverlay.close();
- }
-
- _handleOpenDownloadDialog() {
- this.$.downloadOverlay.open().then(() => {
- this.$.downloadOverlay
- .setFocusStops(this.$.downloadDialog.getFocusStops());
- this.$.downloadDialog.focus();
- });
- }
-
- _handleDownloadDialogClose(e) {
- this.$.downloadOverlay.close();
- }
-
- _handleOpenUploadHelpDialog(e) {
- this.$.uploadHelpOverlay.open();
- }
-
- _handleCloseUploadHelpDialog(e) {
- this.$.uploadHelpOverlay.close();
- }
-
- _handleMessageReply(e) {
- const msg = e.detail.message.message;
- const quoteStr = msg.split('\n').map(
- line => '> ' + line)
- .join('\n') + '\n\n';
- this.$.replyDialog.quote = quoteStr;
- this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
- }
-
- _handleHideBackgroundContent() {
- this.$.mainContent.classList.add('overlayOpen');
- }
-
- _handleShowBackgroundContent() {
- this.$.mainContent.classList.remove('overlayOpen');
- }
-
- _handleReplySent(e) {
- this.addEventListener('change-details-loaded',
- () => {
- this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
- }, {once: true});
- this.$.replyOverlay.close();
- this._reload();
- }
-
- _handleReplyCancel(e) {
- this.$.replyOverlay.close();
- }
-
- _handleReplyAutogrow(e) {
- // If the textarea resizes, we need to re-fit the overlay.
- this.debounce('reply-overlay-refit', () => {
- this.$.replyOverlay.refit();
- }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS);
- }
-
- _handleShowReplyDialog(e) {
- let target = this.$.replyDialog.FocusTarget.REVIEWERS;
- if (e.detail.value && e.detail.value.ccsOnly) {
- target = this.$.replyDialog.FocusTarget.CCS;
- }
- this._openReplyDialog(target);
- }
-
- _handleScroll() {
- this.debounce('scroll', () => {
- this.viewState.scrollTop = document.body.scrollTop;
- }, 150);
- }
-
- _setShownFiles(e) {
- this._shownFileCount = e.detail.length;
- }
-
- _expandAllDiffs(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- this.$.fileList.expandAllDiffs();
- }
-
- _collapseAllDiffs() {
- this.$.fileList.collapseAllDiffs();
- }
-
- _paramsChanged(value) {
- if (value.view !== GerritNav.View.CHANGE) {
- this._initialLoadComplete = false;
- return;
- }
-
- if (value.changeNum && value.project) {
- this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
- }
-
- const patchChanged = this._patchRange &&
- (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
- (this._patchRange.patchNum !== value.patchNum ||
- this._patchRange.basePatchNum !== value.basePatchNum);
- const changeChanged = this._changeNum !== value.changeNum;
-
- const patchRange = {
- patchNum: value.patchNum,
- basePatchNum: value.basePatchNum || 'PARENT',
- };
- // TODO(TS): remove once proper type for patchRange is defined
- if (!isNaN(Number(patchRange.patchNum))) {
- patchRange.patchNum = Number(patchRange.patchNum);
- }
- if (!isNaN(Number(patchRange.basePatchNum))) {
- patchRange.basePatchNum = Number(patchRange.basePatchNum);
- }
-
- this.$.fileList.collapseAllDiffs();
- this._patchRange = patchRange;
-
- // If the change has already been loaded and the parameter change is only
- // in the patch range, then don't do a full reload.
- if (!changeChanged && patchChanged) {
- if (patchRange.patchNum == null) {
- patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
- }
- this._reloadPatchNumDependentResources().then(() => {
- this._sendShowChangeEvent();
- });
- return;
- }
-
- this._initialLoadComplete = false;
- this._changeNum = value.changeNum;
- this.$.relatedChanges.clear();
-
- this._reload(true).then(() => {
- this._performPostLoadTasks();
- });
-
- getPluginLoader().awaitPluginsLoaded()
- .then(() => {
- this._initActiveTabs(value);
- });
- }
-
- _initActiveTabs(params = {}) {
- let primaryTab = PrimaryTab.FILES;
- if (params.queryMap && params.queryMap.has('tab')) {
- primaryTab = params.queryMap.get('tab');
- }
- this._setActivePrimaryTab({
- detail: {
- tab: primaryTab,
- },
- });
- this._setActiveSecondaryTab({
- detail: {
- tab: SecondaryTab.CHANGE_LOG,
- },
- });
- }
-
- _sendShowChangeEvent() {
- this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
- change: this._change,
- patchNum: this._patchRange.patchNum,
- info: {mergeable: this._mergeable},
- });
- }
-
- _performPostLoadTasks() {
- this._maybeShowReplyDialog();
- this._maybeShowRevertDialog();
- this._maybeShowDownloadDialog();
-
- this._sendShowChangeEvent();
-
- this.async(() => {
- if (this.viewState.scrollTop) {
- document.documentElement.scrollTop =
- document.body.scrollTop = this.viewState.scrollTop;
- } else {
- this._maybeScrollToMessage(window.location.hash);
- }
- this._initialLoadComplete = true;
- });
- }
-
- _paramsAndChangeChanged(value, change) {
- // Polymer 2: check for undefined
- if ([value, change].includes(undefined)) {
- return;
- }
-
- // If the change number or patch range is different, then reset the
- // selected file index.
- const patchRangeState = this.viewState.patchRange;
- if (this.viewState.changeNum !== this._changeNum ||
- !patchRangeState ||
- patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
- patchRangeState.patchNum !== this._patchRange.patchNum) {
- this._resetFileListViewState();
- }
- }
-
- _viewStateChanged(viewState) {
- this._numFilesShown = viewState.numFilesShown ?
- viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
- }
-
- _numFilesShownChanged(numFilesShown) {
- this.viewState.numFilesShown = numFilesShown;
- }
-
- _handleMessageAnchorTap(e) {
- const hash = MSG_PREFIX + e.detail.id;
- const url = GerritNav.getUrlForChange(this._change,
- this._patchRange.patchNum, this._patchRange.basePatchNum,
- this._editMode, hash);
- history.replaceState(null, '', url);
- }
-
- _maybeScrollToMessage(hash) {
- if (hash.startsWith(MSG_PREFIX)) {
- this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
- }
- }
-
- _getLocationSearch() {
- // Not inlining to make it easier to test.
- return window.location.search;
- }
-
- _getUrlParameter(param) {
- const pageURL = this._getLocationSearch().substring(1);
- const vars = pageURL.split('&');
- for (let i = 0; i < vars.length; i++) {
- const name = vars[i].split('=');
- if (name[0] == param) {
- return name[0];
- }
- }
- return null;
- }
-
- _maybeShowRevertDialog() {
- getPluginLoader().awaitPluginsLoaded()
- .then(() => this._getLoggedIn())
- .then(loggedIn => {
- if (!loggedIn || !this._change ||
- this._change.status !== ChangeStatus.MERGED) {
- // Do not display dialog if not logged-in or the change is not
- // merged.
- return;
- }
- if (this._getUrlParameter('revert')) {
- this.$.actions.showRevertDialog();
- }
- });
- }
-
- _maybeShowReplyDialog() {
- this._getLoggedIn().then(loggedIn => {
- if (!loggedIn) { return; }
-
- if (this.viewState.showReplyDialog) {
- this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
- // TODO(kaspern@): Find a better signal for when to call center.
- this.async(() => { this.$.replyOverlay.center(); }, 100);
- this.async(() => { this.$.replyOverlay.center(); }, 1000);
- this.set('viewState.showReplyDialog', false);
- }
- });
- }
-
- _maybeShowDownloadDialog() {
- if (this.viewState.showDownloadDialog) {
- this._handleOpenDownloadDialog();
- this.set('viewState.showDownloadDialog', false);
- }
- }
-
- _resetFileListViewState() {
- this.set('viewState.selectedFileIndex', 0);
- this.set('viewState.scrollTop', 0);
- if (!!this.viewState.changeNum &&
- this.viewState.changeNum !== this._changeNum) {
- // Reset the diff mode to null when navigating from one change to
- // another, so that the user's preference is restored.
- this._setDiffViewMode(true);
- this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
- }
- this.set('viewState.changeNum', this._changeNum);
- this.set('viewState.patchRange', this._patchRange);
- }
-
- _changeChanged(change) {
- if (!change || !this._patchRange || !this._allPatchSets) { return; }
-
- // We get the parent first so we keep the original value for basePatchNum
- // and not the updated value.
- const parent = this._getBasePatchNum(change, this._patchRange);
-
- this.set('_patchRange.patchNum', this._patchRange.patchNum ||
- computeLatestPatchNum(this._allPatchSets));
-
- this.set('_patchRange.basePatchNum', parent);
-
- const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title},
- composed: true, bubbles: true,
- }));
- }
-
- /**
- * Gets base patch number, if it is a parent try and decide from
- * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
- *
- * @param {Object} change
- * @param {Object} patchRange
- * @return {number|string}
- */
- _getBasePatchNum(change, patchRange) {
- if (patchRange.basePatchNum &&
- patchRange.basePatchNum !== 'PARENT') {
- return patchRange.basePatchNum;
- }
-
- const revisionInfo = this._getRevisionInfo(change);
- if (!revisionInfo) return 'PARENT';
-
- const parentCounts = revisionInfo.getParentCountMap();
- // check that there is at least 2 parents otherwise fall back to 1,
- // which means there is only one parent.
- const parentCount = parentCounts.hasOwnProperty(1) ?
- parentCounts[1] : 1;
-
- const preferFirst = this._prefs &&
- this._prefs.default_base_for_merges === 'FIRST_PARENT';
-
- if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
- return -1;
- }
-
- return 'PARENT';
- }
-
- _computeChangeUrl(change) {
- return GerritNav.getUrlForChange(change);
- }
-
- _computeShowCommitInfo(changeStatus, current_revision) {
- return changeStatus === 'Merged' && current_revision;
- }
-
- _computeMergedCommitInfo(current_revision, revisions) {
- const rev = revisions[current_revision];
- if (!rev || !rev.commit) { return {}; }
- // CommitInfo.commit is optional. Set commit in all cases to avoid error
- // in <gr-commit-info>. @see Issue 5337
- if (!rev.commit.commit) { rev.commit.commit = current_revision; }
- return rev.commit;
- }
-
- _computeChangeIdClass(displayChangeId) {
- return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
- }
-
- _computeTitleAttributeWarning(displayChangeId) {
- if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
- return 'Change-Id mismatch';
- } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
- return 'No Change-Id in commit message';
- }
- }
-
- _computeChangeIdCommitMessageError(commitMessage, change) {
- // Polymer 2: check for undefined
- if ([commitMessage, change].includes(undefined)) {
- return undefined;
- }
-
- if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
-
- // Find the last match in the commit message:
- let changeId;
- let changeIdArr;
-
- while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
- changeId = changeIdArr[2];
- }
-
- if (changeId) {
- // A change-id is detected in the commit message.
-
- if (changeId === change.change_id) {
- // The change-id found matches the real change-id.
- return null;
- }
- // The change-id found does not match the change-id.
- return CHANGE_ID_ERROR.MISMATCH;
- }
- // There is no change-id in the commit message.
- return CHANGE_ID_ERROR.MISSING;
- }
-
- _computeLabelNames(labels) {
- return Object.keys(labels).sort();
- }
-
- _computeLabelValues(labelName, labels) {
- const result = [];
- const t = labels[labelName];
- if (!t) { return result; }
- const approvals = t.all || [];
- for (const label of approvals) {
- if (label.value && label.value != labels[labelName].default_value) {
- let labelClassName;
- let labelValPrefix = '';
- if (label.value > 0) {
- labelValPrefix = '+';
- labelClassName = 'approved';
- } else if (label.value < 0) {
- labelClassName = 'notApproved';
- }
- result.push({
- value: labelValPrefix + label.value,
- className: labelClassName,
- account: label,
- });
- }
- }
- return result;
- }
-
- _computeReplyButtonLabel(changeRecord, canStartReview) {
- // Polymer 2: check for undefined
- if ([changeRecord, canStartReview].includes(undefined)) {
- return 'Reply';
- }
-
- const drafts = (changeRecord && changeRecord.base) || {};
- const draftCount = Object.keys(drafts)
- .reduce((count, file) => count + drafts[file].length, 0);
-
- let label = canStartReview ? 'Start Review' : 'Reply';
- if (draftCount > 0) {
- label += ' (' + draftCount + ')';
- }
- return label;
- }
-
- _handleOpenReplyDialog(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) {
- return;
- }
- this._getLoggedIn().then(isLoggedIn => {
- if (!isLoggedIn) {
- this.dispatchEvent(new CustomEvent('show-auth-required', {
- composed: true, bubbles: true,
- }));
- return;
- }
-
- e.preventDefault();
- this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
- });
- }
-
- _handleOpenDownloadDialogShortcut(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._handleOpenDownloadDialog();
- }
-
- _handleEditTopic(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.metadata.editTopic();
- }
-
- _handleDiffAgainstBase(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Base is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLeft(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Left is already base.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
- }
-
- _handleDiffAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Latest is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum,
- this._patchRange.basePatchNum);
- }
-
- _handleDiffRightAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Right is already latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum,
- this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
- patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Already diffing base against latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum);
- }
-
- _handleRefreshChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- e.preventDefault();
- this._reload(/* opt_isLocationChange= */false,
- /* opt_clearPatchset= */true);
- }
-
- _handleToggleChangeStar(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
- this.$.changeStar.toggleStar();
- }
-
- _handleUpToDashboard(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._determinePageBack();
- }
-
- _handleExpandAllMessages(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.messagesList.handleExpandCollapse(true);
- }
-
- _handleCollapseAllMessages(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.messagesList.handleExpandCollapse(false);
- }
-
- _handleOpenDiffPrefsShortcut(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- if (this._diffPrefsDisabled) { return; }
-
- e.preventDefault();
- this.$.fileList.openDiffPrefs();
- }
-
- _determinePageBack() {
- // Default backPage to root if user came to change view page
- // via an email link, etc.
- GerritNav.navigateToRelativeUrl(this.backPage ||
- GerritNav.getUrlForRoot());
- }
-
- _handleLabelRemoved(splices, path) {
- for (const splice of splices) {
- for (const removed of splice.removed) {
- const changePath = path.split('.');
- const labelPath = changePath.splice(0, changePath.length - 2);
- const labelDict = this.get(labelPath);
- if (labelDict.approved &&
- labelDict.approved._account_id === removed._account_id) {
- this._reload();
- return;
- }
- }
- }
- }
-
- _labelsChanged(changeRecord) {
- if (!changeRecord) { return; }
- if (changeRecord.value && changeRecord.value.indexSplices) {
- this._handleLabelRemoved(changeRecord.value.indexSplices,
- changeRecord.path);
- }
- this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
- change: this._change,
- });
- }
-
- /**
- * @param {string=} opt_section
- */
- _openReplyDialog(opt_section) {
- this.$.replyOverlay.open().finally(() => {
- // the following code should be executed no matter open succeed or not
- this._resetReplyOverlayFocusStops();
- this.$.replyDialog.open(opt_section);
- flush();
- this.$.replyOverlay.center();
- });
- }
-
- _handleGetChangeDetailError(response) {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getServerConfig() {
- return this.$.restAPI.getConfig();
- }
-
- _getProjectConfig() {
- if (!this._change) return;
- return this.$.restAPI.getProjectConfig(this._change.project).then(
- config => {
- this._projectConfig = config;
- });
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _prepareCommitMsgForLinkify(msg) {
- // TODO(wyatta) switch linkify sequence, see issue 5526.
- // This is a zero-with space. It is added to prevent the linkify library
- // from including R= or CC= as part of the email address.
- return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
- }
-
- /**
- * Utility function to make the necessary modifications to a change in the
- * case an edit exists.
- *
- * @param {!Object} change
- * @param {?Object} edit
- */
- _processEdit(change, edit) {
- if (!edit) { return; }
- change.revisions[edit.commit.commit] = {
- _number: SPECIAL_PATCH_SET_NUM.EDIT,
- basePatchNum: edit.base_patch_set_number,
- commit: edit.commit,
- fetch: edit.fetch,
- };
- // If the edit is based on the most recent patchset, load it by
- // default, unless another patch set to load was specified in the URL.
- if (!this._patchRange.patchNum &&
- change.current_revision === edit.base_revision) {
- change.current_revision = edit.commit.commit;
- this.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
- // Because edits are fibbed as revisions and added to the revisions
- // array, and revision actions are always derived from the 'latest'
- // patch set, we must copy over actions from the patch set base.
- // Context: Issue 7243
- change.revisions[edit.commit.commit].actions =
- change.revisions[edit.base_revision].actions;
- }
- }
-
- _getChangeDetail() {
- const detailCompletes = this.$.restAPI.getChangeDetail(
- this._changeNum, r => this._handleGetChangeDetailError(r));
- const editCompletes = this._getEdit();
- const prefCompletes = this._getPreferences();
-
- return Promise.all([detailCompletes, editCompletes, prefCompletes])
- .then(([change, edit, prefs]) => {
- this._prefs = prefs;
-
- if (!change) {
- return '';
- }
- this._processEdit(change, edit);
- // Issue 4190: Coalesce missing topics to null.
- if (!change.topic) { change.topic = null; }
- if (!change.reviewer_updates) {
- change.reviewer_updates = null;
- }
- const latestRevisionSha = this._getLatestRevisionSHA(change);
- const currentRevision = change.revisions[latestRevisionSha];
- if (currentRevision.commit && currentRevision.commit.message) {
- this._latestCommitMessage = this._prepareCommitMsgForLinkify(
- currentRevision.commit.message);
- } else {
- this._latestCommitMessage = null;
- }
-
- const lineHeight = getComputedStyle(this).lineHeight;
-
- // Slice returns a number as a string, convert to an int.
- this._lineHeight =
- parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
-
- this._change = change;
- if (!this._patchRange || !this._patchRange.patchNum ||
- patchNumEquals(this._patchRange.patchNum,
- currentRevision._number)) {
- // CommitInfo.commit is optional, and may need patching.
- if (!currentRevision.commit.commit) {
- currentRevision.commit.commit = latestRevisionSha;
- }
- this._commitInfo = currentRevision.commit;
- this._selectedRevision = currentRevision;
- // TODO: Fetch and process files.
- } else {
- this._selectedRevision =
- Object.values(this._change.revisions).find(
- revision => {
- // edit patchset is a special one
- const thePatchNum = this._patchRange.patchNum;
- if (thePatchNum === 'edit') {
- return revision._number === thePatchNum;
- }
- return revision._number === parseInt(thePatchNum, 10);
- });
- }
- });
- }
-
- _isSubmitEnabled(revisionActions) {
- return !!(revisionActions && revisionActions.submit &&
- revisionActions.submit.enabled);
- }
-
- _isParentCurrent(revisionActions) {
- if (revisionActions && revisionActions.rebase) {
- return !revisionActions.rebase.enabled;
- } else {
- return true;
- }
- }
-
- _getEdit() {
- return this.$.restAPI.getChangeEdit(this._changeNum, true);
- }
-
- _getLatestCommitMessage() {
- return this.$.restAPI.getChangeCommitInfo(this._changeNum,
- computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
- if (!commitInfo) return Promise.resolve();
- this._latestCommitMessage =
- this._prepareCommitMsgForLinkify(commitInfo.message);
- });
- }
-
- _getLatestRevisionSHA(change) {
- if (change.current_revision) {
- return change.current_revision;
- }
- // current_revision may not be present in the case where the latest rev is
- // a draft and the user doesn’t have permission to view that rev.
- let latestRev = null;
- let latestPatchNum = -1;
- for (const rev in change.revisions) {
- if (!change.revisions.hasOwnProperty(rev)) { continue; }
-
- if (change.revisions[rev]._number > latestPatchNum) {
- latestRev = rev;
- latestPatchNum = change.revisions[rev]._number;
- }
- }
- return latestRev;
- }
-
- _getCommitInfo() {
- return this.$.restAPI.getChangeCommitInfo(
- this._changeNum, this._patchRange.patchNum).then(
- commitInfo => {
- this._commitInfo = commitInfo;
- });
- }
-
- _reloadDraftsWithCallback(e) {
- return this._reloadDrafts().then(() => e.detail.resolve());
- }
-
- /**
- * Fetches a new changeComment object, and data for all types of comments
- * (comments, robot comments, draft comments) is requested.
- */
- _reloadComments() {
- // We are resetting all comment related properties, because we want to avoid
- // a new change being loaded and then paired with outdated comments.
- this._changeComments = undefined;
- this._commentThreads = undefined;
- this._diffDrafts = undefined;
- this._draftCommentThreads = undefined;
- this._robotCommentThreads = undefined;
- return this.$.commentAPI.loadAll(this._changeNum)
- .then(comments => this._recomputeComments(comments));
- }
-
- /**
- * Fetches a new changeComment object, but only updated data for drafts is
- * requested.
- *
- * TODO(taoalpha): clean up this and _reloadComments, as single comment
- * can be a thread so it does not make sense to only update drafts
- * without updating threads
- */
- _reloadDrafts() {
- return this.$.commentAPI.reloadDrafts(this._changeNum)
- .then(comments => this._recomputeComments(comments));
- }
-
- _recomputeComments(comments) {
- this._changeComments = comments;
- this._diffDrafts = {...this._changeComments.drafts};
- this._commentThreads = this._changeComments.getAllThreadsForChange();
- this._draftCommentThreads = this._commentThreads
- .filter(thread => thread.comments[thread.comments.length - 1].__draft)
- .map(thread => {
- const copiedThread = {...thread};
- // Make a hardcopy of all comments and collapse all but last one
- const commentsInThread = copiedThread.comments = thread.comments
- .map(comment => { return {...comment, collapsed: true}; });
- commentsInThread[commentsInThread.length - 1].collapsed = false;
- return copiedThread;
- });
- }
-
- /**
- * Reload the change.
- *
- * @param {boolean=} opt_isLocationChange Reloads the related changes
- * when true and ends reporting events that started on location change.
- * @param {boolean=} opt_clearPatchset Reloads the related changes
- * ignoring any patchset choice made.
- * @return {Promise} A promise that resolves when the core data has loaded.
- * Some non-core data loading may still be in-flight when the core data
- * promise resolves.
- */
- _reload(opt_isLocationChange, opt_clearPatchset) {
- if (opt_clearPatchset) {
- GerritNav.navigateToChange(this._change);
- return;
- }
- this._loading = true;
- this._relatedChangesCollapsed = true;
- this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
- this.reporting.time(CHANGE_DATA_TIMING_LABEL);
-
- // Array to house all promises related to data requests.
- const allDataPromises = [];
-
- // Resolves when the change detail and the edit patch set (if available)
- // are loaded.
- const detailCompletes = this._getChangeDetail();
- allDataPromises.push(detailCompletes);
-
- // Resolves when the loading flag is set to false, meaning that some
- // change content may start appearing.
- const loadingFlagSet = detailCompletes
- .then(() => {
- this._loading = false;
- this.dispatchEvent(new CustomEvent('change-details-loaded',
- {bubbles: true, composed: true}));
- })
- .then(() => {
- this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
- if (opt_isLocationChange) {
- this.reporting.changeDisplayed();
- }
- });
-
- // Resolves when the project config has loaded.
- const projectConfigLoaded = detailCompletes
- .then(() => this._getProjectConfig());
- allDataPromises.push(projectConfigLoaded);
-
- // Resolves when change comments have loaded (comments, drafts and robot
- // comments).
- const commentsLoaded = this._reloadComments();
- allDataPromises.push(commentsLoaded);
-
- let coreDataPromise;
-
- // If the patch number is specified
- if (this._patchRange && this._patchRange.patchNum) {
- // Because a specific patchset is specified, reload the resources that
- // are keyed by patch number or patch range.
- const patchResourcesLoaded = this._reloadPatchNumDependentResources();
- allDataPromises.push(patchResourcesLoaded);
-
- // Promise resolves when the change detail and patch dependent resources
- // have loaded.
- const detailAndPatchResourcesLoaded =
- Promise.all([patchResourcesLoaded, loadingFlagSet]);
-
- // Promise resolves when mergeability information has loaded.
- const mergeabilityLoaded = detailAndPatchResourcesLoaded
- .then(() => this._getMergeability());
- allDataPromises.push(mergeabilityLoaded);
-
- // Promise resovles when the change actions have loaded.
- const actionsLoaded = detailAndPatchResourcesLoaded
- .then(() => this.$.actions.reload());
- allDataPromises.push(actionsLoaded);
-
- // The core data is loaded when both mergeability and actions are known.
- coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
- } else {
- // Resolves when the file list has loaded.
- const fileListReload = loadingFlagSet
- .then(() => this.$.fileList.reload());
- allDataPromises.push(fileListReload);
-
- const latestCommitMessageLoaded = loadingFlagSet.then(() => {
- // If the latest commit message is known, there is nothing to do.
- if (this._latestCommitMessage) { return Promise.resolve(); }
- return this._getLatestCommitMessage();
- });
- allDataPromises.push(latestCommitMessageLoaded);
-
- // Promise resolves when mergeability information has loaded.
- const mergeabilityLoaded = loadingFlagSet
- .then(() => this._getMergeability());
- allDataPromises.push(mergeabilityLoaded);
-
- // Core data is loaded when mergeability has been loaded.
- coreDataPromise = mergeabilityLoaded;
- }
-
- if (opt_isLocationChange) {
- this._editingCommitMessage = false;
- const relatedChangesLoaded = coreDataPromise
- .then(() => this.$.relatedChanges.reload());
- allDataPromises.push(relatedChangesLoaded);
- }
-
- Promise.all(allDataPromises).then(() => {
- this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
- if (opt_isLocationChange) {
- this.reporting.changeFullyLoaded();
- }
- });
-
- return coreDataPromise;
- }
-
- /**
- * Kicks off requests for resources that rely on the patch range
- * (`this._patchRange`) being defined.
- */
- _reloadPatchNumDependentResources() {
- return Promise.all([
- this._getCommitInfo(),
- this.$.fileList.reload(),
- ]);
- }
-
- _getMergeability() {
- if (!this._change) {
- this._mergeable = null;
- return Promise.resolve();
- }
- // If the change is closed, it is not mergeable. Note: already merged
- // changes are obviously not mergeable, but the mergeability API will not
- // answer for abandoned changes.
- if (this._change.status === ChangeStatus.MERGED ||
- this._change.status === ChangeStatus.ABANDONED) {
- this._mergeable = false;
- return Promise.resolve();
- }
-
- this._mergeable = null;
- return this.$.restAPI.getMergeable(this._changeNum).then(m => {
- this._mergeable = m.mergeable;
- });
- }
-
- _computeCanStartReview(change) {
- return !!(change.actions && change.actions.ready &&
- change.actions.ready.enabled);
- }
-
- _computeReplyDisabled() { return false; }
-
- _computeChangePermalinkAriaLabel(changeNum) {
- return 'Change ' + changeNum;
- }
-
- _computeCommitMessageCollapsed(collapsed, collapsible) {
- return collapsible && collapsed;
- }
-
- _computeRelatedChangesClass(collapsed) {
- return collapsed ? 'collapsed' : '';
- }
-
- _computeCollapseText(collapsed) {
- // Symbols are up and down triangles.
- return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
- }
-
- /**
- * Returns the text to be copied when
- * click the copy icon next to change subject
- *
- * @param {!Object} change
- */
- _computeCopyTextForTitle(change) {
- return `${change._number}: ${change.subject} | ` +
- `${location.protocol}//${location.host}` +
- `${this._computeChangeUrl(change)}`;
- }
-
- _toggleCommitCollapsed() {
- this._commitCollapsed = !this._commitCollapsed;
- if (this._commitCollapsed) {
- window.scrollTo(0, 0);
- }
- }
-
- _toggleRelatedChangesCollapsed() {
- this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
- if (this._relatedChangesCollapsed) {
- window.scrollTo(0, 0);
- }
- }
-
- _computeCommitCollapsible(commitMessage) {
- if (!commitMessage) { return false; }
- return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
- }
-
- _getOffsetHeight(element) {
- return element.offsetHeight;
- }
-
- _getScrollHeight(element) {
- return element.scrollHeight;
- }
-
- /**
- * Get the line height of an element to the nearest integer.
- */
- _getLineHeight(element) {
- const lineHeightStr = getComputedStyle(element).lineHeight;
- return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
- }
-
- /**
- * New max height for the related changes section, shorter than the existing
- * change info height.
- */
- _updateRelatedChangeMaxHeight() {
- // Takes into account approximate height for the expand button and
- // bottom margin.
- const EXTRA_HEIGHT = 30;
- let newHeight;
-
- if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
- .matches) {
- // In a small (mobile) view, give the relation chain some space.
- newHeight = SMALL_RELATED_HEIGHT;
- } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
- .matches) {
- // Since related changes are below the commit message, but still next to
- // metadata, the height should be the height of the metadata minus the
- // height of the commit message to reduce jank. However, if that doesn't
- // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
- // Note: extraHeight is to take into account margin/padding.
- const medRelatedHeight = Math.max(
- this._getOffsetHeight(this.$.mainChangeInfo) -
- this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
- MINIMUM_RELATED_MAX_HEIGHT);
- newHeight = medRelatedHeight;
- } else {
- if (this._commitCollapsible) {
- // Make sure the content is lined up if both areas have buttons. If
- // the commit message is not collapsed, instead use the change info
- // height.
- newHeight = this._getOffsetHeight(this.$.commitMessage);
- } else {
- newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
- EXTRA_HEIGHT;
- }
- }
- const stylesToUpdate = {};
-
- // Get the line height of related changes, and convert it to the nearest
- // integer.
- const lineHeight = this._getLineHeight(this.$.relatedChanges);
-
- // Figure out a new height that is divisible by the rounded line height.
- const remainder = newHeight % lineHeight;
- newHeight = newHeight - remainder;
-
- stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
-
- // Update the max-height of the relation chain to this new height.
- if (this._commitCollapsible) {
- stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
- }
-
- this.updateStyles(stylesToUpdate);
- }
-
- _computeShowRelatedToggle() {
- // Make sure the max height has been applied, since there is now content
- // to populate.
- if (!getComputedStyleValue('--relation-chain-max-height', this)) {
- this._updateRelatedChangeMaxHeight();
- }
- // Prevents showMore from showing when click on related change, since the
- // line height would be positive, but related changes height is 0.
- if (!this._getScrollHeight(this.$.relatedChanges)) {
- return this._showRelatedToggle = false;
- }
-
- if (this._getScrollHeight(this.$.relatedChanges) >
- (this._getOffsetHeight(this.$.relatedChanges) +
- this._getLineHeight(this.$.relatedChanges))) {
- return this._showRelatedToggle = true;
- }
- this._showRelatedToggle = false;
- }
-
- _updateToggleContainerClass(showRelatedToggle) {
- if (showRelatedToggle) {
- this.$.relatedChangesToggle.classList.add('showToggle');
- } else {
- this.$.relatedChangesToggle.classList.remove('showToggle');
- }
- }
-
- _startUpdateCheckTimer() {
- if (!this._serverConfig ||
- !this._serverConfig.change ||
- this._serverConfig.change.update_delay === undefined ||
- this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
- return;
- }
-
- this._updateCheckTimerHandle = this.async(() => {
- const change = this._change;
- fetchChangeUpdates(change, this.$.restAPI).then(result => {
- let toastMessage = null;
- if (!result.isLatest) {
- toastMessage = ReloadToastMessage.NEWER_REVISION;
- } else if (result.newStatus === ChangeStatus.MERGED) {
- toastMessage = ReloadToastMessage.MERGED;
- } else if (result.newStatus === ChangeStatus.ABANDONED) {
- toastMessage = ReloadToastMessage.ABANDONED;
- } else if (result.newStatus === ChangeStatus.NEW) {
- toastMessage = ReloadToastMessage.RESTORED;
- } else if (result.newMessages) {
- toastMessage = ReloadToastMessage.NEW_MESSAGE;
- }
-
- // We have to make sure that the update is still relevant for the user.
- // Since starting to fetch the change update the user may have sent a
- // reply, or the change might have been reloaded, or it could be in the
- // process of being reloaded.
- const changeWasReloaded = change !== this._change;
- if (!toastMessage || this._loading || changeWasReloaded) {
- this._startUpdateCheckTimer();
- return;
- }
-
- this._cancelUpdateCheckTimer();
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: toastMessage,
- // Persist this alert.
- dismissOnNavigation: true,
- action: 'Reload',
- callback: () => {
- this._reload(/* opt_isLocationChange= */false,
- /* opt_clearPatchset= */true);
- },
- },
- composed: true, bubbles: true,
- }));
- });
- }, this._serverConfig.change.update_delay * 1000);
- }
-
- _cancelUpdateCheckTimer() {
- if (this._updateCheckTimerHandle) {
- this.cancelAsync(this._updateCheckTimerHandle);
- }
- this._updateCheckTimerHandle = null;
- }
-
- _handleVisibilityChange() {
- if (document.hidden && this._updateCheckTimerHandle) {
- this._cancelUpdateCheckTimer();
- } else if (!this._updateCheckTimerHandle) {
- this._startUpdateCheckTimer();
- }
- }
-
- _handleTopicChanged() {
- this.$.relatedChanges.reload();
- }
-
- _computeHeaderClass(editMode) {
- const classes = ['header'];
- if (editMode) { classes.push('editMode'); }
- return classes.join(' ');
- }
-
- _computeEditMode(patchRangeRecord, paramsRecord) {
- if ([patchRangeRecord, paramsRecord].includes(undefined)) {
- return undefined;
- }
-
- if (paramsRecord.base && paramsRecord.base.edit) { return true; }
-
- const patchRange = patchRangeRecord.base || {};
- return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
- }
-
- _handleFileActionTap(e) {
- e.preventDefault();
- const controls = this.$.fileListHeader
- .shadowRoot.querySelector('#editControls');
- const path = e.detail.path;
- switch (e.detail.action) {
- case GrEditConstants.Actions.DELETE.id:
- controls.openDeleteDialog(path);
- break;
- case GrEditConstants.Actions.OPEN.id:
- GerritNav.navigateToRelativeUrl(
- GerritNav.getEditUrlForDiff(this._change, path,
- this._patchRange.patchNum));
- break;
- case GrEditConstants.Actions.RENAME.id:
- controls.openRenameDialog(path);
- break;
- case GrEditConstants.Actions.RESTORE.id:
- controls.openRestoreDialog(path);
- break;
- }
- }
-
- _computeCommitMessageKey(number, revision) {
- return `c${number}_rev${revision}`;
- }
-
- _patchNumChanged(patchNumStr) {
- if (!this._selectedRevision) {
- return;
- }
-
- let patchNum = parseInt(patchNumStr, 10);
- if (patchNumStr === 'edit') {
- patchNum = patchNumStr;
- }
-
- if (patchNum === this._selectedRevision._number) {
- return;
- }
- this._selectedRevision = Object.values(this._change.revisions).find(
- revision => revision._number === patchNum);
- }
-
- /**
- * If an edit exists already, load it. Otherwise, toggle edit mode via the
- * navigation API.
- */
- _handleEditTap() {
- const editInfo = Object.values(this._change.revisions).find(info =>
- info._number === SPECIAL_PATCH_SET_NUM.EDIT);
-
- if (editInfo) {
- GerritNav.navigateToChange(this._change, SPECIAL_PATCH_SET_NUM.EDIT);
- return;
- }
-
- // Avoid putting patch set in the URL unless a non-latest patch set is
- // selected.
- let patchNum;
- if (!patchNumEquals(this._patchRange.patchNum,
- computeLatestPatchNum(this._allPatchSets))) {
- patchNum = this._patchRange.patchNum;
- }
- GerritNav.navigateToChange(this._change, patchNum, null, true);
- }
-
- _handleStopEditTap() {
- GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
- }
-
- _resetReplyOverlayFocusStops() {
- this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
- }
-
- _handleToggleStar(e) {
- this.$.restAPI.saveChangeStarred(e.detail.change._number,
- e.detail.starred);
- }
-
- _getRevisionInfo(change) {
- return new RevisionInfo(change);
- }
-
- _computeCurrentRevision(currentRevision, revisions) {
- return currentRevision && revisions && revisions[currentRevision];
- }
-
- _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
- return disableDiffPrefs || !loggedIn;
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeLatestPatchNum(allPatchSets) {
- return computeLatestPatchNum(allPatchSets);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _hasEditBasedOnCurrentPatchSet(allPatchSets) {
- return hasEditBasedOnCurrentPatchSet(allPatchSets);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _hasEditPatchsetLoaded(patchRangeRecord) {
- return hasEditPatchsetLoaded(patchRangeRecord);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeAllPatchSets(change) {
- return computeAllPatchSets(change);
- }
-}
-
-customElements.define(GrChangeView.is, GrChangeView);
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
new file mode 100644
index 0000000..68fb622
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -0,0 +1,2788 @@
+/**
+ * @license
+ * Copyright (C) 2015 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.
+ */
+import '@polymer/paper-tabs/paper-tabs';
+import '../../../styles/shared-styles';
+import '../../diff/gr-comment-api/gr-comment-api';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-change-star/gr-change-star';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-content/gr-editable-content';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../gr-change-actions/gr-change-actions';
+import '../gr-change-metadata/gr-change-metadata';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-download-dialog/gr-download-dialog';
+import '../gr-file-list-header/gr-file-list-header';
+import '../gr-included-in-dialog/gr-included-in-dialog';
+import '../gr-messages-list/gr-messages-list';
+import '../gr-related-changes-list/gr-related-changes-list';
+import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-reply-dialog/gr-reply-dialog';
+import '../gr-thread-list/gr-thread-list';
+import '../gr-upload-help-dialog/gr-upload-help-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-view_html';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+ CustomKeyboardEvent,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
+import {appContext} from '../../../services/app-context';
+import {ChangeStatus} from '../../../constants/constants';
+import {
+ computeAllPatchSets,
+ computeLatestPatchNum,
+ fetchChangeUpdates,
+ hasEditBasedOnCurrentPatchSet,
+ hasEditPatchsetLoaded,
+ patchNumEquals,
+ PatchSet,
+} from '../../../utils/patch-set-util';
+import {changeStatuses, changeStatusString} from '../../../utils/change-util';
+import {EventType} from '../../plugins/gr-plugin-types';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
+import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
+import {
+ AccountDetailInfo,
+ ChangeInfo,
+ NumericChangeId,
+ PatchRange,
+ ActionNameToActionInfoMap,
+ CommitId,
+ PatchSetNum,
+ ParentPatchSetNum,
+ EditPatchSetNum,
+ ServerInfo,
+ ConfigInfo,
+ PreferencesInfo,
+ CommitInfo,
+ DiffPreferencesInfo,
+ RevisionInfo,
+ EditInfo,
+ LabelNameToInfoMap,
+ UrlEncodedCommentId,
+ QuickLabelInfo,
+ ApprovalInfo,
+ ElementPropertyDeepChange,
+} from '../../../types/common';
+import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
+import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
+import {CommentEventDetail} from '../../shared/gr-comment/gr-comment';
+import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
+import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
+import {
+ GrCommentApi,
+ ChangeComments,
+} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {
+ CommentThread,
+ UIDraft,
+ DraftInfo,
+ isDraftThread,
+ isRobot,
+} from '../../../utils/comment-util';
+import {
+ PolymerDeepPropertyChange,
+ PolymerSpliceChange,
+ PolymerSplice,
+} from '@polymer/polymer/interfaces';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+ GrFileList,
+ DEFAULT_NUM_FILES_SHOWN,
+} from '../gr-file-list/gr-file-list';
+import {isPolymerSpliceChange} from '../../../types/types';
+
+const CHANGE_ID_ERROR = {
+ MISMATCH: 'mismatch',
+ MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN = /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+
+const REVIEWERS_REGEX = /^(R|CC)=/gm;
+const MIN_CHECK_INTERVAL_SECS = 0;
+
+// These are the same as the breakpoint set in CSS. Make sure both are changed
+// together.
+const BREAKPOINT_RELATED_SMALL = '50em';
+const BREAKPOINT_RELATED_MED = '75em';
+
+// In the event that the related changes medium width calculation is too close
+// to zero, provide some height.
+const MINIMUM_RELATED_MAX_HEIGHT = 100;
+
+const SMALL_RELATED_HEIGHT = 400;
+
+const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
+
+const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
+
+const MSG_PREFIX = '#message-';
+
+const ReloadToastMessage = {
+ NEWER_REVISION: 'A newer patch set has been uploaded',
+ RESTORED: 'This change has been restored',
+ ABANDONED: 'This change has been abandoned',
+ MERGED: 'This change has been merged',
+ NEW_MESSAGE: 'There are new messages on this change',
+};
+
+enum DiffViewMode {
+ SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+ UNIFIED = 'UNIFIED_DIFF',
+}
+
+const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
+const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+// Making the tab names more unique in case a plugin adds one with same name
+const ROBOT_COMMENTS_LIMIT = 10;
+
+// Type for the custom event to switch tab.
+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;
+ // scroll into the tab afterwards, from custom event
+ scrollIntoView?: boolean;
+}
+
+export interface ChangeViewState {
+ diffMode?: DiffViewMode;
+ scrollTop?: number;
+ showDownloadDialog?: boolean;
+ showReplyDialog?: boolean;
+ changeNum?: NumericChangeId;
+ numFilesShown?: number;
+ patchRange?: PatchRange;
+ diffViewMode?: boolean;
+}
+
+export interface GrChangeView {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: GrJsApiInterface;
+ commentAPI: GrCommentApi;
+ applyFixDialog: GrApplyFixDialog;
+ fileList: GrFileList & Element;
+ fileListHeader: GrFileListHeader;
+ commitMessageEditor: GrEditableContent;
+ includedInOverlay: GrOverlay;
+ includedInDialog: GrIncludedInDialog;
+ downloadOverlay: GrOverlay;
+ downloadDialog: GrDownloadDialog;
+ uploadHelpOverlay: GrOverlay;
+ replyOverlay: GrOverlay;
+ replyDialog: GrReplyDialog;
+ mainContent: HTMLDivElement;
+ relatedChanges: GrRelatedChangesList;
+ changeStar: GrChangeStar;
+ actions: GrChangeActions;
+ commitMessage: HTMLDivElement;
+ commitAndRelated: HTMLDivElement;
+ metadata: GrChangeMetadata;
+ relatedChangesToggle: HTMLDivElement;
+ mainChangeInfo: HTMLDivElement;
+ };
+}
+@customElement('gr-change-view')
+export class GrChangeView extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ /**
+ * Fired if an error occurs when fetching the change data.
+ *
+ * @event page-error
+ */
+
+ /**
+ * Fired if being logged in is required.
+ *
+ * @event show-auth-required
+ */
+
+ reporting = appContext.reportingService;
+
+ /**
+ * URL params passed from the router.
+ */
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementChangeViewParams;
+
+ @property({type: Object, notify: true, observer: '_viewStateChanged'})
+ viewState: ChangeViewState = {};
+
+ @property({type: String})
+ backPage?: string;
+
+ @property({type: Boolean})
+ hasParent?: boolean;
+
+ @property({type: Object})
+ keyEventTarget = document.body;
+
+ @property({type: Boolean})
+ disableEdit = false;
+
+ @property({type: Boolean})
+ disableDiffPrefs = false;
+
+ @property({
+ type: Boolean,
+ computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+ })
+ _diffPrefsDisabled?: boolean;
+
+ @property({type: Array})
+ _commentThreads?: CommentThread[];
+
+ // TODO(taoalpha): Consider replacing diffDrafts
+ // with _draftCommentThreads everywhere, currently only
+ // replaced in reply-dialog
+ @property({type: Array})
+ _draftCommentThreads?: CommentThread[];
+
+ @property({
+ type: Array,
+ computed:
+ '_computeRobotCommentThreads(_commentThreads,' +
+ ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
+ })
+ _robotCommentThreads?: CommentThread[];
+
+ @property({type: Object, observer: '_startUpdateCheckTimer'})
+ _serverConfig?: ServerInfo;
+
+ @property({type: Object})
+ _diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Number, observer: '_numFilesShownChanged'})
+ _numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+
+ @property({type: Object})
+ _account?: AccountDetailInfo;
+
+ @property({type: Object})
+ _prefs?: PreferencesInfo;
+
+ @property({type: Object})
+ _changeComments?: ChangeComments;
+
+ @property({type: Boolean, computed: '_computeCanStartReview(_change)'})
+ _canStartReview?: boolean;
+
+ @property({type: Object, observer: '_changeChanged'})
+ _change?: ChangeInfo | ParsedChangeInfo;
+
+ @property({type: Object, computed: '_getRevisionInfo(_change)'})
+ _revisionInfo?: RevisionInfoClass;
+
+ @property({type: Object})
+ _commitInfo?: CommitInfo;
+
+ @property({
+ type: Object,
+ computed:
+ '_computeCurrentRevision(_change.current_revision, ' +
+ '_change.revisions)',
+ observer: '_handleCurrentRevisionUpdate',
+ })
+ _currentRevision?: RevisionInfo;
+
+ @property({type: String})
+ _changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ _diffDrafts?: {[path: string]: UIDraft[]} = {};
+
+ @property({type: Boolean})
+ _editingCommitMessage = false;
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeHideEditCommitMessage(_loggedIn, ' +
+ '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+ '_commitCollapsible)',
+ })
+ _hideEditCommitMessage?: boolean;
+
+ @property({type: String})
+ _diffAgainst?: string;
+
+ @property({type: String})
+ _latestCommitMessage: string | null = '';
+
+ @property({type: Object})
+ _constants = {
+ SecondaryTab,
+ PrimaryTab,
+ };
+
+ @property({type: Object})
+ _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
+
+ @property({type: Number})
+ _lineHeight?: number;
+
+ @property({
+ type: String,
+ computed:
+ '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+ })
+ _changeIdCommitMessageError?: string;
+
+ @property({type: Object})
+ _patchRange?: PatchRange;
+
+ @property({type: String})
+ _filesExpanded?: string;
+
+ @property({type: String})
+ _basePatchNum?: string;
+
+ @property({type: Object})
+ _selectedRevision?: RevisionInfo;
+
+ @property({type: Object})
+ _currentRevisionActions?: ActionNameToActionInfoMap;
+
+ @property({
+ type: Array,
+ computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+ })
+ _allPatchSets?: PatchSet[];
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Boolean})
+ _loading?: boolean;
+
+ @property({type: Object})
+ _projectConfig?: ConfigInfo;
+
+ @property({
+ type: String,
+ computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+ })
+ _replyButtonLabel = 'Reply';
+
+ @property({type: String})
+ _selectedPatchSet?: string;
+
+ @property({type: Number})
+ _shownFileCount?: number;
+
+ @property({type: Boolean})
+ _initialLoadComplete = false;
+
+ @property({type: Boolean})
+ _replyDisabled = true;
+
+ @property({type: String, computed: '_changeStatusString(_change)'})
+ _changeStatus?: string;
+
+ @property({
+ type: String,
+ computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
+ })
+ _changeStatuses?: string;
+
+ /** If false, then the "Show more" button was used to expand. */
+ @property({type: Boolean})
+ _commitCollapsed = true;
+
+ /** Is the "Show more/less" button visible? */
+ @property({
+ type: Boolean,
+ computed: '_computeCommitCollapsible(_latestCommitMessage)',
+ })
+ _commitCollapsible?: boolean;
+
+ @property({type: Boolean})
+ _relatedChangesCollapsed = true;
+
+ @property({type: Number})
+ _updateCheckTimerHandle?: number | null;
+
+ @property({
+ type: Boolean,
+ computed: '_computeEditMode(_patchRange.*, params.*)',
+ })
+ _editMode?: boolean;
+
+ @property({type: Boolean, observer: '_updateToggleContainerClass'})
+ _showRelatedToggle = false;
+
+ @property({
+ type: Boolean,
+ computed: '_isParentCurrent(_currentRevisionActions)',
+ })
+ _parentIsCurrent?: boolean;
+
+ @property({
+ type: Boolean,
+ computed: '_isSubmitEnabled(_currentRevisionActions)',
+ })
+ _submitEnabled?: boolean;
+
+ @property({type: Boolean})
+ _mergeable: boolean | null = null;
+
+ @property({type: Boolean})
+ _showFileTabContent = true;
+
+ @property({type: Array})
+ _dynamicTabHeaderEndpoints: string[] = [];
+
+ @property({type: Array})
+ _dynamicTabContentEndpoints: string[] = [];
+
+ @property({type: String})
+ // The dynamic content of the plugin added tab
+ _selectedTabPluginEndpoint?: string;
+
+ @property({type: String})
+ // The dynamic heading of the plugin added tab
+ _selectedTabPluginHeader?: string;
+
+ @property({
+ type: Array,
+ computed:
+ '_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
+ })
+ _robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
+
+ @property({type: Number})
+ _currentRobotCommentsPatchSet?: PatchSetNum;
+
+ /**
+ * this is a two-element tuple to always
+ * hold the current active tab for both primary and secondary tabs
+ */
+ @property({type: Array})
+ _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
+
+ @property({type: Boolean})
+ _showAllRobotComments = false;
+
+ @property({type: Boolean})
+ _showRobotCommentsButton = false;
+
+ _throttledToggleChangeStar?: EventListener;
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+ [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+ [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+ [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+ [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
+ [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+ [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
+ [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+ [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+ [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+ [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
+ [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+ [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+ [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+ [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+ [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+ };
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ this._throttledToggleChangeStar = this._throttleWrap(e =>
+ this._handleToggleChangeStar(e as CustomKeyboardEvent)
+ );
+ }
+
+ /** @override */
+ created() {
+ super.created();
+
+ this.addEventListener('topic-changed', () => this._handleTopicChanged());
+
+ this.addEventListener(
+ // When an overlay is opened in a mobile viewport, the overlay has a full
+ // screen view. When it has a full screen view, we do not want the
+ // background to be scrollable. This will eliminate background scroll by
+ // hiding most of the contents on the screen upon opening, and showing
+ // again upon closing.
+ 'fullscreen-overlay-opened',
+ () => this._handleHideBackgroundContent()
+ );
+
+ this.addEventListener('fullscreen-overlay-closed', () =>
+ this._handleShowBackgroundContent()
+ );
+
+ this.addEventListener('diff-comments-modified', () =>
+ this._handleReloadCommentThreads()
+ );
+
+ this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getServerConfig().then(config => {
+ this._serverConfig = config;
+ this._replyDisabled = false;
+ });
+
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ if (loggedIn) {
+ this.$.restAPI.getAccount().then(acct => {
+ this._account = acct;
+ });
+ }
+ this._setDiffViewMode();
+ });
+
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._dynamicTabHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-tab-header'
+ );
+ this._dynamicTabContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-tab-content'
+ );
+ if (
+ this._dynamicTabContentEndpoints.length !==
+ this._dynamicTabHeaderEndpoints.length
+ ) {
+ console.warn('Different number of tab headers and tab content.');
+ }
+ })
+ .then(() => this._initActiveTabs(this.params));
+
+ this.addEventListener('comment-save', e => this._handleCommentSave(e));
+ this.addEventListener('comment-refresh', () => this._reloadDrafts());
+ this.addEventListener('comment-discard', e =>
+ this._handleCommentDiscard(e)
+ );
+ this.addEventListener('change-message-deleted', () => this._reload());
+ this.addEventListener('editable-content-save', e =>
+ this._handleCommitMessageSave(e as CustomEvent<{content: string}>)
+ );
+ this.addEventListener('editable-content-cancel', () =>
+ this._handleCommitMessageCancel()
+ );
+ this.addEventListener('open-fix-preview', e =>
+ this._onOpenFixPreview(e as CustomEvent<CommentEventDetail>)
+ );
+ this.addEventListener('close-fix-preview', () => this._onCloseFixPreview());
+ this.listen(window, 'scroll', '_handleScroll');
+ this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+
+ this.addEventListener('show-primary-tab', e =>
+ this._setActivePrimaryTab(e as CustomEvent<SwitchTabEventDetail>)
+ );
+ this.addEventListener('show-secondary-tab', e =>
+ this._setActiveSecondaryTab(e as CustomEvent<SwitchTabEventDetail>)
+ );
+ this.addEventListener('reload', e => {
+ e.stopPropagation();
+ const evt = e as CustomEvent<{clearPatchset: boolean}>;
+ this._reload(
+ /* isLocationChange= */ false,
+ /* clearPatchset= */ evt.detail && evt.detail.clearPatchset
+ );
+ });
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.unlisten(window, 'scroll', '_handleScroll');
+ this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+ if (this._updateCheckTimerHandle) {
+ this._cancelUpdateCheckTimer();
+ }
+ }
+
+ get messagesList() {
+ return this.shadowRoot!.querySelector('gr-messages-list');
+ }
+
+ get threadList() {
+ return this.shadowRoot!.querySelector('gr-thread-list');
+ }
+
+ _changeStatusString(change: ChangeInfo) {
+ return changeStatusString(change);
+ }
+
+ _setDiffViewMode(opt_reset?: boolean) {
+ if (!opt_reset && this.viewState.diffViewMode) {
+ return;
+ }
+
+ return this._getPreferences()
+ .then(prefs => {
+ if (!this.viewState.diffMode && prefs) {
+ this.set('viewState.diffMode', prefs.default_diff_view);
+ }
+ })
+ .then(() => {
+ if (!this.viewState.diffMode) {
+ this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+ }
+ });
+ }
+
+ _onOpenFixPreview(e: CustomEvent<CommentEventDetail>) {
+ this.$.applyFixDialog.open(e);
+ }
+
+ _onCloseFixPreview() {
+ this._reload();
+ }
+
+ _handleToggleDiffMode(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
+ this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+ } else {
+ this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+ }
+ }
+
+ _isTabActive(tab: string, activeTabs: string[]) {
+ return activeTabs.includes(tab);
+ }
+
+ /**
+ * Actual implementation of switching a tab
+ *
+ * @param paperTabs - the parent tabs container
+ */
+ _setActiveTab(
+ paperTabs: PaperTabsElement,
+ activeDetails: {
+ activeTabName?: string;
+ activeTabIndex?: number;
+ scrollIntoView?: boolean;
+ }
+ ) {
+ const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
+ const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
+ HTMLElement
+ >;
+ let activeIndex = -1;
+ if (activeTabIndex !== undefined) {
+ activeIndex = activeTabIndex;
+ } else {
+ for (let i = 0; i <= tabs.length; i++) {
+ const tab = tabs[i];
+ if (tab.dataset['name'] === activeTabName) {
+ activeIndex = i;
+ break;
+ }
+ }
+ }
+ if (activeIndex === -1) {
+ console.warn('tab not found with given info', activeDetails);
+ return;
+ }
+ const tabName = tabs[activeIndex].dataset['name'];
+ if (scrollIntoView) {
+ paperTabs.scrollIntoView();
+ }
+ if (paperTabs.selected !== activeIndex) {
+ paperTabs.selected = activeIndex;
+ this.reporting.reportInteraction('show-tab', {tabName});
+ }
+ return tabName;
+ }
+
+ /**
+ * Changes active primary tab.
+ */
+ _setActivePrimaryTab(e: CustomEvent<SwitchTabEventDetail>) {
+ const primaryTabs = this.shadowRoot!.querySelector(
+ '#primaryTabs'
+ ) as PaperTabsElement;
+ const activeTabName = this._setActiveTab(primaryTabs, {
+ activeTabName: e.detail.tab,
+ activeTabIndex: e.detail.value,
+ scrollIntoView: e.detail.scrollIntoView,
+ });
+ if (activeTabName) {
+ this._activeTabs = [activeTabName, this._activeTabs[1]];
+
+ // update plugin endpoint if its a plugin tab
+ const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
+ activeTabName
+ );
+ if (pluginIndex !== -1) {
+ this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
+ pluginIndex
+ ];
+ this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
+ pluginIndex
+ ];
+ } else {
+ this._selectedTabPluginEndpoint = '';
+ this._selectedTabPluginHeader = '';
+ }
+ }
+ }
+
+ /**
+ * Changes active secondary tab.
+ */
+ _setActiveSecondaryTab(e: CustomEvent<SwitchTabEventDetail>) {
+ const secondaryTabs = this.shadowRoot!.querySelector(
+ '#secondaryTabs'
+ ) as PaperTabsElement;
+ const activeTabName = this._setActiveTab(secondaryTabs, {
+ activeTabName: e.detail.tab,
+ activeTabIndex: e.detail.value,
+ scrollIntoView: e.detail.scrollIntoView,
+ });
+ if (activeTabName) {
+ this._activeTabs = [this._activeTabs[0], activeTabName];
+ }
+ }
+
+ _handleEditCommitMessage() {
+ this._editingCommitMessage = true;
+ this.$.commitMessageEditor.focusTextarea();
+ }
+
+ _handleCommitMessageSave(e: CustomEvent<{content: string}>) {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ // Trim trailing whitespace from each line.
+ const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
+
+ this.$.jsAPI.handleCommitMessage(this._change, message);
+
+ this.$.commitMessageEditor.disabled = true;
+ this.$.restAPI
+ .putChangeCommitMessage(this._changeNum, message)
+ .then(resp => {
+ this.$.commitMessageEditor.disabled = false;
+ if (!resp.ok) {
+ return;
+ }
+
+ this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
+ this._editingCommitMessage = false;
+ this._reloadWindow();
+ })
+ .catch(() => {
+ this.$.commitMessageEditor.disabled = false;
+ });
+ }
+
+ _reloadWindow() {
+ window.location.reload();
+ }
+
+ _handleCommitMessageCancel() {
+ this._editingCommitMessage = false;
+ }
+
+ _computeChangeStatusChips(
+ change: ChangeInfo | undefined,
+ mergeable: boolean | null,
+ submitEnabled?: boolean
+ ) {
+ if (!change) {
+ return undefined;
+ }
+
+ // Show no chips until mergeability is loaded.
+ if (mergeable === null) {
+ return [];
+ }
+
+ const options = {
+ includeDerived: true,
+ mergeable: !!mergeable,
+ submitEnabled: !!submitEnabled,
+ };
+ return changeStatuses(change, options);
+ }
+
+ _computeHideEditCommitMessage(
+ loggedIn: boolean,
+ editing: boolean,
+ change: ChangeInfo,
+ editMode: boolean,
+ collapsed: boolean,
+ collapsible: boolean
+ ) {
+ if (
+ !loggedIn ||
+ editing ||
+ (change && change.status === ChangeStatus.MERGED) ||
+ editMode ||
+ (collapsed && collapsible)
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ _robotCommentCountPerPatchSet(threads: CommentThread[]) {
+ return threads.reduce((robotCommentCountMap, thread) => {
+ const comments = thread.comments;
+ const robotCommentsCount = comments.reduce(
+ (acc, comment) => (isRobot(comment) ? acc + 1 : acc),
+ 0
+ );
+ if (comments[0].patch_set)
+ robotCommentCountMap[`${comments[0].patch_set}`] =
+ (robotCommentCountMap[`${comments[0].patch_set}`] || 0) +
+ robotCommentsCount;
+ return robotCommentCountMap;
+ }, {} as {[patchset: string]: number});
+ }
+
+ _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
+ const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
+ const commentCnt = commentCount[patch._number] || 0;
+ if (commentCnt === 0) return `Patchset ${patch._number}`;
+ const findingsText = commentCnt === 1 ? 'finding' : 'findings';
+ return `Patchset ${patch._number} (${commentCnt} ${findingsText})`;
+ }
+
+ _computeRobotCommentsPatchSetDropdownItems(
+ change: ChangeInfo,
+ commentThreads: CommentThread[]
+ ) {
+ if (!change || !commentThreads || !change.revisions) return [];
+
+ return Object.values(change.revisions)
+ .filter(patch => patch._number !== 'edit')
+ .map(patch => {
+ return {
+ text: this._computeText(patch, commentThreads),
+ value: patch._number,
+ };
+ })
+ .sort((a, b) => (b.value as number) - (a.value as number));
+ }
+
+ _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
+ this._currentRobotCommentsPatchSet = currentRevision._number;
+ }
+
+ _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
+ const patchSet = parseInt(e.detail.value) as PatchSetNum;
+ if (patchSet === this._currentRobotCommentsPatchSet) return;
+ this._currentRobotCommentsPatchSet = patchSet;
+ }
+
+ _computeShowText(showAllRobotComments: boolean) {
+ return showAllRobotComments ? 'Show Less' : 'Show more';
+ }
+
+ _toggleShowRobotComments() {
+ this._showAllRobotComments = !this._showAllRobotComments;
+ }
+
+ _computeRobotCommentThreads(
+ commentThreads: CommentThread[],
+ currentRobotCommentsPatchSet: PatchSetNum,
+ showAllRobotComments: boolean
+ ) {
+ if (!commentThreads || !currentRobotCommentsPatchSet) return [];
+ const threads = commentThreads.filter(thread => {
+ const comments = thread.comments || [];
+ return (
+ comments.length &&
+ isRobot(comments[0]) &&
+ comments[0].patch_set === currentRobotCommentsPatchSet
+ );
+ });
+ this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+ return threads.slice(
+ 0,
+ showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
+ );
+ }
+
+ _handleReloadCommentThreads() {
+ // Get any new drafts that have been saved in the diff view and show
+ // in the comment thread view.
+ this._reloadDrafts().then(() => {
+ this._commentThreads = this._changeComments?.getAllThreadsForChange();
+ flush();
+ });
+ }
+
+ _handleReloadDiffComments(
+ e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
+ ) {
+ // Keeps the file list counts updated.
+ this._reloadDrafts().then(() => {
+ // Get any new drafts that have been saved in the thread view and show
+ // in the diff view.
+ this.$.fileList.reloadCommentsForThreadWithRootId(
+ e.detail.rootId,
+ e.detail.path
+ );
+ flush();
+ });
+ }
+
+ _computeTotalCommentCounts(
+ unresolvedCount: number,
+ changeComments: ChangeComments
+ ) {
+ if (!changeComments) return undefined;
+ const draftCount = changeComments.computeDraftCount();
+ const unresolvedString = GrCountStringFormatter.computeString(
+ unresolvedCount,
+ 'unresolved'
+ );
+ const draftString = GrCountStringFormatter.computePluralString(
+ draftCount,
+ 'draft'
+ );
+
+ return (
+ unresolvedString +
+ // Add a comma and space if both unresolved and draft comments exist.
+ (unresolvedString && draftString ? ', ' : '') +
+ draftString
+ );
+ }
+
+ _handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
+ const draft = e.detail.comment;
+ if (!draft.__draft || !draft.path) return;
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+
+ draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+ // The use of path-based notification helpers (set, push) can’t be used
+ // because the paths could contain dots in them. A new object must be
+ // created to satisfy Polymer’s dirty checking.
+ // https://github.com/Polymer/polymer/issues/3127
+ const diffDrafts = {...this._diffDrafts};
+ if (!diffDrafts[draft.path]) {
+ diffDrafts[draft.path] = [draft];
+ this._diffDrafts = diffDrafts;
+ return;
+ }
+ for (let i = 0; i < diffDrafts[draft.path].length; i++) {
+ if (diffDrafts[draft.path][i].id === draft.id) {
+ diffDrafts[draft.path][i] = draft;
+ this._diffDrafts = diffDrafts;
+ return;
+ }
+ }
+ diffDrafts[draft.path].push(draft);
+ diffDrafts[draft.path].sort(
+ (c1, c2) =>
+ // No line number means that it’s a file comment. Sort it above the
+ // others.
+ (c1.line || -1) - (c2.line || -1)
+ );
+ this._diffDrafts = diffDrafts;
+ }
+
+ _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
+ const draft = e.detail.comment;
+ if (!draft.__draft || !draft.path) {
+ return;
+ }
+
+ if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
+ return;
+ }
+ let index = -1;
+ for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
+ if (this._diffDrafts[draft.path][i].id === draft.id) {
+ index = i;
+ break;
+ }
+ }
+ if (index === -1) {
+ // It may be a draft that hasn’t been added to _diffDrafts since it was
+ // never saved.
+ return;
+ }
+
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+ // The use of path-based notification helpers (set, push) can’t be used
+ // because the paths could contain dots in them. A new object must be
+ // created to satisfy Polymer’s dirty checking.
+ // https://github.com/Polymer/polymer/issues/3127
+ const diffDrafts = {...this._diffDrafts};
+ diffDrafts[draft.path].splice(index, 1);
+ if (diffDrafts[draft.path].length === 0) {
+ delete diffDrafts[draft.path];
+ }
+ this._diffDrafts = diffDrafts;
+ }
+
+ _handleReplyTap(e: MouseEvent) {
+ e.preventDefault();
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+ }
+
+ _handleOpenDiffPrefs() {
+ this.$.fileList.openDiffPrefs();
+ }
+
+ _handleOpenIncludedInDialog() {
+ this.$.includedInDialog.loadData().then(() => {
+ flush();
+ this.$.includedInOverlay.refit();
+ });
+ this.$.includedInOverlay.open();
+ }
+
+ _handleIncludedInDialogClose() {
+ this.$.includedInOverlay.close();
+ }
+
+ _handleOpenDownloadDialog() {
+ this.$.downloadOverlay.open().then(() => {
+ this.$.downloadOverlay.setFocusStops(
+ this.$.downloadDialog.getFocusStops()
+ );
+ this.$.downloadDialog.focus();
+ });
+ }
+
+ _handleDownloadDialogClose() {
+ this.$.downloadOverlay.close();
+ }
+
+ _handleOpenUploadHelpDialog() {
+ this.$.uploadHelpOverlay.open();
+ }
+
+ _handleCloseUploadHelpDialog() {
+ this.$.uploadHelpOverlay.close();
+ }
+
+ _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
+ const msg: string = e.detail.message.message;
+ const quoteStr =
+ msg
+ .split('\n')
+ .map(line => '> ' + line)
+ .join('\n') + '\n\n';
+ this.$.replyDialog.quote = quoteStr;
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
+ }
+
+ _handleHideBackgroundContent() {
+ this.$.mainContent.classList.add('overlayOpen');
+ }
+
+ _handleShowBackgroundContent() {
+ this.$.mainContent.classList.remove('overlayOpen');
+ }
+
+ _handleReplySent() {
+ this.addEventListener(
+ 'change-details-loaded',
+ () => {
+ this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+ },
+ {once: true}
+ );
+ this.$.replyOverlay.close();
+ this._reload();
+ }
+
+ _handleReplyCancel() {
+ this.$.replyOverlay.close();
+ }
+
+ _handleReplyAutogrow() {
+ // If the textarea resizes, we need to re-fit the overlay.
+ this.debounce(
+ 'reply-overlay-refit',
+ () => {
+ this.$.replyOverlay.refit();
+ },
+ REPLY_REFIT_DEBOUNCE_INTERVAL_MS
+ );
+ }
+
+ _handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+ let target = this.$.replyDialog.FocusTarget.REVIEWERS;
+ if (e.detail.value && e.detail.value.ccsOnly) {
+ target = this.$.replyDialog.FocusTarget.CCS;
+ }
+ this._openReplyDialog(target);
+ }
+
+ _handleScroll() {
+ this.debounce(
+ 'scroll',
+ () => {
+ this.viewState.scrollTop = document.body.scrollTop;
+ },
+ 150
+ );
+ }
+
+ _setShownFiles(e: CustomEvent<{length: number}>) {
+ this._shownFileCount = e.detail.length;
+ }
+
+ _expandAllDiffs(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ this.$.fileList.expandAllDiffs();
+ }
+
+ _collapseAllDiffs() {
+ this.$.fileList.collapseAllDiffs();
+ }
+
+ _paramsChanged(value: AppElementChangeViewParams) {
+ if (value.view !== GerritView.CHANGE) {
+ this._initialLoadComplete = false;
+ return;
+ }
+
+ if (value.changeNum && value.project) {
+ this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+ }
+
+ const patchChanged =
+ this._patchRange &&
+ value.patchNum !== undefined &&
+ value.basePatchNum !== undefined &&
+ (this._patchRange.patchNum !== value.patchNum ||
+ this._patchRange.basePatchNum !== value.basePatchNum);
+ const changeChanged = this._changeNum !== value.changeNum;
+
+ const patchRange = {
+ patchNum: value.patchNum,
+ basePatchNum: value.basePatchNum || 'PARENT',
+ };
+ // TODO(TS): remove once proper type for patchRange is defined
+ if (!isNaN(Number(patchRange.patchNum))) {
+ patchRange.patchNum = Number(patchRange.patchNum) as PatchSetNum;
+ }
+ if (!isNaN(Number(patchRange.basePatchNum))) {
+ patchRange.basePatchNum = Number(patchRange.basePatchNum) as PatchSetNum;
+ }
+
+ this.$.fileList.collapseAllDiffs();
+ // TODO(TS): change patchRange to PatchRange.
+ this._patchRange = patchRange as PatchRange;
+
+ // If the change has already been loaded and the parameter change is only
+ // in the patch range, then don't do a full reload.
+ if (!changeChanged && patchChanged) {
+ if (!patchRange.patchNum) {
+ patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+ }
+ this._reloadPatchNumDependentResources().then(() => {
+ this._sendShowChangeEvent();
+ });
+ return;
+ }
+
+ this._initialLoadComplete = false;
+ this._changeNum = value.changeNum;
+ this.$.relatedChanges.clear();
+
+ this._reload(true).then(() => {
+ this._performPostLoadTasks();
+ });
+
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._initActiveTabs(value);
+ });
+ }
+
+ _initActiveTabs(params?: AppElementChangeViewParams) {
+ let primaryTab = PrimaryTab.FILES;
+ if (params && params.queryMap && params.queryMap.has('tab')) {
+ primaryTab = params.queryMap.get('tab') as PrimaryTab;
+ }
+ this._setActivePrimaryTab(
+ new CustomEvent('initActiveTab', {
+ detail: {
+ tab: primaryTab,
+ },
+ })
+ );
+ this._setActiveSecondaryTab(
+ new CustomEvent('initActiveTab', {
+ detail: {
+ tab: SecondaryTab.CHANGE_LOG,
+ },
+ })
+ );
+ }
+
+ _sendShowChangeEvent() {
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
+ change: this._change,
+ patchNum: this._patchRange.patchNum,
+ info: {mergeable: this._mergeable},
+ });
+ }
+
+ _performPostLoadTasks() {
+ this._maybeShowReplyDialog();
+ this._maybeShowRevertDialog();
+ this._maybeShowDownloadDialog();
+
+ this._sendShowChangeEvent();
+
+ this.async(() => {
+ if (this.viewState.scrollTop) {
+ document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
+ } else {
+ this._maybeScrollToMessage(window.location.hash);
+ }
+ this._initialLoadComplete = true;
+ });
+ }
+
+ @observe('params', '_change')
+ _paramsAndChangeChanged(
+ value?: AppElementChangeViewParams,
+ change?: ChangeInfo
+ ) {
+ // Polymer 2: check for undefined
+ if (!value || !change) {
+ return;
+ }
+
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ // If the change number or patch range is different, then reset the
+ // selected file index.
+ const patchRangeState = this.viewState.patchRange;
+ if (
+ this.viewState.changeNum !== this._changeNum ||
+ !patchRangeState ||
+ patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+ patchRangeState.patchNum !== this._patchRange.patchNum
+ ) {
+ this._resetFileListViewState();
+ }
+ }
+
+ _viewStateChanged(viewState: ChangeViewState) {
+ this._numFilesShown = viewState.numFilesShown
+ ? viewState.numFilesShown
+ : DEFAULT_NUM_FILES_SHOWN;
+ }
+
+ _numFilesShownChanged(numFilesShown: number) {
+ this.viewState.numFilesShown = numFilesShown;
+ }
+
+ _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const hash = MSG_PREFIX + e.detail.id;
+ const url = GerritNav.getUrlForChange(
+ this._change,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum,
+ this._editMode,
+ hash
+ );
+ history.replaceState(null, '', url);
+ }
+
+ _maybeScrollToMessage(hash: string) {
+ if (hash.startsWith(MSG_PREFIX) && this.messagesList) {
+ this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
+ }
+ }
+
+ _getLocationSearch() {
+ // Not inlining to make it easier to test.
+ return window.location.search;
+ }
+
+ _getUrlParameter(param: string) {
+ const pageURL = this._getLocationSearch().substring(1);
+ const vars = pageURL.split('&');
+ for (let i = 0; i < vars.length; i++) {
+ const name = vars[i].split('=');
+ if (name[0] === param) {
+ return name[0];
+ }
+ }
+ return null;
+ }
+
+ _maybeShowRevertDialog() {
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => this._getLoggedIn())
+ .then(loggedIn => {
+ if (
+ !loggedIn ||
+ !this._change ||
+ this._change.status !== ChangeStatus.MERGED
+ ) {
+ // Do not display dialog if not logged-in or the change is not
+ // merged.
+ return;
+ }
+ if (this._getUrlParameter('revert')) {
+ this.$.actions.showRevertDialog();
+ }
+ });
+ }
+
+ _maybeShowReplyDialog() {
+ this._getLoggedIn().then(loggedIn => {
+ if (!loggedIn) {
+ return;
+ }
+
+ if (this.viewState.showReplyDialog) {
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+ // TODO(kaspern@): Find a better signal for when to call center.
+ this.async(() => {
+ this.$.replyOverlay.center();
+ }, 100);
+ this.async(() => {
+ this.$.replyOverlay.center();
+ }, 1000);
+ this.set('viewState.showReplyDialog', false);
+ }
+ });
+ }
+
+ _maybeShowDownloadDialog() {
+ if (this.viewState.showDownloadDialog) {
+ this._handleOpenDownloadDialog();
+ this.set('viewState.showDownloadDialog', false);
+ }
+ }
+
+ _resetFileListViewState() {
+ this.set('viewState.selectedFileIndex', 0);
+ this.set('viewState.scrollTop', 0);
+ if (
+ !!this.viewState.changeNum &&
+ this.viewState.changeNum !== this._changeNum
+ ) {
+ // Reset the diff mode to null when navigating from one change to
+ // another, so that the user's preference is restored.
+ this._setDiffViewMode(true);
+ this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
+ }
+ this.set('viewState.changeNum', this._changeNum);
+ this.set('viewState.patchRange', this._patchRange);
+ }
+
+ _changeChanged(change?: ChangeInfo | ParsedChangeInfo) {
+ if (!change || !this._patchRange || !this._allPatchSets) {
+ return;
+ }
+
+ // We get the parent first so we keep the original value for basePatchNum
+ // and not the updated value.
+ const parent = this._getBasePatchNum(change, this._patchRange);
+
+ this.set(
+ '_patchRange.patchNum',
+ this._patchRange.patchNum || computeLatestPatchNum(this._allPatchSets)
+ );
+
+ this.set('_patchRange.basePatchNum', parent);
+
+ const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ /**
+ * Gets base patch number, if it is a parent try and decide from
+ * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+ */
+ _getBasePatchNum(
+ change: ChangeInfo | ParsedChangeInfo,
+ patchRange: PatchRange
+ ) {
+ if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') {
+ return patchRange.basePatchNum;
+ }
+
+ const revisionInfo = this._getRevisionInfo(change);
+ if (!revisionInfo) return 'PARENT';
+
+ const parentCounts = revisionInfo.getParentCountMap();
+ // check that there is at least 2 parents otherwise fall back to 1,
+ // which means there is only one parent.
+ const parentCount = hasOwnProperty(parentCounts, 1) ? parentCounts[1] : 1;
+
+ const preferFirst =
+ this._prefs && this._prefs.default_base_for_merges === 'FIRST_PARENT';
+
+ if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
+ return -1;
+ }
+
+ return 'PARENT';
+ }
+
+ _computeChangeUrl(change: ChangeInfo) {
+ return GerritNav.getUrlForChange(change);
+ }
+
+ _computeShowCommitInfo(changeStatus: string, current_revision: RevisionInfo) {
+ return changeStatus === 'Merged' && current_revision;
+ }
+
+ _computeMergedCommitInfo(
+ current_revision: CommitId,
+ revisions: {[revisionId: string]: RevisionInfo}
+ ) {
+ const rev = revisions[current_revision];
+ if (!rev || !rev.commit) {
+ return {};
+ }
+ // CommitInfo.commit is optional. Set commit in all cases to avoid error
+ // in <gr-commit-info>. @see Issue 5337
+ if (!rev.commit.commit) {
+ rev.commit.commit = current_revision;
+ }
+ return rev.commit;
+ }
+
+ _computeChangeIdClass(displayChangeId: string) {
+ return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+ }
+
+ _computeTitleAttributeWarning(displayChangeId: string) {
+ if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+ return 'Change-Id mismatch';
+ } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+ return 'No Change-Id in commit message';
+ }
+ return undefined;
+ }
+
+ _computeChangeIdCommitMessageError(
+ commitMessage?: string,
+ change?: ChangeInfo
+ ) {
+ if (change === undefined) {
+ return undefined;
+ }
+
+ if (!commitMessage) {
+ return CHANGE_ID_ERROR.MISSING;
+ }
+
+ // Find the last match in the commit message:
+ let changeId;
+ let changeIdArr;
+
+ while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
+ changeId = changeIdArr[2];
+ }
+
+ if (changeId) {
+ // A change-id is detected in the commit message.
+
+ if (changeId === change.change_id) {
+ // The change-id found matches the real change-id.
+ return null;
+ }
+ // The change-id found does not match the change-id.
+ return CHANGE_ID_ERROR.MISMATCH;
+ }
+ // There is no change-id in the commit message.
+ return CHANGE_ID_ERROR.MISSING;
+ }
+
+ _computeReplyButtonLabel(
+ changeRecord?: ElementPropertyDeepChange<
+ GrChangeView,
+ '_diffDrafts'
+ > | null,
+ canStartReview?: PolymerDeepPropertyChange<boolean, boolean>
+ ) {
+ if (changeRecord === undefined || canStartReview === undefined) {
+ return 'Reply';
+ }
+
+ const drafts = (changeRecord && changeRecord.base) || {};
+ const draftCount = Object.keys(drafts).reduce(
+ (count, file) => count + drafts[file].length,
+ 0
+ );
+
+ let label = canStartReview ? 'Start Review' : 'Reply';
+ if (draftCount > 0) {
+ label += ` (${draftCount})`;
+ }
+ return label;
+ }
+
+ _handleOpenReplyDialog(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ this._getLoggedIn().then(isLoggedIn => {
+ if (!isLoggedIn) {
+ this.dispatchEvent(
+ new CustomEvent('show-auth-required', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+
+ e.preventDefault();
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+ });
+ }
+
+ _handleOpenDownloadDialogShortcut(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._handleOpenDownloadDialog();
+ }
+
+ _handleEditTopic(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.metadata.editTopic();
+ }
+
+ _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Base is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+ }
+
+ _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Left is already base.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+ }
+
+ _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Latest is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(
+ this._change,
+ latestPatchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Right is already latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(
+ this._change,
+ latestPatchNum,
+ this._patchRange.patchNum
+ );
+ }
+
+ _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (
+ patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+ patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+ ) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Already diffing base against latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, latestPatchNum);
+ }
+
+ _handleRefreshChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ e.preventDefault();
+ this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
+ }
+
+ _handleToggleChangeStar(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.$.changeStar.toggleStar();
+ }
+
+ _handleUpToDashboard(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._determinePageBack();
+ }
+
+ _handleExpandAllMessages(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.messagesList) {
+ this.messagesList.handleExpandCollapse(true);
+ }
+ }
+
+ _handleCollapseAllMessages(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.messagesList) {
+ this.messagesList.handleExpandCollapse(false);
+ }
+ }
+
+ _handleOpenDiffPrefsShortcut(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ if (this._diffPrefsDisabled) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.fileList.openDiffPrefs();
+ }
+
+ _determinePageBack() {
+ // Default backPage to root if user came to change view page
+ // via an email link, etc.
+ GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
+ }
+
+ _handleLabelRemoved(
+ splices: Array<PolymerSplice<ApprovalInfo[]>>,
+ path: string
+ ) {
+ for (const splice of splices) {
+ for (const removed of splice.removed) {
+ const changePath = path.split('.');
+ const labelPath = changePath.splice(0, changePath.length - 2);
+ const labelDict = this.get(labelPath) as QuickLabelInfo;
+ if (
+ labelDict.approved &&
+ labelDict.approved._account_id === removed._account_id
+ ) {
+ this._reload();
+ return;
+ }
+ }
+ }
+ }
+
+ @observe('_change.labels.*')
+ _labelsChanged(
+ changeRecord: PolymerDeepPropertyChange<
+ LabelNameToInfoMap,
+ PolymerSpliceChange<ApprovalInfo[]>
+ >
+ ) {
+ if (!changeRecord) {
+ return;
+ }
+ if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
+ this._handleLabelRemoved(
+ changeRecord.value.indexSplices,
+ changeRecord.path
+ );
+ }
+ this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+ change: this._change,
+ });
+ }
+
+ _openReplyDialog(section?: FocusTarget) {
+ this.$.replyOverlay.open().finally(() => {
+ // the following code should be executed no matter open succeed or not
+ this._resetReplyOverlayFocusStops();
+ this.$.replyDialog.open(section);
+ flush();
+ this.$.replyOverlay.center();
+ });
+ }
+
+ _handleGetChangeDetailError(response?: Response | null) {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _getServerConfig() {
+ return this.$.restAPI.getConfig();
+ }
+
+ _getProjectConfig() {
+ if (!this._change) throw new Error('missing required change property');
+ return this.$.restAPI
+ .getProjectConfig(this._change.project)
+ .then(config => {
+ this._projectConfig = config;
+ });
+ }
+
+ _getPreferences() {
+ return this.$.restAPI.getPreferences();
+ }
+
+ _prepareCommitMsgForLinkify(msg: string) {
+ // TODO(wyatta) switch linkify sequence, see issue 5526.
+ // This is a zero-with space. It is added to prevent the linkify library
+ // from including R= or CC= as part of the email address.
+ return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
+ }
+
+ /**
+ * Utility function to make the necessary modifications to a change in the
+ * case an edit exists.
+ */
+ _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+ if (!edit) return;
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
+ const changeWithEdit = change;
+ if (changeWithEdit.revisions)
+ changeWithEdit.revisions[edit.commit.commit] = {
+ _number: EditPatchSetNum,
+ basePatchNum: edit.base_patch_set_number,
+ commit: edit.commit,
+ fetch: edit.fetch,
+ } as RevisionInfo;
+
+ // If the edit is based on the most recent patchset, load it by
+ // default, unless another patch set to load was specified in the URL.
+ if (
+ !this._patchRange.patchNum &&
+ changeWithEdit.current_revision === edit.base_revision
+ ) {
+ changeWithEdit.current_revision = edit.commit.commit;
+ this.set('_patchRange.patchNum', EditPatchSetNum);
+ // Because edits are fibbed as revisions and added to the revisions
+ // array, and revision actions are always derived from the 'latest'
+ // patch set, we must copy over actions from the patch set base.
+ // Context: Issue 7243
+ if (changeWithEdit.revisions) {
+ changeWithEdit.revisions[edit.commit.commit].actions =
+ changeWithEdit.revisions[edit.base_revision].actions;
+ }
+ }
+ }
+
+ _getChangeDetail() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ const detailCompletes = this.$.restAPI.getChangeDetail(this._changeNum, r =>
+ this._handleGetChangeDetailError(r)
+ );
+ const editCompletes = this._getEdit();
+ const prefCompletes = this._getPreferences();
+
+ return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
+ ([change, edit, prefs]) => {
+ this._prefs = prefs;
+
+ if (!change) {
+ return false;
+ }
+ this._processEdit(change, edit);
+ // Issue 4190: Coalesce missing topics to null.
+ // TODO(TS): code needs second thought,
+ // it might be that nulls were assigned to trigger some bindings
+ if (!change.topic) {
+ change.topic = (null as unknown) as undefined;
+ }
+ if (!change.reviewer_updates) {
+ change.reviewer_updates = (null as unknown) as undefined;
+ }
+ const latestRevisionSha = this._getLatestRevisionSHA(change);
+ if (!latestRevisionSha)
+ throw new Error('Could not find latest Revision Sha');
+ const currentRevision = change.revisions[latestRevisionSha];
+ if (currentRevision.commit && currentRevision.commit.message) {
+ this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+ currentRevision.commit.message
+ );
+ } else {
+ this._latestCommitMessage = null;
+ }
+
+ const lineHeight = getComputedStyle(this).lineHeight;
+
+ // Slice returns a number as a string, convert to an int.
+ this._lineHeight = parseInt(
+ lineHeight.slice(0, lineHeight.length - 2),
+ 10
+ );
+
+ this._change = change;
+ if (
+ !this._patchRange ||
+ !this._patchRange.patchNum ||
+ patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+ ) {
+ // CommitInfo.commit is optional, and may need patching.
+ if (currentRevision.commit && !currentRevision.commit.commit) {
+ currentRevision.commit.commit = latestRevisionSha as CommitId;
+ }
+ this._commitInfo = currentRevision.commit;
+ this._selectedRevision = currentRevision;
+ // TODO: Fetch and process files.
+ } else {
+ if (!this._change?.revisions || !this._patchRange) return false;
+ this._selectedRevision = Object.values(this._change.revisions).find(
+ revision => {
+ // edit patchset is a special one
+ const thePatchNum = this._patchRange!.patchNum;
+ if (thePatchNum === 'edit') {
+ return revision._number === thePatchNum;
+ }
+ return revision._number === parseInt(`${thePatchNum}`, 10);
+ }
+ );
+ }
+ return false;
+ }
+ );
+ }
+
+ _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
+ return !!(
+ revisionActions &&
+ revisionActions.submit &&
+ revisionActions.submit.enabled
+ );
+ }
+
+ _isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
+ if (revisionActions && revisionActions.rebase) {
+ return !revisionActions.rebase.enabled;
+ } else {
+ return true;
+ }
+ }
+
+ _getEdit() {
+ if (!this._changeNum)
+ return Promise.reject(new Error('missing required changeNum property'));
+ return this.$.restAPI.getChangeEdit(this._changeNum, true);
+ }
+
+ _getLatestCommitMessage() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (lastpatchNum === undefined)
+ throw new Error('missing lastPatchNum property');
+ return this.$.restAPI
+ .getChangeCommitInfo(this._changeNum, lastpatchNum)
+ .then(commitInfo => {
+ if (!commitInfo) return;
+ this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+ commitInfo.message
+ );
+ });
+ }
+
+ _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
+ if (change.current_revision) {
+ return change.current_revision;
+ }
+ // current_revision may not be present in the case where the latest rev is
+ // a draft and the user doesn’t have permission to view that rev.
+ let latestRev = null;
+ let latestPatchNum = -1 as PatchSetNum;
+ for (const rev in change.revisions) {
+ if (!hasOwnProperty(change.revisions, rev)) {
+ continue;
+ }
+
+ if (change.revisions[rev]._number > latestPatchNum) {
+ latestRev = rev;
+ latestPatchNum = change.revisions[rev]._number;
+ }
+ }
+ return latestRev;
+ }
+
+ _getCommitInfo() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (this._patchRange.patchNum === undefined)
+ throw new Error('missing required patchNum property');
+ return this.$.restAPI
+ .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
+ .then(commitInfo => {
+ this._commitInfo = commitInfo;
+ });
+ }
+
+ _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
+ return this._reloadDrafts().then(() => e.detail.resolve());
+ }
+
+ /**
+ * Fetches a new changeComment object, and data for all types of comments
+ * (comments, robot comments, draft comments) is requested.
+ */
+ _reloadComments() {
+ // We are resetting all comment related properties, because we want to avoid
+ // a new change being loaded and then paired with outdated comments.
+ this._changeComments = undefined;
+ this._commentThreads = undefined;
+ this._diffDrafts = undefined;
+ this._draftCommentThreads = undefined;
+ this._robotCommentThreads = undefined;
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ return this.$.commentAPI
+ .loadAll(this._changeNum)
+ .then(comments => this._recomputeComments(comments));
+ }
+
+ /**
+ * Fetches a new changeComment object, but only updated data for drafts is
+ * requested.
+ *
+ * TODO(taoalpha): clean up this and _reloadComments, as single comment
+ * can be a thread so it does not make sense to only update drafts
+ * without updating threads
+ */
+ _reloadDrafts() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ return this.$.commentAPI
+ .reloadDrafts(this._changeNum)
+ .then(comments => this._recomputeComments(comments));
+ }
+
+ _recomputeComments(comments: ChangeComments) {
+ this._changeComments = comments;
+ this._diffDrafts = {...this._changeComments.drafts};
+ this._commentThreads = this._changeComments.getAllThreadsForChange();
+ this._draftCommentThreads = this._commentThreads
+ .filter(isDraftThread)
+ .map(thread => {
+ const copiedThread = {...thread};
+ // Make a hardcopy of all comments and collapse all but last one
+ const commentsInThread = (copiedThread.comments = thread.comments.map(
+ comment => {
+ return {...comment, collapsed: true as boolean};
+ }
+ ));
+ commentsInThread[commentsInThread.length - 1].collapsed = false;
+ return copiedThread;
+ });
+ }
+
+ /**
+ * Reload the change.
+ *
+ * @param isLocationChange Reloads the related changes
+ * when true and ends reporting events that started on location change.
+ * @param clearPatchset Reloads the related changes
+ * ignoring any patchset choice made.
+ * @return A promise that resolves when the core data has loaded.
+ * Some non-core data loading may still be in-flight when the core data
+ * promise resolves.
+ */
+ _reload(isLocationChange?: boolean, clearPatchset?: boolean) {
+ if (clearPatchset && this._change) {
+ GerritNav.navigateToChange(this._change);
+ return Promise.resolve([]);
+ }
+ this._loading = true;
+ this._relatedChangesCollapsed = true;
+ this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+ this.reporting.time(CHANGE_DATA_TIMING_LABEL);
+
+ // Array to house all promises related to data requests.
+ const allDataPromises: Promise<unknown>[] = [];
+
+ // Resolves when the change detail and the edit patch set (if available)
+ // are loaded.
+ const detailCompletes = this._getChangeDetail();
+ allDataPromises.push(detailCompletes);
+
+ // Resolves when the loading flag is set to false, meaning that some
+ // change content may start appearing.
+ const loadingFlagSet = detailCompletes
+ .then(() => {
+ this._loading = false;
+ this.dispatchEvent(
+ new CustomEvent('change-details-loaded', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ })
+ .then(() => {
+ this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+ if (isLocationChange) {
+ this.reporting.changeDisplayed();
+ }
+ });
+
+ // Resolves when the project config has loaded.
+ const projectConfigLoaded = detailCompletes.then(() =>
+ this._getProjectConfig()
+ );
+ allDataPromises.push(projectConfigLoaded);
+
+ // Resolves when change comments have loaded (comments, drafts and robot
+ // comments).
+ const commentsLoaded = this._reloadComments();
+ allDataPromises.push(commentsLoaded);
+
+ let coreDataPromise;
+
+ // If the patch number is specified
+ if (this._patchRange && this._patchRange.patchNum) {
+ // Because a specific patchset is specified, reload the resources that
+ // are keyed by patch number or patch range.
+ const patchResourcesLoaded = this._reloadPatchNumDependentResources();
+ allDataPromises.push(patchResourcesLoaded);
+
+ // Promise resolves when the change detail and patch dependent resources
+ // have loaded.
+ const detailAndPatchResourcesLoaded = Promise.all([
+ patchResourcesLoaded,
+ loadingFlagSet,
+ ]);
+
+ // Promise resolves when mergeability information has loaded.
+ const mergeabilityLoaded = detailAndPatchResourcesLoaded.then(() =>
+ this._getMergeability()
+ );
+ allDataPromises.push(mergeabilityLoaded);
+
+ // Promise resovles when the change actions have loaded.
+ const actionsLoaded = detailAndPatchResourcesLoaded.then(() =>
+ this.$.actions.reload()
+ );
+ allDataPromises.push(actionsLoaded);
+
+ // The core data is loaded when both mergeability and actions are known.
+ coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
+ } else {
+ // Resolves when the file list has loaded.
+ const fileListReload = loadingFlagSet.then(() =>
+ this.$.fileList.reload()
+ );
+ allDataPromises.push(fileListReload);
+
+ const latestCommitMessageLoaded = loadingFlagSet.then(() => {
+ // If the latest commit message is known, there is nothing to do.
+ if (this._latestCommitMessage) {
+ return Promise.resolve();
+ }
+ return this._getLatestCommitMessage();
+ });
+ allDataPromises.push(latestCommitMessageLoaded);
+
+ // Promise resolves when mergeability information has loaded.
+ const mergeabilityLoaded = loadingFlagSet.then(() =>
+ this._getMergeability()
+ );
+ allDataPromises.push(mergeabilityLoaded);
+
+ // Core data is loaded when mergeability has been loaded.
+ coreDataPromise = Promise.all([mergeabilityLoaded]);
+ }
+
+ if (isLocationChange) {
+ this._editingCommitMessage = false;
+ const relatedChangesLoaded = coreDataPromise.then(() =>
+ this.$.relatedChanges.reload()
+ );
+ allDataPromises.push(relatedChangesLoaded);
+ }
+
+ Promise.all(allDataPromises).then(() => {
+ this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+ if (isLocationChange) {
+ this.reporting.changeFullyLoaded();
+ }
+ });
+
+ return coreDataPromise;
+ }
+
+ /**
+ * Kicks off requests for resources that rely on the patch range
+ * (`this._patchRange`) being defined.
+ */
+ _reloadPatchNumDependentResources() {
+ return Promise.all([this._getCommitInfo(), this.$.fileList.reload()]);
+ }
+
+ _getMergeability() {
+ if (!this._change) {
+ this._mergeable = null;
+ return Promise.resolve();
+ }
+ // If the change is closed, it is not mergeable. Note: already merged
+ // changes are obviously not mergeable, but the mergeability API will not
+ // answer for abandoned changes.
+ if (
+ this._change.status === ChangeStatus.MERGED ||
+ this._change.status === ChangeStatus.ABANDONED
+ ) {
+ this._mergeable = false;
+ return Promise.resolve();
+ }
+
+ if (!this._changeNum) {
+ return Promise.reject(new Error('missing required changeNum property'));
+ }
+
+ this._mergeable = null;
+ return this.$.restAPI.getMergeable(this._changeNum).then(mergableInfo => {
+ if (mergableInfo) {
+ this._mergeable = mergableInfo.mergeable;
+ }
+ });
+ }
+
+ _computeCanStartReview(change: ChangeInfo) {
+ return !!(
+ change.actions &&
+ change.actions.ready &&
+ change.actions.ready.enabled
+ );
+ }
+
+ _computeReplyDisabled() {
+ return false;
+ }
+
+ _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
+ return `Change ${changeNum}`;
+ }
+
+ _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+ return collapsible && collapsed;
+ }
+
+ _computeRelatedChangesClass(collapsed: boolean) {
+ return collapsed ? 'collapsed' : '';
+ }
+
+ _computeCollapseText(collapsed: boolean) {
+ // Symbols are up and down triangles.
+ return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
+ }
+
+ /**
+ * Returns the text to be copied when
+ * click the copy icon next to change subject
+ */
+ _computeCopyTextForTitle(change: ChangeInfo) {
+ return (
+ `${change._number}: ${change.subject} | ` +
+ `${location.protocol}//${location.host}` +
+ `${this._computeChangeUrl(change)}`
+ );
+ }
+
+ _toggleCommitCollapsed() {
+ this._commitCollapsed = !this._commitCollapsed;
+ if (this._commitCollapsed) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ _toggleRelatedChangesCollapsed() {
+ this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
+ if (this._relatedChangesCollapsed) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ _computeCommitCollapsible(commitMessage?: string) {
+ if (!commitMessage) {
+ return false;
+ }
+ return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+ }
+
+ _getOffsetHeight(element: HTMLElement) {
+ return element.offsetHeight;
+ }
+
+ _getScrollHeight(element: HTMLElement) {
+ return element.scrollHeight;
+ }
+
+ /**
+ * Get the line height of an element to the nearest integer.
+ */
+ _getLineHeight(element: Element) {
+ const lineHeightStr = getComputedStyle(element).lineHeight;
+ return Math.round(Number(lineHeightStr.slice(0, lineHeightStr.length - 2)));
+ }
+
+ /**
+ * New max height for the related changes section, shorter than the existing
+ * change info height.
+ */
+ _updateRelatedChangeMaxHeight() {
+ // Takes into account approximate height for the expand button and
+ // bottom margin.
+ const EXTRA_HEIGHT = 30;
+ let newHeight;
+
+ if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`).matches) {
+ // In a small (mobile) view, give the relation chain some space.
+ newHeight = SMALL_RELATED_HEIGHT;
+ } else if (
+ window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`).matches
+ ) {
+ // Since related changes are below the commit message, but still next to
+ // metadata, the height should be the height of the metadata minus the
+ // height of the commit message to reduce jank. However, if that doesn't
+ // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
+ // Note: extraHeight is to take into account margin/padding.
+ const medRelatedHeight = Math.max(
+ this._getOffsetHeight(this.$.mainChangeInfo) -
+ this._getOffsetHeight(this.$.commitMessage) -
+ 2 * EXTRA_HEIGHT,
+ MINIMUM_RELATED_MAX_HEIGHT
+ );
+ newHeight = medRelatedHeight;
+ } else {
+ if (this._commitCollapsible) {
+ // Make sure the content is lined up if both areas have buttons. If
+ // the commit message is not collapsed, instead use the change info
+ // height.
+ newHeight = this._getOffsetHeight(this.$.commitMessage);
+ } else {
+ newHeight =
+ this._getOffsetHeight(this.$.commitAndRelated) - EXTRA_HEIGHT;
+ }
+ }
+ const stylesToUpdate: {[key: string]: string} = {};
+
+ // Get the line height of related changes, and convert it to the nearest
+ // integer.
+ const lineHeight = this._getLineHeight(this.$.relatedChanges);
+
+ // Figure out a new height that is divisible by the rounded line height.
+ const remainder = newHeight % lineHeight;
+ newHeight = newHeight - remainder;
+
+ stylesToUpdate['--relation-chain-max-height'] = `${newHeight}px`;
+
+ // Update the max-height of the relation chain to this new height.
+ if (this._commitCollapsible) {
+ stylesToUpdate['--related-change-btn-top-padding'] = `${remainder}px`;
+ }
+
+ this.updateStyles(stylesToUpdate);
+ }
+
+ _computeShowRelatedToggle() {
+ // Make sure the max height has been applied, since there is now content
+ // to populate.
+ if (!getComputedStyleValue('--relation-chain-max-height', this)) {
+ this._updateRelatedChangeMaxHeight();
+ }
+ // Prevents showMore from showing when click on related change, since the
+ // line height would be positive, but related changes height is 0.
+ if (!this._getScrollHeight(this.$.relatedChanges)) {
+ return (this._showRelatedToggle = false);
+ }
+
+ if (
+ this._getScrollHeight(this.$.relatedChanges) >
+ this._getOffsetHeight(this.$.relatedChanges) +
+ this._getLineHeight(this.$.relatedChanges)
+ ) {
+ return (this._showRelatedToggle = true);
+ }
+ return (this._showRelatedToggle = false);
+ }
+
+ _updateToggleContainerClass(showRelatedToggle: boolean) {
+ if (showRelatedToggle) {
+ this.$.relatedChangesToggle.classList.add('showToggle');
+ } else {
+ this.$.relatedChangesToggle.classList.remove('showToggle');
+ }
+ }
+
+ _startUpdateCheckTimer() {
+ if (
+ !this._serverConfig ||
+ !this._serverConfig.change ||
+ this._serverConfig.change.update_delay === undefined ||
+ this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
+ ) {
+ return;
+ }
+
+ this._updateCheckTimerHandle = this.async(() => {
+ if (!this._change) throw new Error('missing required change property');
+ const change = this._change;
+ fetchChangeUpdates(change, this.$.restAPI).then(result => {
+ let toastMessage = null;
+ if (!result.isLatest) {
+ toastMessage = ReloadToastMessage.NEWER_REVISION;
+ } else if (result.newStatus === ChangeStatus.MERGED) {
+ toastMessage = ReloadToastMessage.MERGED;
+ } else if (result.newStatus === ChangeStatus.ABANDONED) {
+ toastMessage = ReloadToastMessage.ABANDONED;
+ } else if (result.newStatus === ChangeStatus.NEW) {
+ toastMessage = ReloadToastMessage.RESTORED;
+ } else if (result.newMessages) {
+ toastMessage = ReloadToastMessage.NEW_MESSAGE;
+ }
+
+ // We have to make sure that the update is still relevant for the user.
+ // Since starting to fetch the change update the user may have sent a
+ // reply, or the change might have been reloaded, or it could be in the
+ // process of being reloaded.
+ const changeWasReloaded = change !== this._change;
+ if (!toastMessage || this._loading || changeWasReloaded) {
+ this._startUpdateCheckTimer();
+ return;
+ }
+
+ this._cancelUpdateCheckTimer();
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: toastMessage,
+ // Persist this alert.
+ dismissOnNavigation: true,
+ action: 'Reload',
+ callback: () => {
+ this._reload(
+ /* isLocationChange= */ false,
+ /* clearPatchset= */ true
+ );
+ },
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }, this._serverConfig.change.update_delay * 1000);
+ }
+
+ _cancelUpdateCheckTimer() {
+ if (this._updateCheckTimerHandle) {
+ this.cancelAsync(this._updateCheckTimerHandle);
+ }
+ this._updateCheckTimerHandle = null;
+ }
+
+ _handleVisibilityChange() {
+ if (document.hidden && this._updateCheckTimerHandle) {
+ this._cancelUpdateCheckTimer();
+ } else if (!this._updateCheckTimerHandle) {
+ this._startUpdateCheckTimer();
+ }
+ }
+
+ _handleTopicChanged() {
+ this.$.relatedChanges.reload();
+ }
+
+ _computeHeaderClass(editMode: boolean) {
+ const classes = ['header'];
+ if (editMode) {
+ classes.push('editMode');
+ }
+ return classes.join(' ');
+ }
+
+ _computeEditMode(
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+ paramsRecord: PolymerDeepPropertyChange<
+ AppElementChangeViewParams,
+ AppElementChangeViewParams
+ >
+ ) {
+ if (!patchRangeRecord || !paramsRecord) {
+ return undefined;
+ }
+
+ if (paramsRecord.base && paramsRecord.base.edit) {
+ return true;
+ }
+
+ const patchRange = patchRangeRecord.base || {};
+ return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+ }
+
+ _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+ e.preventDefault();
+ const controls = this.$.fileListHeader.shadowRoot!.querySelector(
+ '#editControls'
+ ) as GrEditControls | null;
+ if (!controls) throw new Error('Missing edit controls');
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const path = e.detail.path;
+ switch (e.detail.action) {
+ case GrEditConstants.Actions.DELETE.id:
+ controls.openDeleteDialog(path);
+ break;
+ case GrEditConstants.Actions.OPEN.id:
+ GerritNav.navigateToRelativeUrl(
+ GerritNav.getEditUrlForDiff(
+ this._change,
+ path,
+ this._patchRange.patchNum
+ )
+ );
+ break;
+ case GrEditConstants.Actions.RENAME.id:
+ controls.openRenameDialog(path);
+ break;
+ case GrEditConstants.Actions.RESTORE.id:
+ controls.openRestoreDialog(path);
+ break;
+ }
+ }
+
+ _computeCommitMessageKey(number: NumericChangeId, revision: CommitId) {
+ return `c${number}_rev${revision}`;
+ }
+
+ @observe('_patchRange.patchNum')
+ _patchNumChanged(patchNumStr: PatchSetNum) {
+ if (!this._selectedRevision) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+
+ let patchNum: PatchSetNum;
+ if (patchNumStr === 'edit') {
+ patchNum = EditPatchSetNum;
+ } else {
+ patchNum = parseInt(`${patchNumStr}`, 10) as PatchSetNum;
+ }
+
+ if (patchNum === this._selectedRevision._number) {
+ return;
+ }
+ if (this._change.revisions)
+ this._selectedRevision = Object.values(this._change.revisions).find(
+ revision => revision._number === patchNum
+ );
+ }
+
+ /**
+ * If an edit exists already, load it. Otherwise, toggle edit mode via the
+ * navigation API.
+ */
+ _handleEditTap() {
+ if (!this._change || !this._change.revisions)
+ throw new Error('missing required change property');
+ const editInfo = Object.values(this._change.revisions).find(
+ info => info._number === EditPatchSetNum
+ );
+
+ if (editInfo) {
+ GerritNav.navigateToChange(this._change, EditPatchSetNum);
+ return;
+ }
+
+ // Avoid putting patch set in the URL unless a non-latest patch set is
+ // selected.
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ let patchNum;
+ if (
+ !patchNumEquals(
+ this._patchRange.patchNum,
+ computeLatestPatchNum(this._allPatchSets)
+ )
+ ) {
+ patchNum = this._patchRange.patchNum;
+ }
+ GerritNav.navigateToChange(this._change, patchNum, undefined, true);
+ }
+
+ _handleStopEditTap() {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+ }
+
+ _resetReplyOverlayFocusStops() {
+ this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+ }
+
+ _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+ this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+ }
+
+ _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
+ return new RevisionInfoClass(change);
+ }
+
+ _computeCurrentRevision(
+ currentRevision: CommitId,
+ revisions: {[revisionId: string]: RevisionInfo}
+ ) {
+ return currentRevision && revisions && revisions[currentRevision];
+ }
+
+ _computeDiffPrefsDisabled(disableDiffPrefs: boolean, loggedIn: boolean) {
+ return disableDiffPrefs || !loggedIn;
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+ return computeLatestPatchNum(allPatchSets);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+ return hasEditBasedOnCurrentPatchSet(allPatchSets);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _hasEditPatchsetLoaded(
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+ ) {
+ const patchRange = patchRangeRecord.base;
+ if (!patchRange) {
+ return false;
+ }
+ return hasEditPatchsetLoaded(patchRange);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeAllPatchSets(change: ChangeInfo) {
+ return computeAllPatchSets(change);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-view': GrChangeView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index 6170bea..4fcbc78 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -300,6 +300,7 @@
_fetchSharedCacheURL() { return Promise.resolve({}); },
});
element = fixture.instantiate();
+ element._changeNum = '1';
sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
getPluginLoader().loadPlugins([]);
pluginApi.install(
@@ -333,6 +334,11 @@
basePatchNum: 'PARENT',
patchNum: 1,
};
+ element._change = {
+ _number: '1',
+ project: '',
+ change_id: '1',
+ };
const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
const replaceStateStub = sinon.stub(history, 'replaceState');
element._handleMessageAnchorTap({detail: {id: 'a12345'}});
@@ -414,6 +420,7 @@
suite('plugins adding to file tab', () => {
setup(done => {
+ element._changeNum = '1';
// Resolving it here instead of during setup() as other tests depend
// on flush() not being called during setup.
flush(() => done());
@@ -459,6 +466,7 @@
queryMap.set('tab', PrimaryTab.FINDINGS);
// view is required
element.params = {
+ changeNum: '1',
view: GerritNav.View.CHANGE,
...element.params, queryMap};
flush(() => {
@@ -473,6 +481,7 @@
queryMap.set('tab', 'random');
// view is required
element.params = {
+ changeNum: '1',
view: GerritNav.View.CHANGE,
...element.params, queryMap};
flush(() => {
@@ -783,6 +792,7 @@
getAllThreadsForChange: () => ([]),
computeDraftCount: () => 1,
}));
+ element._changeNum = '1';
});
test('drafts are reloaded when reload-drafts fired', done => {
@@ -1415,6 +1425,7 @@
});
test('_handleCommitMessageSave trims trailing whitespace', () => {
+ element._change = {};
const putStub = sinon.stub(element.$.restAPI, 'putChangeCommitMessage')
.returns(Promise.resolve({}));
@@ -1609,14 +1620,16 @@
});
test('_openReplyDialog called with `ANY` when coming from tap event',
- () => {
- const openStub = sinon.stub(element, '_openReplyDialog');
- element._serverConfig = {};
- MockInteractions.tap(element.$.replyBtn);
- assert(openStub.lastCall.calledWithExactly(
- element.$.replyDialog.FocusTarget.ANY),
- '_openReplyDialog should have been passed ANY');
- assert.equal(openStub.callCount, 1);
+ done => {
+ flush(() => {
+ const openStub = sinon.stub(element, '_openReplyDialog');
+ MockInteractions.tap(element.$.replyBtn);
+ assert(openStub.lastCall.calledWithExactly(
+ element.$.replyDialog.FocusTarget.ANY),
+ '_openReplyDialog should have been passed ANY');
+ assert.equal(openStub.callCount, 1);
+ done();
+ });
});
test('_openReplyDialog called with `BODY` when coming from message reply' +
@@ -1806,10 +1819,13 @@
});
});
- test('reply button is disabled until server config is loaded', () => {
+ test('reply button is disabled until server config is loaded', done => {
assert.isTrue(element._replyDisabled);
- element._serverConfig = {};
- assert.isFalse(element._replyDisabled);
+ // fetches the server config on attached
+ flush(() => {
+ assert.isFalse(element._replyDisabled);
+ done();
+ });
});
suite('commit message expand/collapse', () => {
@@ -2189,6 +2205,11 @@
basePatchNum: 'PARENT',
patchNum: 1,
};
+ element._change = {
+ _number: '1',
+ project: '',
+ change_id: '1',
+ };
const fileList = element.$.fileList;
const Actions = GrEditConstants.Actions;
element.$.fileListHeader.editMode = true;
@@ -2371,6 +2392,11 @@
});
test('_handleStopEditTap', done => {
+ element._change = {
+ _number: '1',
+ project: '',
+ change_id: '1',
+ };
sinon.stub(element.$.metadata, '_computeLabelNames');
navigateToChangeStub.restore();
sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index f5e3588..1957f5c 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -65,7 +65,7 @@
loadData() {
if (!this.changeNum) {
- return;
+ return Promise.reject(new Error('missing required property changeNum'));
}
this._filterText = '';
return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(configs => {
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 7fc5973..7c71eaa 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
@@ -113,7 +113,7 @@
const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
-enum FocusTarget {
+export enum FocusTarget {
ANY = 'any',
BODY = 'body',
CCS = 'cc',
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index d7a1f17..5ca7d887 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -35,12 +35,14 @@
Reviewers,
AccountId,
DetailedLabelInfo,
+ EmailAddress,
} from '../../../types/common';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {hasOwnProperty} from '../../../utils/common-util';
import {isRemovableReviewer} from '../../../utils/change-util';
+import {ReviewerState} from '../../../constants/constants';
export interface GrReviewerList {
$: {
@@ -262,7 +264,7 @@
if (!target.account || !this.change) {
return;
}
- const accountID = target.account._account_id;
+ const accountID = target.account._account_id || target.account.email;
this.disabled = true;
if (!accountID) return;
this._xhrPromise = this._removeReviewer(accountID)
@@ -272,12 +274,15 @@
return response;
}
if (!this.change || !this.change.reviewers) return;
- const reviewers: {[type: string]: AccountInfo[] | undefined} = this
- .change!.reviewers;
- for (const type of ['REVIEWER', 'CC']) {
- reviewers[type] = reviewers[type] || [];
- for (let i = 0; i < reviewers[type]!.length; i++) {
- if (reviewers[type]![i]._account_id === accountID) {
+ const reviewers = this.change.reviewers;
+ for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+ const reviewerStateByType = reviewers[type] || [];
+ reviewers[type] = reviewerStateByType;
+ for (let i = 0; i < reviewerStateByType.length; i++) {
+ if (
+ reviewerStateByType[i]._account_id === accountID ||
+ reviewerStateByType[i].email === accountID
+ ) {
this.splice('change.reviewers.' + type, i, 1);
break;
}
@@ -316,7 +321,7 @@
this._displayedReviewers = this._reviewers;
}
- _removeReviewer(id: AccountId): Promise<Response | undefined> {
+ _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
if (!this.change) return Promise.resolve(undefined);
return this.$.restAPI.removeChangeReviewer(this.change._number, id);
}
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index ad9af30..d29abfc 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -116,6 +116,101 @@
}
});
+ suite('_handleRemove', () => {
+ let removeReviewerStub;
+ let reviewersChangedSpy;
+
+ const reviewerWithId = {
+ _account_id: 2,
+ name: 'Some name',
+ };
+
+ const reviewerWithIdAndEmail = {
+ _account_id: 4,
+ name: 'Some other name',
+ email: 'example@',
+ };
+
+ const reviewerWithEmailOnly = {
+ email: 'example2@example',
+ };
+
+ let chips;
+
+ setup(() => {
+ removeReviewerStub = sinon
+ .stub(element, '_removeReviewer')
+ .returns(Promise.resolve(new Response({status: 200})));
+ element.mutable = true;
+
+ const allReviewers = [
+ reviewerWithId,
+ reviewerWithIdAndEmail,
+ reviewerWithEmailOnly,
+ ];
+
+ element.change = {
+ owner: {
+ _account_id: 1,
+ },
+ reviewers: {
+ REVIEWER: allReviewers,
+ },
+ removable_reviewers: allReviewers,
+ };
+ flush();
+ chips = Array.from(element.root.querySelectorAll('gr-account-chip'));
+ assert.equal(chips.length, allReviewers.length);
+ reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
+ });
+
+ test('_handleRemove for account with accountId only', async () => {
+ const accountChip = chips.find(chip =>
+ chip.account._account_id === reviewerWithId._account_id
+ );
+ accountChip._handleRemoveTap(new MouseEvent('click'));
+ await flush();
+ assert.isTrue(removeReviewerStub.calledOnce);
+ assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
+ assert.isTrue(reviewersChangedSpy.called);
+ expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+ reviewerWithIdAndEmail,
+ reviewerWithEmailOnly,
+ ]);
+ });
+
+ test('_handleRemove for account with accountId and email', async () => {
+ const accountChip = chips.find(chip =>
+ chip.account._account_id === reviewerWithIdAndEmail._account_id
+ );
+ accountChip._handleRemoveTap(new MouseEvent('click'));
+ await flush();
+ assert.isTrue(removeReviewerStub.calledOnce);
+ assert.isTrue(
+ removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id));
+ assert.isTrue(reviewersChangedSpy.called);
+ expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+ reviewerWithId,
+ reviewerWithEmailOnly,
+ ]);
+ });
+
+ test('_handleRemove for account with email only', async () => {
+ const accountChip = chips.find(
+ chip => chip.account.email === reviewerWithEmailOnly.email
+ );
+ accountChip._handleRemoveTap(new MouseEvent('click'));
+ await flush();
+ assert.isTrue(removeReviewerStub.calledOnce);
+ assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
+ assert.isTrue(reviewersChangedSpy.called);
+ expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+ reviewerWithId,
+ reviewerWithIdAndEmail,
+ ]);
+ });
+ });
+
test('tracking reviewers and ccs', () => {
let counter = 0;
function makeAccount() {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 33c1346..38c3c9c 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -131,15 +131,20 @@
return this._robotComments;
}
- findCommentById(commentId: UrlEncodedCommentId): Comment | undefined {
- const findComment = (comments: {[path: string]: CommentBasics[]}) => {
+ findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
+ if (!commentId) return undefined;
+ const findComment = (comments: {[path: string]: UIComment[]}) => {
let comment;
for (const path of Object.keys(comments)) {
comment = comment || comments[path].find(c => c.id === commentId);
}
return comment;
};
- return findComment(this._comments) || findComment(this._robotComments);
+ return (
+ findComment(this._comments) ||
+ findComment(this._robotComments) ||
+ findComment(this._drafts)
+ );
}
/**
@@ -580,14 +585,14 @@
export const _testOnly_findCommentById =
ChangeComments.prototype.findCommentById;
-interface GrCommentApi {
+export interface GrCommentApi {
$: {
restAPI: RestApiService & Element;
};
}
@customElement('gr-comment-api')
-class GrCommentApi extends GestureEventListeners(
+export class GrCommentApi extends GestureEventListeners(
LegacyElementMixin(PolymerElement)
) {
static get template() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index e7c9a4a..4f94550 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -34,7 +34,6 @@
import {
Comment,
isDraft,
- PatchSetFile,
sortComments,
UIComment,
} from '../../../utils/comment-util';
@@ -70,6 +69,7 @@
import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
import {LineNumber} from '../gr-diff/gr-diff-line';
import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {PatchSetFile} from '../../../types/types';
const MSG_EMPTY_BLAME = 'No blame information for this diff.';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
deleted file mode 100644
index fa31d09..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ /dev/null
@@ -1,1593 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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.
- */
-import '@polymer/iron-dropdown/iron-dropdown.js';
-import '@polymer/iron-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-comment-api/gr-comment-api.js';
-import '../gr-diff-cursor/gr-diff-cursor.js';
-import '../gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-diff-host/gr-diff-host.js';
-import '../gr-diff-mode-selector/gr-diff-mode-selector.js';
-import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../gr-patch-range-select/gr-patch-range-select.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-diff-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {appContext} from '../../../services/app-context.js';
-import {
- computeAllPatchSets,
- computeLatestPatchNum,
- patchNumEquals,
- SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {
- addUnmodifiedFiles, computeDisplayPath, computeTruncatedPath,
- isMagicPath, specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
-import {Side} from '../../../constants/constants.js';
-
-const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
-const MSG_LOADING_BLAME = 'Loading blame...';
-const MSG_LOADED_BLAME = 'Blame loaded';
-
-const PARENT = 'PARENT';
-
-const DiffSides = {
- LEFT: 'left',
- RIGHT: 'right',
-};
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrDiffView extends KeyboardShortcutMixin(
- GestureEventListeners(LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
- * Fired when user tries to navigate away while comments are pending save.
- *
- * @event show-alert
- */
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- /**
- * @type {{ diffMode: (string|undefined) }}
- */
- changeViewState: {
- type: Object,
- notify: true,
- value() { return {}; },
- observer: '_changeViewStateChanged',
- },
- disableDiffPrefs: {
- type: Boolean,
- value: false,
- },
- _diffPrefsDisabled: {
- type: Boolean,
- computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
- },
- /** @type {?} */
- _patchRange: Object,
- /** @type {?} */
- _commitRange: Object,
- /**
- * @type {{
- * subject: string,
- * project: string,
- * revisions: string,
- * }}
- */
- _change: Object,
- /** @type {?} */
- _changeComments: Object,
- _changeNum: String,
- /**
- * This is a DiffInfo object.
- * This is retrieved and owned by a child component.
- */
- _diff: Object,
- // An array specifically formatted to be used in a gr-dropdown-list
- // element for selected a file to view.
- _formattedFiles: {
- type: Array,
- computed: '_formatFilesForDropdown(_files, ' +
- '_patchRange.patchNum, _changeComments)',
- },
- // An sorted array of files, as returned by the rest API.
- _fileList: {
- type: Array,
- computed: '_getSortedFileList(_files)',
- },
- /**
- * Contains information about files as returned by the rest API.
- *
- * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
- */
- _files: {
- type: Object,
- value() { return {sortedFileList: [], changeFilesByPath: {}}; },
- },
-
- /** @type {Gerrit.FileRange} */
- _file: {
- type: Object,
- computed: '_getCurrentFile(_files, _path)',
- },
-
- _path: {
- type: String,
- observer: '_pathChanged',
- },
- _fileNum: {
- type: Number,
- computed: '_computeFileNum(_path, _formattedFiles)',
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _prefs: Object,
- _projectConfig: Object,
- _userPrefs: Object,
- _diffMode: {
- type: String,
- computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
- },
- _isImageDiff: Boolean,
- // The return type is FilesWebLinks from gr-patch-range-select.
- _filesWeblinks: Object,
-
- /**
- * Map of paths in the current change and patch range that have comments
- * or drafts or robot comments.
- */
- _commentMap: Object,
-
- _commentsForDiff: Object,
-
- /**
- * Object to contain the path of the next and previous file in the current
- * change and patch range that has comments.
- */
- _commentSkips: {
- type: Object,
- computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
- },
- _editMode: {
- type: Boolean,
- computed: '_computeEditMode(_patchRange.*)',
- },
- _isBlameLoaded: Boolean,
- _isBlameLoading: {
- type: Boolean,
- value: false,
- },
- _allPatchSets: {
- type: Array,
- computed: '_computeAllPatchSets(_change, _change.revisions.*)',
- },
- _revisionInfo: {
- type: Object,
- computed: '_getRevisionInfo(_change)',
- },
- _reviewedFiles: {
- type: Object,
- value: () => new Set(),
- },
- // line number on the diff which should be scrolled to upon loading
- _focusLineNum: Number,
- };
- }
-
- static get observers() {
- return [
- '_getProjectConfig(_change.project)',
- '_getFiles(_changeNum, _patchRange.*, _changeComments)',
- '_setReviewedObserver(_loggedIn, params.*, _prefs, _patchRange.*)',
- '_recomputeComments(_files.changeFilesByPath,' +
- '_path, _patchRange, _projectConfig)',
- ];
- }
-
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- };
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.LEFT_PANE]: '_handleLeftPane',
- [Shortcut.RIGHT_PANE]: '_handleRightPane',
- [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
- [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
- [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
- [Shortcut.NEXT_FILE_WITH_COMMENTS]:
- '_handleNextLineOrFileWithComments',
- [Shortcut.PREV_FILE_WITH_COMMENTS]:
- '_handlePrevLineOrFileWithComments',
- [Shortcut.NEW_COMMENT]: '_handleNewComment',
- [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
- [Shortcut.NEXT_FILE]: '_handleNextFile',
- [Shortcut.PREV_FILE]: '_handlePrevFile',
- [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
- [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
- [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
- [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
- [Shortcut.OPEN_REPLY_DIALOG]:
- '_handleOpenReplyDialogOrToggleLeftPane',
- [Shortcut.TOGGLE_LEFT_PANE]:
- '_handleOpenReplyDialogOrToggleLeftPane',
- [Shortcut.OPEN_DOWNLOAD_DIALOG]:
- '_handleOpenDownloadDialog',
- [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
- [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
- [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
- [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
- [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
- [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
- [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
- [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
- '_handleToggleHideAllCommentThreads',
- [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
- [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
- [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
- '_handleDiffRightAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LATEST]:
- '_handleDiffBaseAgainstLatest',
-
- // Final two are actually handled by gr-comment-thread.
- [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
- [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- this.flagsService = appContext.flagsService;
- }
-
- connectedCallback() {
- super.connectedCallback();
- this._throttledToggleFileReviewed = this._throttleWrap(e =>
- this._handleToggleFileReviewed(e));
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- });
-
- this.addEventListener('open-fix-preview',
- e => this._onOpenFixPreview(e));
- this.$.cursor.push('diffs', this.$.diffHost);
- this._onRenderHandler = () => {
- this.$.cursor.reInitCursor();
- };
- this.$.diffHost.addEventListener('render', this._onRenderHandler);
- }
-
- detached() {
- this.$.diffHost.removeEventListener('render', this._onRenderHandler);
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getProjectConfig(project) {
- if (!project) return;
- return this.$.restAPI.getProjectConfig(project).then(
- config => {
- this._projectConfig = config;
- });
- }
-
- _getChangeDetail(changeNum) {
- return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
- this._change = change;
- return change;
- });
- }
-
- _getChangeEdit(changeNum) {
- return this.$.restAPI.getChangeEdit(this._changeNum);
- }
-
- _getSortedFileList(files) {
- if (!files) return [];
- return files.sortedFileList;
- }
-
- /**
- * @param {!Object} files
- * @param {string} path
- * @returns {!Gerrit.FileRange}
- */
- _getCurrentFile(files, path) {
- if ([files, path].includes(undefined)) return;
- const fileInfo = files.changeFilesByPath[path];
- const fileRange = {path};
- if (fileInfo && fileInfo.old_path) {
- fileRange.basePath = fileInfo.old_path;
- }
- return fileRange;
- }
-
- _getFiles(changeNum, patchRangeRecord, changeComments) {
- // Polymer 2: check for undefined
- if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
- .some(arg => arg === undefined)) {
- return Promise.resolve();
- }
-
- if (!patchRangeRecord.base.patchNum) {
- return Promise.resolve();
- }
-
- const patchRange = patchRangeRecord.base;
- return this.$.restAPI.getChangeFiles(
- changeNum, patchRange).then(changeFiles => {
- if (!changeFiles) return;
- const commentedPaths = changeComments.getPaths(patchRange);
- const files = {...changeFiles};
- addUnmodifiedFiles(files, commentedPaths);
- this._files = {
- sortedFileList: Object.keys(files).sort(specialFilePathCompare),
- changeFilesByPath: files,
- };
- });
- }
-
- _getDiffPreferences() {
- return this.$.restAPI.getDiffPreferences().then(prefs => {
- this._prefs = prefs;
- });
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _getWindowWidth() {
- return window.innerWidth;
- }
-
- _handleReviewedChange(e) {
- this._setReviewed(dom(e).rootTarget.checked);
- }
-
- _setReviewed(reviewed) {
- if (this._editMode) { return; }
- this.$.reviewed.checked = reviewed;
- if (!this._patchRange.patchNum) return;
- this._saveReviewedState(reviewed).catch(err => {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_REVIEW_STATUS},
- composed: true, bubbles: true,
- }));
- throw err;
- });
- }
-
- _saveReviewedState(reviewed) {
- return this.$.restAPI.saveFileReviewed(this._changeNum,
- this._patchRange.patchNum, this._path, reviewed);
- }
-
- _handleToggleFileReviewed(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._setReviewed(!this.$.reviewed.checked);
- }
-
- _handleEscKey(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.diffHost.displayLine = false;
- }
-
- _handleLeftPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.moveLeft();
- }
-
- _handleRightPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.moveRight();
- }
-
- _handlePrevLineOrFileWithComments(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (e.detail.keyboardEvent.shiftKey &&
- e.detail.keyboardEvent.keyCode === 75) { // 'K'
- this._moveToPreviousFileWithComment();
- return;
- }
- if (this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.diffHost.displayLine = true;
- this.$.cursor.moveUp();
- }
-
- _handleVisibleLine(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.moveToVisibleArea();
- }
-
- _onOpenFixPreview(e) {
- this.$.applyFixDialog.open(e);
- }
-
- _handleNextLineOrFileWithComments(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (e.detail.keyboardEvent.shiftKey &&
- e.detail.keyboardEvent.keyCode === 74) { // 'J'
- this._moveToNextFileWithComment();
- return;
- }
- if (this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.diffHost.displayLine = true;
- this.$.cursor.moveDown();
- }
-
- _moveToPreviousFileWithComment() {
- if (!this._commentSkips) { return; }
-
- // If there is no previous diff with comments, then return to the change
- // view.
- if (!this._commentSkips.previous) {
- this._navToChangeView();
- return;
- }
-
- GerritNav.navigateToDiff(this._change, this._commentSkips.previous,
- this._patchRange.patchNum, this._patchRange.basePatchNum);
- }
-
- _moveToNextFileWithComment() {
- if (!this._commentSkips) { return; }
-
- // If there is no next diff with comments, then return to the change view.
- if (!this._commentSkips.next) {
- this._navToChangeView();
- return;
- }
-
- GerritNav.navigateToDiff(this._change, this._commentSkips.next,
- this._patchRange.patchNum, this._patchRange.basePatchNum);
- }
-
- _handleNewComment(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
- this.$.cursor.createCommentInPlace();
- }
-
- _handlePrevFile(e) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.getKeyboardEvent(e).metaKey) { return; }
-
- e.preventDefault();
- this._navToFile(this._path, this._fileList, -1);
- }
-
- _handleNextFile(e) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.getKeyboardEvent(e).metaKey) { return; }
-
- e.preventDefault();
- this._navToFile(this._path, this._fileList, 1);
- }
-
- _handleNextChunkOrCommentThread(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- if (e.detail.keyboardEvent.shiftKey) {
- this.$.cursor.moveToNextCommentThread();
- } else {
- if (this.modifierPressed(e)) { return; }
- // navigate to next file if key is not being held down
- this.$.cursor.moveToNextChunk(/* opt_clipToTop = */false,
- /* opt_navigateToNextFile = */!e.detail.keyboardEvent.repeat);
- }
- }
-
- _handlePrevChunkOrCommentThread(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- if (e.detail.keyboardEvent.shiftKey) {
- this.$.cursor.moveToPreviousCommentThread();
- } else {
- if (this.modifierPressed(e)) { return; }
- this.$.cursor.moveToPreviousChunk();
- }
- }
-
- _handleOpenReplyDialogOrToggleLeftPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
- e.preventDefault();
- this.$.diffHost.toggleLeftDiff();
- return;
- }
-
- if (this.modifierPressed(e)) { return; }
-
- if (!this._loggedIn) { return; }
-
- this.set('changeViewState.showReplyDialog', true);
- e.preventDefault();
- this._navToChangeView();
- }
-
- _handleOpenDownloadDialog(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (this.modifierPressed(e)) { return; }
- this.set('changeViewState.showDownloadDialog', true);
- e.preventDefault();
- this._navToChangeView();
- }
-
- _handleUpToChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._navToChangeView();
- }
-
- _handleCommaKey(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- if (this._diffPrefsDisabled) { return; }
-
- e.preventDefault();
- this.$.diffPreferencesDialog.open();
- }
-
- _handleToggleDiffMode(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
- this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
- } else {
- this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
- }
- }
-
- _navToChangeView() {
- if (!this._changeNum || !this._patchRange.patchNum) { return; }
- this._navigateToChange(
- this._change,
- this._patchRange,
- this._change && this._change.revisions);
- }
-
- _navToFile(path, fileList, direction) {
- const newPath = this._getNavLinkPath(path, fileList, direction);
- if (!newPath) { return; }
-
- if (newPath.up) {
- this._navigateToChange(
- this._change,
- this._patchRange,
- this._change && this._change.revisions);
- return;
- }
-
- GerritNav.navigateToDiff(this._change, newPath.path,
- this._patchRange.patchNum, this._patchRange.basePatchNum);
- }
-
- /**
- * @param {?string} path The path of the current file being shown.
- * @param {!Array<string>} fileList The list of files in this change and
- * patch range.
- * @param {number} direction Either 1 (next file) or -1 (prev file).
- * @param {(number|boolean)} opt_noUp Whether to return to the change view
- * when advancing the file goes outside the bounds of fileList.
- *
- * @return {?string} The next URL when proceeding in the specified
- * direction.
- */
- _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
- const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
- if (!newPath) { return null; }
-
- if (newPath.up) {
- return this._getChangePath(
- this._change,
- this._patchRange,
- this._change && this._change.revisions);
- }
- return this._getDiffUrl(this._change, this._patchRange, newPath.path);
- }
-
- _goToEditFile() {
- // TODO(taoalpha): add a shortcut for editing
- const cursorAddress = this.$.cursor.getAddress();
- const editUrl = GerritNav.getEditUrlForDiff(
- this._change,
- this._path,
- this._patchRange.patchNum,
- cursorAddress && cursorAddress.number
- );
- return GerritNav.navigateToRelativeUrl(editUrl);
- }
-
- /**
- * Gives an object representing the target of navigating either left or
- * right through the change. The resulting object will have one of the
- * following forms:
- * * {path: "<target file path>"} - When another file path should be the
- * result of the navigation.
- * * {up: true} - When the result of navigating should go back to the
- * change view.
- * * null - When no navigation is possible for the given direction.
- *
- * @param {?string} path The path of the current file being shown.
- * @param {!Array<string>} fileList The list of files in this change and
- * patch range.
- * @param {number} direction Either 1 (next file) or -1 (prev file).
- * @param {?number|boolean=} opt_noUp Whether to return to the change view
- * when advancing the file goes outside the bounds of fileList.
- * @return {?Object}
- */
- _getNavLinkPath(path, fileList, direction, opt_noUp) {
- if (!path || !fileList || fileList.length === 0) { return null; }
-
- let idx = fileList.indexOf(path);
- if (idx === -1) {
- const file = direction > 0 ?
- fileList[0] :
- fileList[fileList.length - 1];
- return {path: file};
- }
-
- idx += direction;
- // Redirect to the change view if opt_noUp isn’t truthy and idx falls
- // outside the bounds of [0, fileList.length).
- if (idx < 0 || idx > fileList.length - 1) {
- if (opt_noUp) { return null; }
- return {up: true};
- }
-
- return {path: fileList[idx]};
- }
-
- _getReviewedFiles(changeNum, patchNum) {
- return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
- .then(files => {
- this._reviewedFiles = new Set(files);
- return this._reviewedFiles;
- });
- }
-
- _getReviewedStatus(editMode, changeNum, patchNum, path) {
- if (editMode) { return Promise.resolve(false); }
- return this._getReviewedFiles(changeNum, patchNum)
- .then(files => files.has(path));
- }
-
- _initLineOfInterestAndCursor(leftSide) {
- this.$.diffHost.lineOfInterest =
- this._getLineOfInterest({
- leftSide,
- });
- this._initCursor({
- leftSide,
- });
- }
-
- _displayDiffBaseAgainstLeftToast() {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- // \u2190 = ←
- message: `Patchset ${this._patchRange.basePatchNum} vs ` +
- `${this._patchRange.patchNum} selected. Press v + \u2190 to view `
- + `Base vs ${this._patchRange.basePatchNum}`,
- },
- composed: true, bubbles: true,
- }));
- }
-
- _displayDiffAgainstLatestToast(latestPatchNum) {
- const leftPatchset = patchNumEquals(
- this._patchRange.basePatchNum, 'PARENT')
- ? 'Base' : `Patchset ${this._patchRange.basePatchNum}`;
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- // \u2191 = ↑
- message: `${leftPatchset} vs
- ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
- ${leftPatchset} vs Patchset ${latestPatchNum}`,
- },
- composed: true, bubbles: true,
- }));
- }
-
- _displayToasts() {
- if (!patchNumEquals(this._patchRange.basePatchNum, 'PARENT')) {
- this._displayDiffBaseAgainstLeftToast();
- return;
- }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this._displayDiffAgainstLatestToast(latestPatchNum);
- return;
- }
- }
-
- _initCommitRange() {
- let commit;
- let baseCommit;
- if (!this._patchRange || !this._patchRange.patchNum) return;
- for (const commitSha in this._change.revisions) {
- if (!this._change.revisions.hasOwnProperty(commitSha)) continue;
- const revision = this._change.revisions[commitSha];
- const patchNum = revision._number.toString();
- if (patchNum === this._patchRange.patchNum) {
- commit = commitSha;
- const commitObj = revision.commit || {};
- const parents = commitObj.parents || [];
- if (this._patchRange.basePatchNum === PARENT && parents.length) {
- baseCommit = parents[parents.length - 1].commit;
- }
- } else if (patchNum === this._patchRange.basePatchNum) {
- baseCommit = commitSha;
- }
- }
- this._commitRange = {commit, baseCommit};
- }
-
- _initPatchRange() {
- let leftSide;
- if (this.params.commentId) {
- const comment = this._changeComments.findCommentById(
- this.params.commentId);
- if (!comment) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'comment not found',
- },
- composed: true, bubbles: true,
- }));
- GerritNav.navigateToChange(this._change);
- return;
- }
- this._path = comment.path;
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(latestPatchNum, comment.patch_set)) {
- this._patchRange = {
- patchNum: latestPatchNum,
- basePatchNum: PARENT,
- };
- leftSide = comment.__commentSide === 'left';
- } else {
- this._patchRange = {
- patchNum: latestPatchNum,
- basePatchNum: comment.patch_set,
- };
- // comment is now on the left side since we are showing
- // comment.patch_set vs latest
- leftSide = true;
- }
- this._focusLineNum = comment.line;
- } else {
- if (this.params.path) {
- this._path = this.params.path;
- }
- if (this.params.patchNum) {
- this._patchRange = {
- patchNum: this.params.patchNum,
- basePatchNum: this.params.basePatchNum || PARENT,
- };
- }
- if (this.params.lineNum) {
- this._focusLineNum = this.params.lineNum;
- leftSide = this.params.leftSide;
- }
- }
- this._initLineOfInterestAndCursor(leftSide);
- this._commentMap = this._getPaths(this._patchRange);
-
- this._commentsForDiff = this._getCommentsForPath(this._path,
- this._patchRange, this._projectConfig);
- }
-
- _isFileUnchanged(diff) {
- if (!diff || !diff.content) return false;
- return !diff.content.some(content =>
- (content.a && !content.common) ||
- (content.b && !content.common)
- );
- }
-
- _paramsChanged(value) {
- if (value.view !== GerritNav.View.DIFF) { return; }
-
- this._change = undefined;
- this._files = undefined;
- this._path = undefined;
- this._patchRange = undefined;
- this._commitRange = undefined;
- this._changeComments = undefined;
- this._focusLineNum = undefined;
-
- if (value.changeNum && value.project) {
- this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
- }
-
- this._changeNum = value.changeNum;
- this.classList.remove('hideComments');
-
- // When navigating away from the page, there is a possibility that the
- // patch number is no longer a part of the URL (say when navigating to
- // the top-level change info view) and therefore undefined in `params`.
- // If route is of type /comment/<commentId>/ then no patchNum is present
- if (!value.patchNum && !value.commentLink) {
- console.warn('invalid url, no patchNum found');
- return;
- }
-
- const promises = [];
-
- promises.push(this._getDiffPreferences());
-
- promises.push(this._getPreferences().then(prefs => {
- this._userPrefs = prefs;
- }));
-
- promises.push(this._getChangeDetail(this._changeNum));
- promises.push(this._loadComments());
-
- promises.push(this._getChangeEdit(this._changeNum));
-
- this.$.diffHost.cancel();
- this.$.diffHost.clearDiffContent();
- this._loading = true;
- return Promise.all(promises)
- .then(r => {
- this._loading = false;
- this._initPatchRange();
- this._initCommitRange();
- this.$.diffHost.comments = this._commentsForDiff;
- const edit = r[4];
- if (edit) {
- this.set('_change.revisions.' + edit.commit.commit, {
- _number: SPECIAL_PATCH_SET_NUM.EDIT,
- basePatchNum: edit.base_patch_set_number,
- commit: edit.commit,
- });
- }
- return this.$.diffHost.reload(true);
- })
- .then(() => {
- this.reporting.diffViewFullyLoaded();
- // If diff view displayed has not ended yet, it ends here.
- this.reporting.diffViewDisplayed();
- })
- .then(() => {
- const fileUnchanged = this._isFileUnchanged(this._diff);
- if (fileUnchanged && value.commentLink) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: `File is unchanged between Patchset
- ${this._patchRange.basePatchNum} and
- ${this._patchRange.patchNum}. Showing diff of Base vs
- ${this._patchRange.basePatchNum}`,
- },
- composed: true, bubbles: true,
- }));
- GerritNav.navigateToDiff(
- this._change, this._path, this._patchRange.basePatchNum,
- 'PARENT', this._focusLineNum);
- return;
- }
- if (value.commentLink) {
- this._displayToasts();
- }
- // If the blame was loaded for a previous file and user navigates to
- // another file, then we load the blame for this file too
- if (this._isBlameLoaded) this._loadBlame();
- });
- }
-
- _changeViewStateChanged(changeViewState) {
- if (changeViewState.diffMode === null) {
- // If screen size is small, always default to unified view.
- this.$.restAPI.getPreferences().then(prefs => {
- this.set('changeViewState.diffMode', prefs.default_diff_view);
- });
- }
- }
-
- _setReviewedObserver(_loggedIn, paramsRecord, _prefs, patchRangeRecord) {
- // Polymer 2: check for undefined
- if ([_loggedIn, paramsRecord, _prefs, patchRangeRecord,
- patchRangeRecord.base].includes(
- undefined)) {
- return;
- }
- const patchRange = patchRangeRecord.base;
- const params = paramsRecord.base || {};
- if (!_loggedIn) { return; }
-
- if (_prefs.manual_review) {
- // Checkbox state needs to be set explicitly only when manual_review
- // is specified.
-
- if (patchRange.patchNum) {
- this._getReviewedStatus(this.editMode, this._changeNum,
- patchRange.patchNum, this._path).then(status => {
- this.$.reviewed.checked = status;
- });
- }
- return;
- }
-
- if (params.view === GerritNav.View.DIFF) {
- this._setReviewed(true);
- }
- }
-
- /**
- * If the params specify a diff address then configure the diff cursor.
- */
- _initCursor(params) {
- if (this._focusLineNum === undefined) { return; }
- if (params.leftSide) {
- this.$.cursor.side = DiffSides.LEFT;
- } else {
- this.$.cursor.side = DiffSides.RIGHT;
- }
- this.$.cursor.initialLineNumber = this._focusLineNum;
- }
-
- _getLineOfInterest(params) {
- // If there is a line number specified, pass it along to the diff so that
- // it will not get collapsed.
- if (!this._focusLineNum) { return null; }
- return {number: this._focusLineNum, leftSide: params.leftSide};
- }
-
- _pathChanged(path) {
- if (path) {
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: computeTruncatedPath(path)},
- composed: true, bubbles: true,
- }));
- }
-
- if (!this._fileList || this._fileList.length == 0) { return; }
-
- this.set('changeViewState.selectedFileIndex',
- this._fileList.indexOf(path));
- }
-
- _getDiffUrl(change, patchRange, path) {
- if ([change, patchRange, path].includes(undefined)) {
- return '';
- }
- return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
- patchRange.basePatchNum);
- }
-
- _patchRangeStr(patchRange) {
- let patchStr = patchRange.patchNum;
- if (patchRange.basePatchNum != null &&
- patchRange.basePatchNum != PARENT) {
- patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
- }
- return patchStr;
- }
-
- /**
- * When the latest patch of the change is selected (and there is no base
- * patch) then the patch range need not appear in the URL. Return a patch
- * range object with undefined values when a range is not needed.
- *
- * @param {!Object} patchRange
- * @param {!Object} revisions
- * @return {!Object}
- */
- _getChangeUrlRange(patchRange, revisions) {
- let patchNum = undefined;
- let basePatchNum = undefined;
- let latestPatchNum = -1;
- for (const rev of Object.values(revisions || {})) {
- latestPatchNum = Math.max(latestPatchNum, rev._number);
- }
- if (patchRange.basePatchNum !== PARENT ||
- parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
- patchNum = patchRange.patchNum;
- basePatchNum = patchRange.basePatchNum;
- }
- return {patchNum, basePatchNum};
- }
-
- _getChangePath(change, patchRange, revisions) {
- if ([change, patchRange].includes(undefined)) {
- return '';
- }
- const range = this._getChangeUrlRange(patchRange, revisions);
- return GerritNav.getUrlForChange(change, range.patchNum,
- range.basePatchNum);
- }
-
- _navigateToChange(change, patchRange, revisions) {
- const range = this._getChangeUrlRange(patchRange, revisions);
- GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
- }
-
- _computeChangePath(change, patchRangeRecord, revisions) {
- return this._getChangePath(change, patchRangeRecord.base, revisions);
- }
-
- _formatFilesForDropdown(files, patchNum, changeComments) {
- // Polymer 2: check for undefined
- if ([
- files,
- patchNum,
- changeComments,
- ].includes(undefined)) {
- return;
- }
-
- if (!files) { return; }
- const dropdownContent = [];
- for (const path of files.sortedFileList) {
- dropdownContent.push({
- text: computeDisplayPath(path),
- mobileText: computeTruncatedPath(path),
- value: path,
- bottomText: this._computeCommentString(changeComments, patchNum,
- path, files.changeFilesByPath[path]),
- });
- }
- return dropdownContent;
- }
-
- _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
- const unresolvedCount = changeComments.computeUnresolvedNum({patchNum,
- path});
- const commentCount = changeComments.computeCommentCount({patchNum, path});
- const commentString = GrCountStringFormatter.computePluralString(
- commentCount, 'comment');
- const unresolvedString = GrCountStringFormatter.computeString(
- unresolvedCount, 'unresolved');
-
- const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
-
- return [
- unmodifiedString,
- commentString,
- unresolvedString]
- .filter(v => v && v.length > 0).join(', ');
- }
-
- _computePrefsButtonHidden(prefs, prefsDisabled) {
- return prefsDisabled || !prefs;
- }
-
- _handleFileChange(e) {
- // This is when it gets set initially.
- const path = e.detail.value;
- if (path === this._path) {
- return;
- }
-
- GerritNav.navigateToDiff(this._change, path, this._patchRange.patchNum,
- this._patchRange.basePatchNum);
- }
-
- _handleFileTap(e) {
- // async is needed so that that the click event is fired before the
- // dropdown closes (This was a bug for touch devices).
- this.async(() => {
- this.$.dropdown.close();
- }, 1);
- }
-
- _handlePatchChange(e) {
- const {basePatchNum, patchNum} = e.detail;
- if (patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
- patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
- GerritNav.navigateToDiff(
- this._change, this._path, patchNum, basePatchNum);
- }
-
- _handlePrefsTap(e) {
- e.preventDefault();
- this.$.diffPreferencesDialog.open();
- }
-
- /**
- * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
- * the current state.
- *
- * The expected behavior is to use the mode specified in the user's
- * preferences unless they have manually chosen the alternative view or they
- * are on a mobile device. If the user navigates up to the change view, it
- * should clear this choice and revert to the preference the next time a
- * diff is viewed.
- *
- * Use side-by-side if the user is not logged in.
- *
- * @return {string}
- */
- _getDiffViewMode() {
- if (this.changeViewState.diffMode) {
- return this.changeViewState.diffMode;
- } else if (this._userPrefs) {
- this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
- return this._userPrefs.default_diff_view;
- } else {
- return 'SIDE_BY_SIDE';
- }
- }
-
- _computeModeSelectHideClass(_diff) {
- return (!_diff || _diff.binary) ? 'hide' : '';
- }
-
- _onLineSelected(e, detail) {
- if (!this._change) { return; }
- const number = detail.number;
- // for on-comment-anchor-tap side can be PARENT/REVISIONS
- // for on-line-selected side can be left/right
- const leftSide = detail.side === Side.LEFT || detail.side === 'PARENT';
- const url = GerritNav.getUrlForDiffById(this._changeNum,
- this._change.project, this._path, this._patchRange.patchNum,
- this._patchRange.basePatchNum, number, leftSide);
- history.replaceState(null, '', url);
- }
-
- _computeDownloadDropdownLinks(
- project, changeNum, patchRange, path, diff) {
- if (!patchRange || !patchRange.patchNum) { return []; }
-
- const links = [
- {
- url: this._computeDownloadPatchLink(
- project, changeNum, patchRange, path),
- name: 'Patch',
- },
- ];
-
- if (diff && diff.meta_a) {
- let leftPath = path;
- if (diff.change_type === 'RENAMED') {
- leftPath = diff.meta_a.name;
- }
- links.push(
- {
- url: this._computeDownloadFileLink(
- project, changeNum, patchRange, leftPath, true),
- name: 'Left Content',
- }
- );
- }
-
- if (diff && diff.meta_b) {
- links.push(
- {
- url: this._computeDownloadFileLink(
- project, changeNum, patchRange, path, false),
- name: 'Right Content',
- }
- );
- }
-
- return links;
- }
-
- _computeDownloadFileLink(
- project, changeNum, patchRange, path, isBase) {
- let patchNum = patchRange.patchNum;
-
- const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
-
- if (isBase && !comparedAgainsParent) {
- patchNum = patchRange.basePatchNum;
- }
-
- let url = changeBaseURL(project, changeNum, patchNum) +
- `/files/${encodeURIComponent(path)}/download`;
-
- if (isBase && comparedAgainsParent) {
- url += '?parent=1';
- }
-
- return url;
- }
-
- _computeDownloadPatchLink(project, changeNum, patchRange, path) {
- let url = changeBaseURL(project, changeNum, patchRange.patchNum);
- url += '/patch?zip&path=' + encodeURIComponent(path);
- return url;
- }
-
- _loadComments() {
- return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
- this._changeComments = comments;
- });
- }
-
- _recomputeComments(files, path, patchRange, projectConfig) {
- // Polymer 2: check for undefined
- if ([
- files,
- path,
- patchRange,
- projectConfig,
- ].includes(undefined)) {
- return undefined;
- }
-
- const file = files[path];
- if (file && file.old_path) {
- this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
- {path, basePath: file.old_path},
- patchRange,
- projectConfig);
-
- this.$.diffHost.comments = this._commentsForDiff;
- }
- }
-
- _getPaths(patchRange) {
- return this._changeComments.getPaths(patchRange);
- }
-
- _getCommentsForPath(path, patchRange, projectConfig) {
- return this._changeComments.getCommentsBySideForPath(path, patchRange,
- projectConfig);
- }
-
- _getDiffDrafts() {
- return this.$.restAPI.getDiffDrafts(this._changeNum);
- }
-
- _computeCommentSkips(commentMap, fileList, path) {
- // Polymer 2: check for undefined
- if ([
- commentMap,
- fileList,
- path,
- ].includes(undefined)) {
- return undefined;
- }
-
- const skips = {previous: null, next: null};
- if (!fileList.length) { return skips; }
- const pathIndex = fileList.indexOf(path);
-
- // Scan backward for the previous file.
- for (let i = pathIndex - 1; i >= 0; i--) {
- if (commentMap[fileList[i]]) {
- skips.previous = fileList[i];
- break;
- }
- }
-
- // Scan forward for the next file.
- for (let i = pathIndex + 1; i < fileList.length; i++) {
- if (commentMap[fileList[i]]) {
- skips.next = fileList[i];
- break;
- }
- }
-
- return skips;
- }
-
- _computeContainerClass(editMode) {
- return editMode ? 'editMode' : '';
- }
-
- /**
- * @param {!Object} patchRangeRecord
- */
- _computeEditMode(patchRangeRecord) {
- const patchRange = patchRangeRecord.base || {};
- return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
- }
-
- _computeBlameToggleLabel(loaded, loading) {
- if (loaded) { return 'Hide blame'; }
- return 'Show blame';
- }
-
- _loadBlame() {
- this._isBlameLoading = true;
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: MSG_LOADING_BLAME},
- composed: true, bubbles: true,
- }));
- this.$.diffHost.loadBlame()
- .then(() => {
- this._isBlameLoading = false;
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: MSG_LOADED_BLAME},
- composed: true, bubbles: true,
- }));
- })
- .catch(() => {
- this._isBlameLoading = false;
- });
- }
-
- /**
- * Load and display blame information if it has not already been loaded.
- * Otherwise hide it.
- */
- _toggleBlame() {
- if (this._isBlameLoaded) {
- this.$.diffHost.clearBlame();
- return;
- }
- this._loadBlame();
- }
-
- _handleToggleBlame(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- this._toggleBlame();
- }
-
- _handleToggleHideAllCommentThreads(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- this.toggleClass('hideComments');
- }
-
- _handleDiffAgainstBase(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Base is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToDiff(
- this._change, this._path, this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLeft(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Left is already base.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToDiff(this._change, this._path,
- this._patchRange.basePatchNum);
- }
-
- _handleDiffAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Latest is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
-
- GerritNav.navigateToDiff(
- this._change, this._path, latestPatchNum,
- this._patchRange.basePatchNum);
- }
-
- _handleDiffRightAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Right is already latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToDiff(this._change, this._path, latestPatchNum,
- this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
- patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Already diffing base against latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
- }
-
- _computeBlameLoaderClass(isImageDiff, path) {
- return !isMagicPath(path) && !isImageDiff ? 'show' : '';
- }
-
- _getRevisionInfo(change) {
- return new RevisionInfo(change);
- }
-
- _computeFileNum(file, files) {
- // Polymer 2: check for undefined
- if ([file, files].includes(undefined)) {
- return undefined;
- }
-
- return files.findIndex(({value}) => value === file) + 1;
- }
-
- /**
- * @param {number} fileNum
- * @param {!Array<string>} files
- * @return {string}
- */
- _computeFileNumClass(fileNum, files) {
- if (files && fileNum > 0) {
- return 'show';
- }
- return '';
- }
-
- _handleExpandAllDiffContext(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- this.$.diffHost.expandAllContext();
- }
-
- _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
- return disableDiffPrefs || !loggedIn;
- }
-
- _handleNextUnreviewedFile(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- this._setReviewed(true);
- // Ensure that the currently viewed file always appears in unreviewedFiles
- // so we resolve the right "next" file.
- const unreviewedFiles = this._fileList
- .filter(file =>
- (file === this._path || !this._reviewedFiles.has(file)));
- this._navToFile(this._path, unreviewedFiles, 1);
- }
-
- _handleReloadingDiffPreference() {
- this._getDiffPreferences();
- }
-
- _computeCanEdit(loggedIn, changeChangeRecord) {
- if ([changeChangeRecord, changeChangeRecord.base]
- .some(arg => arg === undefined)) {
- return false;
- }
- return loggedIn && changeIsOpen(changeChangeRecord.base);
- }
-
- _computeIsLoggedIn(loggedIn) {
- return loggedIn ? true : false;
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeAllPatchSets(change) {
- return computeAllPatchSets(change);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeDisplayPath(path) {
- return computeDisplayPath(path);
- }
-}
-
-customElements.define(GrDiffView.is, GrDiffView);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
new file mode 100644
index 0000000..155da60
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -0,0 +1,1942 @@
+/**
+ * @license
+ * Copyright (C) 2015 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.
+ */
+import '@polymer/iron-dropdown/iron-dropdown';
+import '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/revision-info/revision-info';
+import '../gr-comment-api/gr-comment-api';
+import '../gr-diff-cursor/gr-diff-cursor';
+import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-diff-host/gr-diff-host';
+import '../gr-diff-mode-selector/gr-diff-mode-selector';
+import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../gr-patch-range-select/gr-patch-range-select';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-view_html';
+import {
+ CustomKeyboardEvent,
+ KeyboardShortcutMixin,
+ Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+ computeAllPatchSets,
+ computeLatestPatchNum,
+ patchNumEquals,
+ PatchSet,
+} from '../../../utils/patch-set-util';
+import {
+ addUnmodifiedFiles,
+ computeDisplayPath,
+ computeTruncatedPath,
+ isMagicPath,
+ specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
+import {
+ DropdownItem,
+ GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+ ChangeComments,
+ GrCommentApi,
+ TwoSidesComments,
+} from '../gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
+import {
+ ChangeInfo,
+ CommitId,
+ ConfigInfo,
+ DiffInfo,
+ DiffPreferencesInfo,
+ EditInfo,
+ EditPatchSetNum,
+ ElementPropertyDeepChange,
+ FileInfo,
+ NumericChangeId,
+ ParentPatchSetNum,
+ PatchRange,
+ PatchSetNum,
+ PreferencesInfo,
+ RepoName,
+ RevisionInfo,
+} from '../../../types/common';
+import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
+import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
+import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {LineOfInterest} from '../gr-diff/gr-diff';
+import {CommentEventDetail} from '../../shared/gr-comment/gr-comment';
+import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
+import {CommentMap} from '../../../utils/comment-util';
+import {AppElementParams} from '../../gr-app-types';
+import {FetchParams} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+const MSG_LOADING_BLAME = 'Loading blame...';
+const MSG_LOADED_BLAME = 'Blame loaded';
+
+interface Files {
+ sortedFileList: string[];
+ changeFilesByPath: {[path: string]: FileInfo};
+}
+
+interface CommentSkips {
+ previous: string | null;
+ next: string | null;
+}
+
+export interface GrDiffView {
+ $: {
+ restAPI: RestApiService & Element;
+ commentAPI: GrCommentApi;
+ cursor: GrDiffCursor;
+ diffHost: GrDiffHost;
+ reviewed: HTMLInputElement;
+ dropdown: GrDropdownList;
+ diffPreferencesDialog: GrOverlay;
+ applyFixDialog: GrApplyFixDialog;
+ modeSelect: GrDiffModeSelector;
+ };
+}
+
+@customElement('gr-diff-view')
+export class GrDiffView extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ /**
+ * Fired when user tries to navigate away while comments are pending save.
+ *
+ * @event show-alert
+ */
+
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementParams;
+
+ @property({type: Object})
+ keyEventTarget: HTMLElement = document.body;
+
+ @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
+ changeViewState: Partial<ChangeViewState> = {};
+
+ @property({type: Boolean})
+ disableDiffPrefs = false;
+
+ @property({
+ type: Boolean,
+ computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+ })
+ _diffPrefsDisabled?: boolean;
+
+ @property({type: Object})
+ _patchRange?: PatchRange;
+
+ @property({type: Object})
+ _commitRange?: CommitRange;
+
+ @property({type: Object})
+ _change?: ChangeInfo;
+
+ @property({type: Object})
+ _changeComments?: ChangeComments;
+
+ @property({type: String})
+ _changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ _diff?: DiffInfo;
+
+ @property({
+ type: Array,
+ computed:
+ '_formatFilesForDropdown(_files, ' +
+ '_patchRange.patchNum, _changeComments)',
+ })
+ _formattedFiles?: DropdownItem[];
+
+ @property({type: Array, computed: '_getSortedFileList(_files)'})
+ _fileList?: string[];
+
+ @property({type: Object})
+ _files: Files = {sortedFileList: [], changeFilesByPath: {}};
+
+ @property({type: Object, computed: '_getCurrentFile(_files, _path)'})
+ _file?: FileInfo;
+
+ @property({type: String, observer: '_pathChanged'})
+ _path?: string;
+
+ @property({type: Number, computed: '_computeFileNum(_path, _formattedFiles)'})
+ _fileNum?: number;
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Object})
+ _prefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ _projectConfig?: ConfigInfo;
+
+ @property({type: Object})
+ _userPrefs?: PreferencesInfo;
+
+ @property({
+ type: String,
+ computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
+ })
+ _diffMode?: string;
+
+ @property({type: Boolean})
+ _isImageDiff?: boolean;
+
+ @property({type: Object})
+ _filesWeblinks?: FilesWebLinks;
+
+ @property({type: Object})
+ _commentMap?: CommentMap;
+
+ @property({type: Object})
+ _commentsForDiff?: TwoSidesComments;
+
+ @property({
+ type: Object,
+ computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
+ })
+ _commentSkips?: CommentSkips;
+
+ @property({type: Boolean, computed: '_computeEditMode(_patchRange.*)'})
+ _editMode?: boolean;
+
+ @property({type: Boolean})
+ _isBlameLoaded?: boolean;
+
+ @property({type: Boolean})
+ _isBlameLoading = false;
+
+ @property({
+ type: Array,
+ computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+ })
+ _allPatchSets?: PatchSet[] = [];
+
+ @property({type: Object, computed: '_getRevisionInfo(_change)'})
+ _revisionInfo?: RevisionInfoObj;
+
+ @property({type: Object})
+ _reviewedFiles = new Set<string>();
+
+ @property({type: Number})
+ _focusLineNum?: number;
+
+ get keyBindings() {
+ return {
+ esc: '_handleEscKey',
+ };
+ }
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.LEFT_PANE]: '_handleLeftPane',
+ [Shortcut.RIGHT_PANE]: '_handleRightPane',
+ [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+ [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+ [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
+ [Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
+ [Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
+ [Shortcut.NEW_COMMENT]: '_handleNewComment',
+ [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+ [Shortcut.NEXT_FILE]: '_handleNextFile',
+ [Shortcut.PREV_FILE]: '_handlePrevFile',
+ [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+ [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+ [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+ [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+ [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialogOrToggleLeftPane',
+ [Shortcut.TOGGLE_LEFT_PANE]: '_handleOpenReplyDialogOrToggleLeftPane',
+ [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
+ [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+ [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+ [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+ [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
+ [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+ [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+ [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+ [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+ '_handleToggleHideAllCommentThreads',
+ [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+ [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+ [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+
+ // Final two are actually handled by gr-comment-thread.
+ [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+ [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+ };
+ }
+
+ reporting = appContext.reportingService;
+
+ flagsService = appContext.flagsService;
+
+ _throttledToggleFileReviewed?: EventListener;
+
+ _onRenderHandler?: EventListener;
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ this._throttledToggleFileReviewed = this._throttleWrap(e =>
+ this._handleToggleFileReviewed(e as CustomKeyboardEvent)
+ );
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ });
+
+ this.addEventListener('open-fix-preview', e =>
+ this._onOpenFixPreview(e as CustomEvent<CommentEventDetail>)
+ );
+ this.$.cursor.push('diffs', this.$.diffHost);
+ this._onRenderHandler = (_: Event) => {
+ this.$.cursor.reInitCursor();
+ };
+ this.$.diffHost.addEventListener('render', this._onRenderHandler);
+ }
+
+ /** @override */
+ detached() {
+ if (this._onRenderHandler) {
+ this.$.diffHost.removeEventListener('render', this._onRenderHandler);
+ }
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ @observe('_change.project')
+ _getProjectConfig(project?: RepoName) {
+ if (!project) return;
+ return this.$.restAPI.getProjectConfig(project).then(config => {
+ this._projectConfig = config;
+ });
+ }
+
+ _getChangeDetail(changeNum: NumericChangeId) {
+ return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+ if (!change) throw new Error('Missing "change" in API response.');
+ this._change = change;
+ return change;
+ });
+ }
+
+ _getChangeEdit() {
+ if (!this._changeNum) throw new Error('Missing this._changeNum');
+ return this.$.restAPI.getChangeEdit(this._changeNum);
+ }
+
+ _getSortedFileList(files?: Files) {
+ if (!files) return [];
+ return files.sortedFileList;
+ }
+
+ _getCurrentFile(files?: Files, path?: string) {
+ if (!files || !path) return;
+ const fileInfo = files.changeFilesByPath[path];
+ const fileRange: FileRange = {path};
+ if (fileInfo && fileInfo.old_path) {
+ fileRange.basePath = fileInfo.old_path;
+ }
+ return fileRange;
+ }
+
+ @observe('_changeNum', '_patchRange.*', '_changeComments')
+ _getFiles(
+ changeNum: NumericChangeId,
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+ changeComments: ChangeComments
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ [changeNum, patchRangeRecord, patchRangeRecord.base, changeComments].some(
+ arg => arg === undefined
+ )
+ ) {
+ return Promise.resolve();
+ }
+
+ if (!patchRangeRecord.base.patchNum) {
+ return Promise.resolve();
+ }
+
+ const patchRange = patchRangeRecord.base;
+ return this.$.restAPI
+ .getChangeFiles(changeNum, patchRange)
+ .then(changeFiles => {
+ if (!changeFiles) return;
+ const commentedPaths = changeComments.getPaths(patchRange);
+ const files = {...changeFiles};
+ addUnmodifiedFiles(files, commentedPaths);
+ this._files = {
+ sortedFileList: Object.keys(files).sort(specialFilePathCompare),
+ changeFilesByPath: files,
+ };
+ });
+ }
+
+ _getDiffPreferences() {
+ return this.$.restAPI.getDiffPreferences().then(prefs => {
+ this._prefs = prefs;
+ });
+ }
+
+ _getPreferences() {
+ return this.$.restAPI.getPreferences();
+ }
+
+ _getWindowWidth() {
+ return window.innerWidth;
+ }
+
+ _handleReviewedChange(e: Event) {
+ this._setReviewed(
+ ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
+ );
+ }
+
+ _setReviewed(reviewed: boolean) {
+ if (this._editMode) return;
+ this.$.reviewed.checked = reviewed;
+ if (!this._patchRange?.patchNum) return;
+ this._saveReviewedState(reviewed).catch(err => {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_REVIEW_STATUS},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ throw err;
+ });
+ }
+
+ _saveReviewedState(reviewed: boolean): Promise<Response | undefined> {
+ if (!this._changeNum) return Promise.resolve(undefined);
+ if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
+ if (!this._path) return Promise.resolve(undefined);
+ return this.$.restAPI.saveFileReviewed(
+ this._changeNum,
+ this._patchRange?.patchNum,
+ this._path,
+ reviewed
+ );
+ }
+
+ _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ this._setReviewed(!this.$.reviewed.checked);
+ }
+
+ _handleEscKey(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ this.$.diffHost.displayLine = false;
+ }
+
+ _handleLeftPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ this.$.cursor.moveLeft();
+ }
+
+ _handleRightPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ this.$.cursor.moveRight();
+ }
+
+ _handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ if (
+ e.detail.keyboardEvent?.shiftKey &&
+ e.detail.keyboardEvent?.keyCode === 75
+ ) {
+ // 'K'
+ this._moveToPreviousFileWithComment();
+ return;
+ }
+ if (this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.diffHost.displayLine = true;
+ this.$.cursor.moveUp();
+ }
+
+ _handleVisibleLine(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ this.$.cursor.moveToVisibleArea();
+ }
+
+ _onOpenFixPreview(e: CustomEvent<CommentEventDetail>) {
+ this.$.applyFixDialog.open(e);
+ }
+
+ _handleNextLineOrFileWithComments(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ if (
+ e.detail.keyboardEvent?.shiftKey &&
+ e.detail.keyboardEvent?.keyCode === 74
+ ) {
+ // 'J'
+ this._moveToNextFileWithComment();
+ return;
+ }
+ if (this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.diffHost.displayLine = true;
+ this.$.cursor.moveDown();
+ }
+
+ _moveToPreviousFileWithComment() {
+ if (!this._commentSkips) return;
+ if (!this._change) return;
+ if (!this._patchRange?.patchNum) return;
+
+ // If there is no previous diff with comments, then return to the change
+ // view.
+ if (!this._commentSkips.previous) {
+ this._navToChangeView();
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ this._commentSkips.previous,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _moveToNextFileWithComment() {
+ if (!this._commentSkips) return;
+ if (!this._change) return;
+ if (!this._patchRange?.patchNum) return;
+
+ // If there is no next diff with comments, then return to the change view.
+ if (!this._commentSkips.next) {
+ this._navToChangeView();
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ this._commentSkips.next,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handleNewComment(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ this.$.cursor.createCommentInPlace();
+ }
+
+ _handlePrevFile(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ // Check for meta key to avoid overriding native chrome shortcut.
+ if (this.getKeyboardEvent(e).metaKey) return;
+ if (!this._path) return;
+ if (!this._fileList) return;
+
+ e.preventDefault();
+ this._navToFile(this._path, this._fileList, -1);
+ }
+
+ _handleNextFile(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ // Check for meta key to avoid overriding native chrome shortcut.
+ if (this.getKeyboardEvent(e).metaKey) return;
+ if (!this._path) return;
+ if (!this._fileList) return;
+
+ e.preventDefault();
+ this._navToFile(this._path, this._fileList, 1);
+ }
+
+ _handleNextChunkOrCommentThread(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ if (e.detail.keyboardEvent?.shiftKey) {
+ this.$.cursor.moveToNextCommentThread();
+ } else {
+ if (this.modifierPressed(e)) return;
+ // navigate to next file if key is not being held down
+ this.$.cursor.moveToNextChunk(
+ /* opt_clipToTop = */ false,
+ /* opt_navigateToNextFile = */ !e.detail.keyboardEvent?.repeat
+ );
+ }
+ }
+
+ _handlePrevChunkOrCommentThread(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ if (e.detail.keyboardEvent?.shiftKey) {
+ this.$.cursor.moveToPreviousCommentThread();
+ } else {
+ if (this.modifierPressed(e)) return;
+ this.$.cursor.moveToPreviousChunk();
+ }
+ }
+
+ _handleOpenReplyDialogOrToggleLeftPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ if (e.detail.keyboardEvent?.shiftKey) {
+ // Hide left diff.
+ e.preventDefault();
+ this.$.diffHost.toggleLeftDiff();
+ return;
+ }
+
+ if (this.modifierPressed(e)) return;
+ if (!this._loggedIn) return;
+
+ this.set('changeViewState.showReplyDialog', true);
+ e.preventDefault();
+ this._navToChangeView();
+ }
+
+ _handleOpenDownloadDialog(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ this.set('changeViewState.showDownloadDialog', true);
+ e.preventDefault();
+ this._navToChangeView();
+ }
+
+ _handleUpToChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ this._navToChangeView();
+ }
+
+ _handleCommaKey(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+ if (this._diffPrefsDisabled) return;
+
+ e.preventDefault();
+ this.$.diffPreferencesDialog.open();
+ }
+
+ _handleToggleDiffMode(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+ } else {
+ this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
+ }
+ }
+
+ _navToChangeView() {
+ if (!this._changeNum || !this._patchRange?.patchNum) {
+ return;
+ }
+ this._navigateToChange(
+ this._change,
+ this._patchRange,
+ this._change && this._change.revisions
+ );
+ }
+
+ _navToFile(path: string, fileList: string[], direction: -1 | 1) {
+ const newPath = this._getNavLinkPath(path, fileList, direction);
+ if (!newPath?.path) return;
+ if (!this._change) return;
+ if (!this._patchRange) return;
+
+ if (newPath.up) {
+ this._navigateToChange(
+ this._change,
+ this._patchRange,
+ this._change && this._change.revisions
+ );
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ newPath.path,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ /**
+ * @param path The path of the current file being shown.
+ * @param fileList The list of files in this change and
+ * patch range.
+ * @param direction Either 1 (next file) or -1 (prev file).
+ * @param opt_noUp Whether to return to the change view
+ * when advancing the file goes outside the bounds of fileList.
+ * @return The next URL when proceeding in the specified
+ * direction.
+ */
+ _computeNavLinkURL(
+ change?: ChangeInfo,
+ path?: string,
+ fileList?: string[],
+ direction?: -1 | 1,
+ opt_noUp?: boolean
+ ) {
+ if (!change) return null;
+ if (!path) return null;
+ if (!fileList) return null;
+ if (!direction) return null;
+
+ const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+ if (!newPath) {
+ return null;
+ }
+
+ if (newPath.up) {
+ return this._getChangePath(
+ this._change,
+ this._patchRange,
+ this._change && this._change.revisions
+ );
+ }
+ return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+ }
+
+ _goToEditFile() {
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ // TODO(taoalpha): add a shortcut for editing
+ const cursorAddress = this.$.cursor.getAddress();
+ const editUrl = GerritNav.getEditUrlForDiff(
+ this._change,
+ this._path,
+ this._patchRange.patchNum,
+ cursorAddress?.number
+ );
+ GerritNav.navigateToRelativeUrl(editUrl);
+ }
+
+ /**
+ * Gives an object representing the target of navigating either left or
+ * right through the change. The resulting object will have one of the
+ * following forms:
+ * * {path: "<target file path>"} - When another file path should be the
+ * result of the navigation.
+ * * {up: true} - When the result of navigating should go back to the
+ * change view.
+ * * null - When no navigation is possible for the given direction.
+ *
+ * @param path The path of the current file being shown.
+ * @param fileList The list of files in this change and
+ * patch range.
+ * @param direction Either 1 (next file) or -1 (prev file).
+ * @param opt_noUp Whether to return to the change view
+ * when advancing the file goes outside the bounds of fileList.
+ */
+ _getNavLinkPath(
+ path: string,
+ fileList: string[],
+ direction: -1 | 1,
+ opt_noUp?: boolean
+ ) {
+ if (!path || !fileList || fileList.length === 0) {
+ return null;
+ }
+
+ let idx = fileList.indexOf(path);
+ if (idx === -1) {
+ const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
+ return {path: file};
+ }
+
+ idx += direction;
+ // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+ // outside the bounds of [0, fileList.length).
+ if (idx < 0 || idx > fileList.length - 1) {
+ if (opt_noUp) {
+ return null;
+ }
+ return {up: true};
+ }
+
+ return {path: fileList[idx]};
+ }
+
+ _getReviewedFiles(
+ changeNum?: NumericChangeId,
+ patchNum?: PatchSetNum
+ ): Promise<Set<string>> {
+ if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
+ return this.$.restAPI.getReviewedFiles(changeNum, patchNum).then(files => {
+ this._reviewedFiles = new Set(files);
+ return this._reviewedFiles;
+ });
+ }
+
+ _getReviewedStatus(
+ editMode?: boolean,
+ changeNum?: NumericChangeId,
+ patchNum?: PatchSetNum,
+ path?: string
+ ) {
+ if (editMode || !path) {
+ return Promise.resolve(false);
+ }
+ return this._getReviewedFiles(changeNum, patchNum).then(files =>
+ files.has(path)
+ );
+ }
+
+ _initLineOfInterestAndCursor(leftSide: boolean) {
+ this.$.diffHost.lineOfInterest = this._getLineOfInterest({
+ leftSide,
+ });
+ this._initCursor({
+ leftSide,
+ });
+ }
+
+ _displayDiffBaseAgainstLeftToast() {
+ if (!this._patchRange) return;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ // \u2190 = ←
+ message:
+ `Patchset ${this._patchRange.basePatchNum} vs ` +
+ `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+ `Base vs ${this._patchRange.basePatchNum}`,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
+ if (!this._patchRange) return;
+ const leftPatchset = patchNumEquals(
+ this._patchRange.basePatchNum,
+ ParentPatchSetNum
+ )
+ ? 'Base'
+ : `Patchset ${this._patchRange.basePatchNum}`;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ // \u2191 = ↑
+ message: `${leftPatchset} vs
+ ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
+ ${leftPatchset} vs Patchset ${latestPatchNum}`,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _displayToasts() {
+ if (!this._patchRange) return;
+ if (!patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this._displayDiffBaseAgainstLeftToast();
+ return;
+ }
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this._displayDiffAgainstLatestToast(latestPatchNum);
+ return;
+ }
+ }
+
+ _initCommitRange() {
+ let commit: CommitId | undefined;
+ let baseCommit: CommitId | undefined;
+ if (!this._change) return;
+ if (!this._patchRange || !this._patchRange.patchNum) return;
+ for (const commitSha in this._change.revisions) {
+ if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
+ const revision = this._change.revisions[commitSha];
+ const patchNum = revision._number.toString();
+ if (patchNum === this._patchRange.patchNum) {
+ commit = commitSha as CommitId;
+ const commitObj = revision.commit;
+ const parents = commitObj?.parents || [];
+ if (
+ this._patchRange.basePatchNum === ParentPatchSetNum &&
+ parents.length
+ ) {
+ baseCommit = parents[parents.length - 1].commit;
+ }
+ } else if (patchNum === this._patchRange.basePatchNum) {
+ baseCommit = commitSha as CommitId;
+ }
+ }
+ this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
+ }
+
+ _initPatchRange() {
+ let leftSide = false;
+ if (!this._change) return;
+ if (this.params?.view !== GerritView.DIFF) return;
+ if (this.params?.commentId) {
+ const comment = this._changeComments?.findCommentById(
+ this.params.commentId
+ );
+ if (!comment) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'comment not found',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ GerritNav.navigateToChange(this._change);
+ return;
+ }
+ this._path = comment.path;
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (!comment.patch_set) throw new Error('Missing comment.patch_set');
+ if (!latestPatchNum) throw new Error('Missing _allPatchSets');
+ if (patchNumEquals(latestPatchNum, comment.patch_set)) {
+ this._patchRange = {
+ patchNum: latestPatchNum,
+ basePatchNum: ParentPatchSetNum,
+ };
+ leftSide = comment.__commentSide === 'left';
+ } else {
+ this._patchRange = {
+ patchNum: latestPatchNum,
+ basePatchNum: comment.patch_set,
+ };
+ // comment is now on the left side since we are showing
+ // comment.patch_set vs latest
+ leftSide = true;
+ }
+ this._focusLineNum = comment.line;
+ } else {
+ if (this.params.path) {
+ this._path = this.params.path;
+ }
+ if (this.params.patchNum) {
+ this._patchRange = {
+ patchNum: this.params.patchNum,
+ basePatchNum: this.params.basePatchNum || ParentPatchSetNum,
+ };
+ }
+ if (this.params.lineNum) {
+ this._focusLineNum = this.params.lineNum;
+ leftSide = !!this.params.leftSide;
+ }
+ }
+ if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
+ this._initLineOfInterestAndCursor(leftSide);
+ this._commentMap = this._getPaths(this._patchRange);
+
+ this._commentsForDiff = this._getCommentsForPath(
+ this._path,
+ this._patchRange,
+ this._projectConfig
+ );
+ }
+
+ _isFileUnchanged(diff: DiffInfo) {
+ if (!diff || !diff.content) return false;
+ return !diff.content.some(
+ content =>
+ (content.a && !content.common) || (content.b && !content.common)
+ );
+ }
+
+ _paramsChanged(value: AppElementParams) {
+ if (value.view !== GerritView.DIFF) {
+ return;
+ }
+
+ this._change = undefined;
+ this._files = {sortedFileList: [], changeFilesByPath: {}};
+ this._path = undefined;
+ this._patchRange = undefined;
+ this._commitRange = undefined;
+ this._changeComments = undefined;
+ this._focusLineNum = undefined;
+
+ if (value.changeNum && value.project) {
+ this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+ }
+
+ this._changeNum = value.changeNum;
+ this.classList.remove('hideComments');
+
+ // When navigating away from the page, there is a possibility that the
+ // patch number is no longer a part of the URL (say when navigating to
+ // the top-level change info view) and therefore undefined in `params`.
+ // If route is of type /comment/<commentId>/ then no patchNum is present
+ if (!value.patchNum && !value.commentLink) {
+ console.warn('invalid url, no patchNum found');
+ return;
+ }
+
+ const promises: Promise<unknown>[] = [];
+
+ promises.push(this._getDiffPreferences());
+
+ promises.push(
+ this._getPreferences().then(prefs => {
+ this._userPrefs = prefs;
+ })
+ );
+
+ promises.push(this._getChangeDetail(this._changeNum));
+ promises.push(this._loadComments());
+
+ promises.push(this._getChangeEdit());
+
+ this.$.diffHost.cancel();
+ this.$.diffHost.clearDiffContent();
+ this._loading = true;
+ return Promise.all(promises)
+ .then(r => {
+ this._loading = false;
+ this._initPatchRange();
+ this._initCommitRange();
+ this.$.diffHost.comments = this._commentsForDiff;
+ const edit = r[4] as EditInfo | undefined;
+ if (edit) {
+ this.set(`_change.revisions.${edit.commit.commit}`, {
+ _number: EditPatchSetNum,
+ basePatchNum: edit.base_patch_set_number,
+ commit: edit.commit,
+ });
+ }
+ return this.$.diffHost.reload(true);
+ })
+ .then(() => {
+ this.reporting.diffViewFullyLoaded();
+ // If diff view displayed has not ended yet, it ends here.
+ this.reporting.diffViewDisplayed();
+ })
+ .then(() => {
+ if (!this._diff) throw new Error('Missing this._diff');
+ const fileUnchanged = this._isFileUnchanged(this._diff);
+ if (fileUnchanged && value.commentLink) {
+ if (!this._change) throw new Error('Missing this._change');
+ if (!this._path) throw new Error('Missing this._path');
+ if (!this._patchRange) throw new Error('Missing this._patchRange');
+
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: `File is unchanged between Patchset
+ ${this._patchRange.basePatchNum} and
+ ${this._patchRange.patchNum}. Showing diff of Base vs
+ ${this._patchRange.basePatchNum}`,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ this._patchRange.basePatchNum,
+ ParentPatchSetNum,
+ this._focusLineNum
+ );
+ return;
+ }
+ if (value.commentLink) {
+ this._displayToasts();
+ }
+ // If the blame was loaded for a previous file and user navigates to
+ // another file, then we load the blame for this file too
+ if (this._isBlameLoaded) this._loadBlame();
+ });
+ }
+
+ _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
+ if (changeViewState.diffMode === null) {
+ // If screen size is small, always default to unified view.
+ this.$.restAPI.getPreferences().then(prefs => {
+ if (prefs) {
+ this.set('changeViewState.diffMode', prefs.default_diff_view);
+ }
+ });
+ }
+ }
+
+ @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+ _setReviewedObserver(
+ _loggedIn?: boolean,
+ paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
+ _prefs?: DiffPreferencesInfo,
+ patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+ ) {
+ if (_loggedIn === undefined) return;
+ if (paramsRecord === undefined) return;
+ if (_prefs === undefined) return;
+ if (patchRangeRecord === undefined) return;
+ if (patchRangeRecord.base === undefined) return;
+
+ const patchRange = patchRangeRecord.base;
+ if (!_loggedIn) {
+ return;
+ }
+
+ if (_prefs.manual_review) {
+ // Checkbox state needs to be set explicitly only when manual_review
+ // is specified.
+
+ if (patchRange.patchNum) {
+ this._getReviewedStatus(
+ this._editMode,
+ this._changeNum,
+ patchRange.patchNum,
+ this._path
+ ).then((status: boolean) => {
+ this.$.reviewed.checked = status;
+ });
+ }
+ return;
+ }
+
+ if (paramsRecord.base?.view === GerritNav.View.DIFF) {
+ this._setReviewed(true);
+ }
+ }
+
+ /**
+ * If the params specify a diff address then configure the diff cursor.
+ */
+ _initCursor(params: FetchParams) {
+ if (this._focusLineNum === undefined) {
+ return;
+ }
+ if (params.leftSide) {
+ this.$.cursor.side = Side.LEFT;
+ } else {
+ this.$.cursor.side = Side.RIGHT;
+ }
+ this.$.cursor.initialLineNumber = this._focusLineNum;
+ }
+
+ _getLineOfInterest(params: FetchParams): LineOfInterest | undefined {
+ // If there is a line number specified, pass it along to the diff so that
+ // it will not get collapsed.
+ if (!this._focusLineNum) {
+ return undefined;
+ }
+
+ return {number: this._focusLineNum, leftSide: !!params.leftSide};
+ }
+
+ _pathChanged(path: string) {
+ if (path) {
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: computeTruncatedPath(path)},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ if (!this._fileList || this._fileList.length === 0) return;
+
+ this.set('changeViewState.selectedFileIndex', this._fileList.indexOf(path));
+ }
+
+ _getDiffUrl(change?: ChangeInfo, patchRange?: PatchRange, path?: string) {
+ if (!change || !patchRange || !path) return '';
+ return GerritNav.getUrlForDiff(
+ change,
+ path,
+ patchRange.patchNum,
+ patchRange.basePatchNum
+ );
+ }
+
+ _patchRangeStr(patchRange: PatchRange) {
+ let patchStr = `${patchRange.patchNum}`;
+ if (
+ patchRange.basePatchNum &&
+ patchRange.basePatchNum !== ParentPatchSetNum
+ ) {
+ patchStr = `${patchRange.basePatchNum}..${patchRange.patchNum}`;
+ }
+ return patchStr;
+ }
+
+ /**
+ * When the latest patch of the change is selected (and there is no base
+ * patch) then the patch range need not appear in the URL. Return a patch
+ * range object with undefined values when a range is not needed.
+ */
+ _getChangeUrlRange(
+ patchRange?: PatchRange,
+ revisions?: {[revisionId: string]: RevisionInfo}
+ ) {
+ let patchNum = undefined;
+ let basePatchNum = undefined;
+ let latestPatchNum = -1;
+ for (const rev of Object.values(revisions || {})) {
+ if (typeof rev._number === 'number') {
+ latestPatchNum = Math.max(latestPatchNum, rev._number);
+ }
+ }
+ if (!patchRange) return {patchNum, basePatchNum};
+ if (
+ patchRange.basePatchNum !== ParentPatchSetNum ||
+ !patchNumEquals(patchRange.patchNum, latestPatchNum as PatchSetNum)
+ ) {
+ patchNum = patchRange.patchNum;
+ basePatchNum = patchRange.basePatchNum;
+ }
+ return {patchNum, basePatchNum};
+ }
+
+ _getChangePath(
+ change?: ChangeInfo,
+ patchRange?: PatchRange,
+ revisions?: {[revisionId: string]: RevisionInfo}
+ ) {
+ if (!change) return '';
+ if (!patchRange) return '';
+
+ const range = this._getChangeUrlRange(patchRange, revisions);
+ return GerritNav.getUrlForChange(
+ change,
+ range.patchNum,
+ range.basePatchNum
+ );
+ }
+
+ _navigateToChange(
+ change?: ChangeInfo,
+ patchRange?: PatchRange,
+ revisions?: {[revisionId: string]: RevisionInfo}
+ ) {
+ if (!change) return;
+ const range = this._getChangeUrlRange(patchRange, revisions);
+ GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
+ }
+
+ _computeChangePath(
+ change?: ChangeInfo,
+ patchRangeRecord?: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+ revisions?: {[revisionId: string]: RevisionInfo}
+ ) {
+ if (!patchRangeRecord) return '';
+ return this._getChangePath(change, patchRangeRecord.base, revisions);
+ }
+
+ _formatFilesForDropdown(
+ files?: Files,
+ patchNum?: PatchSetNum,
+ changeComments?: ChangeComments
+ ): DropdownItem[] {
+ if (!files) return [];
+ if (!patchNum) return [];
+ if (!changeComments) return [];
+
+ const dropdownContent: DropdownItem[] = [];
+ for (const path of files.sortedFileList) {
+ dropdownContent.push({
+ text: computeDisplayPath(path),
+ mobileText: computeTruncatedPath(path),
+ value: path,
+ bottomText: this._computeCommentString(
+ changeComments,
+ patchNum,
+ path,
+ files.changeFilesByPath[path]
+ ),
+ });
+ }
+ return dropdownContent;
+ }
+
+ _computeCommentString(
+ changeComments?: ChangeComments,
+ patchNum?: PatchSetNum,
+ path?: string,
+ changeFileInfo?: FileInfo
+ ) {
+ if (!changeComments) return '';
+ if (!path) return '';
+ if (!changeFileInfo) return '';
+
+ const unresolvedCount = changeComments.computeUnresolvedNum({
+ patchNum,
+ path,
+ });
+ const commentCount = changeComments.computeCommentCount({patchNum, path});
+ const commentString = GrCountStringFormatter.computePluralString(
+ commentCount,
+ 'comment'
+ );
+ const unresolvedString = GrCountStringFormatter.computeString(
+ unresolvedCount,
+ 'unresolved'
+ );
+
+ const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
+
+ return [unmodifiedString, commentString, unresolvedString]
+ .filter(v => v && v.length > 0)
+ .join(', ');
+ }
+
+ _computePrefsButtonHidden(
+ prefs?: DiffPreferencesInfo,
+ prefsDisabled?: boolean
+ ) {
+ return prefsDisabled || !prefs;
+ }
+
+ _handleFileChange(e: CustomEvent) {
+ if (!this._change) return;
+ if (!this._patchRange) return;
+
+ // This is when it gets set initially.
+ const path = e.detail.value;
+ if (path === this._path) {
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ path,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handlePatchChange(e: CustomEvent) {
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ const {basePatchNum, patchNum} = e.detail;
+ if (
+ patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+ patchNumEquals(patchNum, this._patchRange.patchNum)
+ ) {
+ return;
+ }
+ GerritNav.navigateToDiff(this._change, this._path, patchNum, basePatchNum);
+ }
+
+ _handlePrefsTap(e: Event) {
+ e.preventDefault();
+ this.$.diffPreferencesDialog.open();
+ }
+
+ /**
+ * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+ * the current state.
+ *
+ * The expected behavior is to use the mode specified in the user's
+ * preferences unless they have manually chosen the alternative view or they
+ * are on a mobile device. If the user navigates up to the change view, it
+ * should clear this choice and revert to the preference the next time a
+ * diff is viewed.
+ *
+ * Use side-by-side if the user is not logged in.
+ */
+ _getDiffViewMode() {
+ if (this.changeViewState.diffMode) {
+ return this.changeViewState.diffMode;
+ } else if (this._userPrefs) {
+ this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
+ return this._userPrefs.default_diff_view;
+ } else {
+ return 'SIDE_BY_SIDE';
+ }
+ }
+
+ _computeModeSelectHideClass(diff?: DiffInfo) {
+ return !diff || diff.binary ? 'hide' : '';
+ }
+
+ _onLineSelected(
+ _: Event,
+ detail: {side: Side | CommentSide; number: number}
+ ) {
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._changeNum) return;
+ if (!this._patchRange) return;
+
+ const number = detail.number;
+ // for on-comment-anchor-tap side can be PARENT/REVISIONS
+ // for on-line-selected side can be left/right
+ const leftSide =
+ detail.side === Side.LEFT || detail.side === CommentSide.PARENT;
+ const url = GerritNav.getUrlForDiffById(
+ this._changeNum,
+ this._change.project,
+ this._path,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum,
+ number,
+ leftSide
+ );
+ history.replaceState(null, '', url);
+ }
+
+ _computeDownloadDropdownLinks(
+ project?: RepoName,
+ changeNum?: NumericChangeId,
+ patchRange?: PatchRange,
+ path?: string,
+ diff?: DiffInfo
+ ) {
+ if (!project) return [];
+ if (!changeNum) return [];
+ if (!patchRange || !patchRange.patchNum) return [];
+ if (!path) return [];
+
+ const links = [
+ {
+ url: this._computeDownloadPatchLink(
+ project,
+ changeNum,
+ patchRange,
+ path
+ ),
+ name: 'Patch',
+ },
+ ];
+
+ if (diff && diff.meta_a) {
+ let leftPath = path;
+ if (diff.change_type === 'RENAMED') {
+ leftPath = diff.meta_a.name;
+ }
+ links.push({
+ url: this._computeDownloadFileLink(
+ project,
+ changeNum,
+ patchRange,
+ leftPath,
+ true
+ ),
+ name: 'Left Content',
+ });
+ }
+
+ if (diff && diff.meta_b) {
+ links.push({
+ url: this._computeDownloadFileLink(
+ project,
+ changeNum,
+ patchRange,
+ path,
+ false
+ ),
+ name: 'Right Content',
+ });
+ }
+
+ return links;
+ }
+
+ _computeDownloadFileLink(
+ project: RepoName,
+ changeNum: NumericChangeId,
+ patchRange: PatchRange,
+ path: string,
+ isBase?: boolean
+ ) {
+ let patchNum = patchRange.patchNum;
+
+ const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+
+ if (isBase && !comparedAgainsParent) {
+ patchNum = patchRange.basePatchNum;
+ }
+
+ let url =
+ changeBaseURL(project, changeNum, patchNum) +
+ `/files/${encodeURIComponent(path)}/download`;
+
+ if (isBase && comparedAgainsParent) {
+ url += '?parent=1';
+ }
+
+ return url;
+ }
+
+ _computeDownloadPatchLink(
+ project: RepoName,
+ changeNum: NumericChangeId,
+ patchRange: PatchRange,
+ path: string
+ ) {
+ let url = changeBaseURL(project, changeNum, patchRange.patchNum);
+ url += '/patch?zip&path=' + encodeURIComponent(path);
+ return url;
+ }
+
+ _loadComments() {
+ if (!this._changeNum) throw new Error('Missing this._changeNum');
+ return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
+ this._changeComments = comments;
+ });
+ }
+
+ @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+ _recomputeComments(
+ files?: {[path: string]: FileInfo},
+ path?: string,
+ patchRange?: PatchRange,
+ projectConfig?: ConfigInfo
+ ) {
+ if (!files) return;
+ if (!path) return;
+ if (!patchRange) return;
+ if (!projectConfig) return;
+ if (!this._changeComments) return;
+
+ const file = files[path];
+ if (file && file.old_path) {
+ this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
+ {path, basePath: file.old_path},
+ patchRange,
+ projectConfig
+ );
+
+ this.$.diffHost.comments = this._commentsForDiff;
+ }
+ }
+
+ _getPaths(patchRange: PatchRange) {
+ if (!this._changeComments) return {};
+ return this._changeComments.getPaths(patchRange);
+ }
+
+ _getCommentsForPath(
+ path?: string,
+ patchRange?: PatchRange,
+ projectConfig?: ConfigInfo
+ ) {
+ if (!path) return undefined;
+ if (!patchRange) return undefined;
+ if (!this._changeComments) return undefined;
+
+ return this._changeComments.getCommentsBySideForPath(
+ path,
+ patchRange,
+ projectConfig
+ );
+ }
+
+ _getDiffDrafts() {
+ if (!this._changeNum) throw new Error('Missing this._changeNum');
+
+ return this.$.restAPI.getDiffDrafts(this._changeNum);
+ }
+
+ _computeCommentSkips(
+ commentMap?: CommentMap,
+ fileList?: string[],
+ path?: string
+ ) {
+ if (!commentMap) return undefined;
+ if (!fileList) return undefined;
+ if (!path) return undefined;
+
+ const skips: CommentSkips = {previous: null, next: null};
+ if (!fileList.length) {
+ return skips;
+ }
+ const pathIndex = fileList.indexOf(path);
+
+ // Scan backward for the previous file.
+ for (let i = pathIndex - 1; i >= 0; i--) {
+ if (commentMap[fileList[i]]) {
+ skips.previous = fileList[i];
+ break;
+ }
+ }
+
+ // Scan forward for the next file.
+ for (let i = pathIndex + 1; i < fileList.length; i++) {
+ if (commentMap[fileList[i]]) {
+ skips.next = fileList[i];
+ break;
+ }
+ }
+
+ return skips;
+ }
+
+ _computeContainerClass(editMode: boolean) {
+ return editMode ? 'editMode' : '';
+ }
+
+ _computeEditMode(
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+ ) {
+ const patchRange = patchRangeRecord.base || {};
+ return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+ }
+
+ _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
+ return loaded && !loading ? 'Hide blame' : 'Show blame';
+ }
+
+ _loadBlame() {
+ this._isBlameLoading = true;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: MSG_LOADING_BLAME},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this.$.diffHost
+ .loadBlame()
+ .then(() => {
+ this._isBlameLoading = false;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: MSG_LOADED_BLAME},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ })
+ .catch(() => {
+ this._isBlameLoading = false;
+ });
+ }
+
+ /**
+ * Load and display blame information if it has not already been loaded.
+ * Otherwise hide it.
+ */
+ _toggleBlame() {
+ if (this._isBlameLoaded) {
+ this.$.diffHost.clearBlame();
+ return;
+ }
+ this._loadBlame();
+ }
+
+ _handleToggleBlame(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ this._toggleBlame();
+ }
+
+ _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ this.toggleClass('hideComments');
+ }
+
+ _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Base is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ this._patchRange.patchNum
+ );
+ }
+
+ _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Left is already base.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Latest is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ latestPatchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Right is already latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ latestPatchNum,
+ this._patchRange.patchNum
+ );
+ }
+
+ _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (
+ patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+ patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+ ) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Already diffing base against latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
+ }
+
+ _computeBlameLoaderClass(isImageDiff?: boolean, path?: string) {
+ return !isMagicPath(path) && !isImageDiff ? 'show' : '';
+ }
+
+ _getRevisionInfo(change: ChangeInfo) {
+ return new RevisionInfoObj(change);
+ }
+
+ _computeFileNum(file?: string, files?: DropdownItem[]) {
+ if (!file || !files) return undefined;
+
+ return files.findIndex(({value}) => value === file) + 1;
+ }
+
+ _computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
+ if (files && fileNum && fileNum > 0) {
+ return 'show';
+ }
+ return '';
+ }
+
+ _handleExpandAllDiffContext(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ this.$.diffHost.expandAllContext();
+ }
+
+ _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
+ return disableDiffPrefs || !loggedIn;
+ }
+
+ _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._path) return;
+ if (!this._fileList) return;
+ if (!this._reviewedFiles) return;
+
+ this._setReviewed(true);
+ // Ensure that the currently viewed file always appears in unreviewedFiles
+ // so we resolve the right "next" file.
+ const unreviewedFiles = this._fileList.filter(
+ file => file === this._path || !this._reviewedFiles.has(file)
+ );
+ this._navToFile(this._path, unreviewedFiles, 1);
+ }
+
+ _handleReloadingDiffPreference() {
+ this._getDiffPreferences();
+ }
+
+ _computeCanEdit(
+ loggedIn?: boolean,
+ changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+ ) {
+ if (!changeChangeRecord?.base) return false;
+ return loggedIn && changeIsOpen(changeChangeRecord.base);
+ }
+
+ _computeIsLoggedIn(loggedIn: boolean) {
+ return loggedIn ? true : false;
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeAllPatchSets(change: ChangeInfo) {
+ return computeAllPatchSets(change);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeDisplayPath(path: string) {
+ return computeDisplayPath(path);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-view': GrDiffView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index b63af54..7d18527 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -24,6 +24,7 @@
import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
import {appContext} from '../../../services/app-context.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
const basicFixture = fixtureFromElement('gr-diff-view');
@@ -82,7 +83,7 @@
};
}
- setup(() => {
+ setup(async () => {
clock = sinon.useFakeTimers();
sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
stub('gr-rest-api-interface', {
@@ -118,6 +119,14 @@
},
});
element = basicFixture.instantiate();
+ element._changeNum = '42';
+ element._path = 'some/path.txt';
+ element._change = {};
+ element._diff = {content: []};
+ element._patchRange = {
+ patchNum: 77,
+ basePatchNum: 'PARENT',
+ };
sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
_comments: {'/COMMIT_MSG': [{id: 'c1', line: 10, patch_set: 2,
__commentSide: 'left', path: '/COMMIT_MSG'}]},
@@ -127,7 +136,8 @@
getCommentsBySideForPath: () => {},
findCommentById: _testOnly_findCommentById,
}));
- return element._loadComments();
+ await element._loadComments();
+ await flush();
});
teardown(() => {
@@ -269,13 +279,22 @@
assert.equal(element._isFileUnchanged(diff), true);
});
- test('diff toast to go to latest is shown and not base', () => {
+ test('diff toast to go to latest is shown and not base', async () => {
sinon.stub(element.reporting, 'diffViewDisplayed');
sinon.stub(element, '_loadBlame');
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.spy(element, '_paramsChanged');
- sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
- generateChange({revisionsCount: 11})));
+ element.$.restAPI.getDiffChangeDetail.restore();
+ sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
+ .returns(
+ Promise.resolve(generateChange({revisionsCount: 11})));
+ element._patchRange = {
+ patchNum: '2',
+ basePatchNum: '1',
+ };
+ sinon.stub(element, '_isFileUnchanged').returns(false);
+ const toastStub =
+ sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
@@ -283,12 +302,8 @@
commentId: 'c1',
commentLink: true,
};
- element._change = generateChange({revisionsCount: 11});
- const toastStub =
- sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
- return element._paramsChanged.returnValues[0].then(() => {
- assert.isTrue(toastStub.called);
- });
+ await element._paramsChanged.returnValues[0];
+ assert.isTrue(toastStub.called);
});
test('toggle left diff with a hotkey', () => {
@@ -1251,7 +1266,7 @@
});
test('_getLineOfInterest', () => {
- assert.isNull(element._getLineOfInterest({}));
+ assert.isUndefined(element._getLineOfInterest({}));
element._focusLineNum = 12;
let result = element._getLineOfInterest({});
@@ -1334,10 +1349,20 @@
});
suite('_initPatchRange', () => {
+ setup(async () => {
+ // const changeDetail = generateChange({revisionsCount: 5});
+ // sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
+ // .returns(Promise.resolve(changeDetail));
+ element.params = {
+ view: GerritView.DIFF,
+ changeNum: '42',
+ patchNum: '3',
+ };
+ await flush();
+ });
test('empty', () => {
sinon.stub(element, '_getCommentsForPath');
sinon.stub(element, '_getPaths').returns(new Map());
- element.params = {};
element._initPatchRange();
assert.equal(Object.keys(element._commentMap).length, 0);
});
@@ -1354,7 +1379,6 @@
basePatchNum: '3',
patchNum: '5',
};
- element.params = {};
element._initPatchRange();
assert.deepEqual(Object.keys(element._commentMap),
['path/to/file/one.cpp', 'path-to/file/two.py']);
@@ -1523,6 +1547,9 @@
.then(reviewed => assert.isFalse(reviewed)));
promises.push(element._getReviewedStatus(false, null, null, 'path')
+ .then(reviewed => assert.isFalse(reviewed)));
+
+ promises.push(element._getReviewedStatus(false, 3, 5, 'path')
.then(reviewed => assert.isTrue(reviewed)));
return Promise.all(promises);
@@ -1613,6 +1640,7 @@
patchNum: 1,
basePatchNum: 'PARENT',
};
+ element._change = generateChange({revisionsCount: 1});
flush();
assert.isTrue(GerritNav.navigateToDiff.notCalled);
@@ -1754,6 +1782,7 @@
getReviewedFiles() { return Promise.resolve([]); },
});
element = basicFixture.instantiate();
+ element._changeNum = '42';
return element._loadComments();
});
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index aee5bcb..29eef48 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -20,7 +20,14 @@
GroupDetailView,
RepoDetailView,
} from './core/gr-navigation/gr-navigation';
-import {DashboardId, GroupId, RepoName} from '../types/common';
+import {
+ DashboardId,
+ GroupId,
+ NumericChangeId,
+ PatchSetNum,
+ RepoName,
+ UrlEncodedCommentId,
+} from '../types/common';
export interface AppElement extends HTMLElement {
params: AppElementParams | GenerateUrlParameters;
@@ -86,6 +93,28 @@
view: GerritView.AGREEMENTS;
}
+export interface AppElementDiffViewParam {
+ view: GerritView.DIFF;
+ changeNum: NumericChangeId;
+ project?: RepoName;
+ commentId?: UrlEncodedCommentId;
+ path?: string;
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+ lineNum: number;
+ leftSide?: boolean;
+ commentLink?: boolean;
+}
+export interface AppElementChangeViewParams {
+ view: GerritView.CHANGE;
+ changeNum: NumericChangeId;
+ project: RepoName;
+ edit?: boolean;
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+ queryMap?: Map<string, string> | URLSearchParams;
+}
+
export interface AppElementJustRegisteredParams {
// We use params.view === ... as a type guard.
// The view?: never tells to the compiler that
@@ -100,10 +129,12 @@
| AppElementDashboardParams
| AppElementGroupParams
| AppElementAdminParams
+ | AppElementChangeViewParams
| AppElementRepoParams
| AppElementDocSearchParams
| AppElementPluginScreenParams
| AppElementSearchParam
| AppElementSettingsParam
| AppElementAgreementParam
- | AppElementJustRegisteredParams;
+ | AppElementJustRegisteredParams
+ | AppElementDiffViewParam;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index 90dac88..78b6cda 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -29,6 +29,15 @@
removeScrollLock,
} from '@polymer/iron-overlay-behavior/iron-scroll-manager';
+interface ShowAlertEventDetail {
+ message: string;
+ dismissOnNavigation?: boolean;
+}
+
+interface ReloadEventDetail {
+ clearPatchset?: boolean;
+}
+
const HOVER_CLASS = 'hovered';
const HIDE_CLASS = 'hide';
@@ -193,6 +202,19 @@
* Hovercard elements are created outside of <gr-app>, so if you want to fire
* events, then you probably want to do that through the target element.
*/
+
+ dispatchEventThroughTarget(eventName: string): void;
+
+ dispatchEventThroughTarget(
+ eventName: 'show-alert',
+ detail: ShowAlertEventDetail
+ ): void;
+
+ dispatchEventThroughTarget(
+ eventName: 'reload',
+ detail: ReloadEventDetail
+ ): void;
+
dispatchEventThroughTarget(eventName: string, detail?: unknown) {
if (!detail) detail = {};
if (this._target)
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 8c26d4a..626c2dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -35,6 +35,7 @@
} from './gr-js-api-types';
import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
import {DiffLayer, HighlightJS} from '../../../types/types';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
const elements: {[key: string]: HTMLElement} = {};
const eventCallbacks: {[key: string]: EventCallback[]} = {};
@@ -177,7 +178,7 @@
}
}
- handleCommitMessage(change: ChangeInfo, msg: string) {
+ handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string) {
for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
try {
cb(change, msg);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 15cdac4..75af8a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -139,6 +139,7 @@
GroupName,
Hashtag,
TopMenuEntryInfo,
+ MergeableInfo,
} from '../../../types/common';
import {
CancelConditionCallback,
@@ -1551,7 +1552,7 @@
endpoint: '/commit?links',
patchNum,
reportEndpointAsIs: true,
- });
+ }) as Promise<CommitInfo | undefined>;
}
getChangeFiles(
@@ -3582,7 +3583,7 @@
changeNum,
endpoint: '/revisions/current/mergeable',
reportEndpointAsIs: true,
- });
+ }) as Promise<MergeableInfo | undefined>;
}
deleteDraftComments(query: string): Promise<Response> {
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index 180fb2e..fadbfa7 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -17,6 +17,7 @@
import {patchNumEquals} from '../../../utils/patch-set-util';
import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
type RevNumberToParentCountMap = {[revNumber: number]: number};
@@ -26,7 +27,7 @@
* @param change A change object resulting from a change detail
* call that includes revision information.
*/
- constructor(private change: ChangeInfo) {}
+ constructor(private change: ChangeInfo | ParsedChangeInfo) {}
/**
* Get the largest number of parents of the commit in any revision. For
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index b255ea5..d915873 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -544,6 +544,7 @@
readonly metaKey: boolean;
readonly shiftKey: boolean;
readonly keyCode: number;
+ readonly repeat: boolean;
}
function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
const event = dom(e.detail ? e.detail.keyboardEvent : e);
@@ -1091,6 +1092,8 @@
getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
+ // TODO(TS): Remove underscore. Apparently not a private method.
+ _throttleWrap(eventListener: EventListener): EventListener;
}
export function _testOnly_getShortcutManagerInstance() {
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 6b93082..950619b 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -98,6 +98,8 @@
Hashtag,
FileNameToFileInfoMap,
TopMenuEntryInfo,
+ MergeableInfo,
+ CommitInfo,
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
@@ -216,7 +218,7 @@
getChangeDetail(
changeNum: number | string,
- opt_errFn?: Function,
+ opt_errFn?: ErrorCallback,
opt_cancelCondition?: Function
): Promise<ParsedChangeInfo | null | undefined>;
@@ -818,6 +820,11 @@
topic: string | null
): Promise<string>;
+ getChangeFiles(
+ changeNum: NumericChangeId,
+ patchRange: PatchRange
+ ): Promise<FileNameToFileInfoMap | undefined>;
+
getChangeOrEditFiles(
changeNum: NumericChangeId,
patchRange: PatchRange
@@ -844,4 +851,17 @@
): Promise<Response | undefined>;
getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
+
+ setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+ getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
+
+ putChangeCommitMessage(
+ changeNum: NumericChangeId,
+ message: string
+ ): Promise<Response>;
+
+ getChangeCommitInfo(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<CommitInfo | undefined>;
}
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 500187a..eead4f8 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -14,7 +14,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
+// This should be the first import to install handler before any other code
+import './source-map-support-install.js';
// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
import '../scripts/bundled-polymer.js';
diff --git a/polygerrit-ui/app/test/source-map-support-install.js b/polygerrit-ui/app/test/source-map-support-install.js
new file mode 100644
index 0000000..a8f147382
--- /dev/null
+++ b/polygerrit-ui/app/test/source-map-support-install.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+// The karma.conf.js file loads required module before any other modules
+// The source-map-support.js can't be imported with import ... statement
+window.sourceMapSupport.install();
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 5bcf0b8..81eae16 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -44,6 +44,7 @@
NotifyType,
EmailFormat,
AuthType,
+ MergeStrategy,
} from '../constants/constants';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
@@ -804,7 +805,7 @@
large_change: string;
reply_label: string;
reply_tooltip: string;
- update_delay: string;
+ update_delay: number;
submit_whole_topic: boolean;
disable_private_changes: boolean;
mergeability_computation_behavior: string;
@@ -1158,6 +1159,7 @@
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
*/
export interface CommentInfo {
+ // TODO(TS): Make this required.
patch_set?: PatchSetNum;
id: UrlEncodedCommentId;
path?: string;
@@ -1441,7 +1443,7 @@
/**
* The ConfigInfo entity contains information about the effective
- * projectconfiguration.
+ * project configuration.
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
*/
export interface ConfigInfo {
@@ -1468,7 +1470,8 @@
}
/**
- * The ProjectAccessInfo entity contains information about the access rights for a project
+ * The ProjectAccessInfo entity contains information about the access rights for
+ * a project.
* https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
*/
export interface ProjectAccessInfo {
@@ -2233,3 +2236,16 @@
topic?: TopicName;
allow_empty?: boolean;
}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export interface MergeableInfo {
+ submit_type: SubmitType;
+ strategy?: MergeStrategy;
+ mergeable: boolean;
+ commit_merged?: boolean;
+ content_merged?: boolean;
+ conflicts?: string[];
+ mergeable_into?: string[];
+}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 3768391..3bb8e37 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -14,12 +14,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {Side} from '../constants/constants';
+import {DiffViewMode, Side} from '../constants/constants';
import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
import {PaperInputElement} from '@polymer/paper-input/paper-input';
-import {CommitId} from './common';
+import {CommitId, NumericChangeId, PatchRange, PatchSetNum} from './common';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
export function notUndefined<T>(x: T | undefined): x is T {
return x !== undefined;
@@ -165,3 +166,58 @@
addListener?(listener: DiffLayerListener): void;
removeListener?(listener: DiffLayerListener): void;
}
+
+export interface ChangeViewState {
+ changeNum: NumericChangeId | null;
+ patchRange: PatchRange | null;
+ selectedFileIndex: number;
+ showReplyDialog: boolean;
+ showDownloadDialog: boolean;
+ diffMode: DiffViewMode | null;
+ numFilesShown: number | null;
+ scrollTop: number;
+}
+
+export interface ChangeListViewState {
+ query: string | null;
+ offset: number;
+ selectedChangeIndex: number;
+}
+
+export interface DashboardViewState {
+ selectedChangeIndex: number;
+}
+
+export interface ViewState {
+ changeView: ChangeViewState;
+ changeListView: ChangeListViewState;
+ dashboardView: DashboardViewState;
+}
+
+export interface PatchSetFile {
+ path: string;
+ basePath?: string;
+ patchNum?: PatchSetNum;
+}
+
+export interface PatchNumOnly {
+ patchNum: PatchSetNum;
+}
+
+export function isPatchSetFile(
+ x: PatchSetFile | PatchNumOnly
+): x is PatchSetFile {
+ return !!(x as PatchSetFile).path;
+}
+
+export interface FileRange {
+ basePath?: string;
+ path: string;
+}
+
+export function isPolymerSpliceChange<
+ T,
+ U extends Array<{} | null | undefined>
+>(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
+ return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 962278d..48ef367 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -57,10 +57,6 @@
basePatchNum?: PatchSetNum;
}
-interface PatchRangeRecord {
- base: PatchRange;
-}
-
/**
* As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
* this function checks for patchNum equality.
@@ -246,7 +242,9 @@
export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
-export function computeLatestPatchNum(allPatchSets?: PatchSet[]) {
+export function computeLatestPatchNum(
+ allPatchSets?: PatchSet[]
+): PatchSetNum | undefined {
if (!allPatchSets || !allPatchSets.length) {
return undefined;
}
@@ -263,11 +261,7 @@
return allPatchSets[0].num === EditPatchSetNum;
}
-export function hasEditPatchsetLoaded(patchRangeRecord: PatchRangeRecord) {
- const patchRange = patchRangeRecord.base;
- if (!patchRange) {
- return false;
- }
+export function hasEditPatchsetLoaded(patchRange: PatchRange) {
return (
patchRange.patchNum === EditPatchSetNum ||
patchRange.basePatchNum === EditPatchSetNum
@@ -283,7 +277,7 @@
* meantime. The promise is rejected on network error.
*/
export function fetchChangeUpdates(
- change: ChangeInfo,
+ change: ChangeInfo | ParsedChangeInfo,
restAPI: RestApiService
) {
const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 008abd2..dda6031 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -90,7 +90,7 @@
return path;
}
-export function isMagicPath(path: string) {
+export function isMagicPath(path?: string) {
return (
!!path &&
(path === SpecialFilePath.COMMIT_MESSAGE ||
diff --git a/polygerrit-ui/grep-patch-karma.js b/polygerrit-ui/grep-patch-karma.js
new file mode 100644
index 0000000..adf5171
--- /dev/null
+++ b/polygerrit-ui/grep-patch-karma.js
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+// The IntelliJ (and probably other IDEs) passes test names as a regexp in
+// the format:
+// --grep=/some regexp.../
+// But mochajs doesn't expect the '/' characters before and after the regexp.
+// The code below patches input args and removes '/' if they exists.
+function installPatch(karma) {
+ const originalKarmaStart = karma.start;
+
+ karma.start = function(config, ...args) {
+ const regexpGrepPrefix = '--grep=/';
+ const regexpGrepSuffix = '/';
+ if (config && config.args) {
+ for (let i = 0; i < config.args.length; i++) {
+ const arg = config.args[i];
+ if (arg.startsWith(regexpGrepPrefix) && arg.endsWith(regexpGrepSuffix)) {
+ const regexpText = arg.slice(regexpGrepPrefix.length, -regexpGrepPrefix.length);
+ config.args[i] = '--grep=' + regexpText;
+ }
+ }
+ }
+ originalKarmaStart.apply(this, [config, ...args]);
+ }
+
+}
+
+const karma = window.__karma__;
+if (karma && karma.start && !karma.__grep_patch_installed__) {
+ karma.__grep_patch_installed__ = true;
+ installPatch(karma);
+}
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index 879a5c8..fb87675 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -43,6 +43,20 @@
}
}
+function runInIde() {
+ // A simple detection of IDE.
+ // Default browserNoActivityTimeout is 30 seconds. An IDE usually
+ // runs karma in background and send commands when a user wants to
+ // execute test. If interval between user executed tests is bigger than
+ // browserNoActivityTimeout, the IDE reports error and doesn't restart
+ // server.
+ // We want to increase browserNoActivityTimeout when tests run in IDE.
+ // Wd don't want to increase it in other cases, oterhise hanging tests
+ // can slow down CI.
+ return !runUnderBazel &&
+ process.argv.some(arg => arg.toLowerCase().contains('intellij'));
+}
+
module.exports = function(config) {
const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
const rootDir = runUnderBazel ?
@@ -58,7 +72,10 @@
const testFilesPattern = (typeof config.testFiles == 'string') ?
testFilesLocationPattern + config.testFiles :
testFilesLocationPattern + '*_test.js';
+ // Special patch for grep parameters (see details in the grep-patch-karam.js)
+ const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
config.set({
+ browserNoActivityTimeout: runInIde ? 60 * 60 * 1000 : 30 * 1000,
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '../',
plugins: [
@@ -76,6 +93,8 @@
// list of files / patterns to load in the browser
files: [
+ ...additionalFiles,
+ getUiDevNpmFilePath('source-map-support/browser-source-map-support.js'),
getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
getUiDevNpmFilePath('sinon/pkg/sinon.js'),
{ pattern: testFilesPattern, type: 'module' },
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 7de55aa..91b8579 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -15,7 +15,8 @@
"karma-mocha-reporter": "^2.2.5",
"lodash": "^4.17.15",
"mocha": "7.2.0",
- "sinon": "^9.0.2"
+ "sinon": "^9.0.2",
+ "source-map-support": "^0.5.19"
},
"license": "Apache-2.0",
"private": true
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index dfc5a43..a70ded8 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -3741,7 +3741,7 @@
socket.io-client "2.1.1"
socket.io-parser "~3.2.0"
-source-map-support@~0.5.12:
+source-map-support@^0.5.19, source-map-support@~0.5.12:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==