Merge "Add API to set and get CustomKeyedValues."
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index c853226..4c666b9 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -3998,6 +3998,9 @@
All users must be a member of this group to allow account creation or
authentication.
+
+For example, setting to `ldap/gerritaccess` limits account creation or
+authentication to members of the ldap group `gerritaccess`.
++
Setting mandatoryGroup implies enabling of `ldap.fetchMemberOfEagerly`
+
By default, unset.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index ea17a40..d5d68b3f 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -382,7 +382,7 @@
[[custom-keyed-values]]
--
* `CUSTOM_KEYED_VALUES`: include the custom key-value map
----
+--
[[star]]
--
@@ -8260,6 +8260,9 @@
patch set is inferred. +
Empty string is used for rebasing directly on top of the target branch,
which effectively breaks dependency towards a parent change.
+|`strategy` |optional|
+The strategy of the merge, can be `recursive`, `resolve`,
+`simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
|`allow_conflicts` |optional, defaults to false|
If `true`, the rebase also succeeds if there are conflicts. +
If there are conflicts the file contents of the rebased patch set contain
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 1b87f32..91a4da6 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -43,6 +43,7 @@
PLUGIN_DELETE_PROJECT,
PLUGIN_HIGH_AVAILABILITY,
PLUGIN_MULTI_SITE,
+ PLUGIN_PULL_REPLICATION,
PLUGIN_SERVICEUSER,
PLUGIN_WEBSESSION_FLATFILE,
MODULE_GIT_REFS_FILTER
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index a85bc73..07e65d0 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -20,6 +20,13 @@
public String base;
/**
+ * {@code strategy} name of the merge strategy.
+ *
+ * @see org.eclipse.jgit.merge.MergeStrategy
+ */
+ public String strategy;
+
+ /**
* Whether the rebase should succeed if there are conflicts.
*
* <p>If there are conflicts the file contents of the rebased change contain git conflict markers
diff --git a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
index 5333826..2c1cae6 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
@@ -14,8 +14,10 @@
package com.google.gerrit.gpg;
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.BaseEncoding;
import com.google.gerrit.entities.Account;
@@ -30,6 +32,9 @@
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.util.NB;
public class PublicKeyStoreUtil {
@@ -77,4 +82,29 @@
public Iterable<ExternalId> getGpgExtIds(Account.Id id) throws IOException {
return externalIds.byAccount(id, SCHEME_GPGKEY);
}
+
+ public RefUpdate.Result deletePgpKey(PGPPublicKey key, PersonIdent committer, PersonIdent author)
+ throws PGPException, IOException {
+ return deletePgpKeys(ImmutableList.of(key), committer, author).get(0);
+ }
+
+ public List<RefUpdate.Result> deletePgpKeys(
+ List<PGPPublicKey> keys, PersonIdent committer, PersonIdent author)
+ throws IOException, PGPException {
+ List<RefUpdate.Result> res = new ArrayList<>();
+ try (PublicKeyStore store = storeProvider.get()) {
+ for (PGPPublicKey key : keys) {
+ store.remove(key.getFingerprint());
+
+ CommitBuilder cb = new CommitBuilder();
+ cb.setAuthor(author);
+ cb.setCommitter(committer);
+ cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
+
+ RefUpdate.Result saveResult = store.save(cb);
+ res.add(saveResult);
+ }
+ }
+ return res;
+ }
}
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index f709dd6..6381ada 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -14,7 +14,6 @@
package com.google.gerrit.gpg.server;
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
import com.google.common.collect.ImmutableList;
@@ -28,6 +27,7 @@
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.UserInitiated;
import com.google.gerrit.server.account.AccountsUpdate;
@@ -42,7 +42,6 @@
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.RefUpdate;
@@ -50,7 +49,7 @@
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final Provider<PersonIdent> serverIdent;
- private final Provider<PublicKeyStore> storeProvider;
+ private final PublicKeyStoreUtil publicKeyStoreUtil;
private final Provider<AccountsUpdate> accountsUpdateProvider;
private final ExternalIds externalIds;
private final DeleteKeyEmailFactories deleteKeyEmailFactories;
@@ -59,13 +58,13 @@
@Inject
DeleteGpgKey(
@GerritPersonIdent Provider<PersonIdent> serverIdent,
- Provider<PublicKeyStore> storeProvider,
+ PublicKeyStoreUtil publicKeyStoreUtil,
@UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
ExternalIds externalIds,
DeleteKeyEmailFactories deleteKeyEmailFactories,
ExternalIdKeyFactory externalIdKeyFactory) {
this.serverIdent = serverIdent;
- this.storeProvider = storeProvider;
+ this.publicKeyStoreUtil = publicKeyStoreUtil;
this.accountsUpdateProvider = accountsUpdateProvider;
this.externalIds = externalIds;
this.deleteKeyEmailFactories = deleteKeyEmailFactories;
@@ -90,42 +89,35 @@
rsrc.getUser().getAccountId(),
u -> u.deleteExternalId(extId.get()));
- try (PublicKeyStore store = storeProvider.get()) {
- store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
+ PersonIdent committer = serverIdent.get();
+ PersonIdent author = rsrc.getUser().newCommitterIdent(committer);
- CommitBuilder cb = new CommitBuilder();
- PersonIdent committer = serverIdent.get();
- cb.setAuthor(rsrc.getUser().newCommitterIdent(committer));
- cb.setCommitter(committer);
- cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
-
- RefUpdate.Result saveResult = store.save(cb);
- switch (saveResult) {
- case NO_CHANGE:
- case FAST_FORWARD:
- try {
- deleteKeyEmailFactories
- .createEmail(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
- .send();
- } catch (EmailException e) {
- logger.atSevere().withCause(e).log(
- "Cannot send GPG key deletion message to %s",
- rsrc.getUser().getAccount().preferredEmail());
- }
- break;
- case LOCK_FAILURE:
- case FORCED:
- case IO_FAILURE:
- case NEW:
- case NOT_ATTEMPTED:
- case REJECTED:
- case REJECTED_CURRENT_BRANCH:
- case RENAMED:
- case REJECTED_MISSING_OBJECT:
- case REJECTED_OTHER_REASON:
- default:
- throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
- }
+ RefUpdate.Result saveResult = publicKeyStoreUtil.deletePgpKey(key, committer, author);
+ switch (saveResult) {
+ case NO_CHANGE:
+ case FAST_FORWARD:
+ try {
+ deleteKeyEmailFactories
+ .createEmail(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
+ .send();
+ } catch (EmailException e) {
+ logger.atSevere().withCause(e).log(
+ "Cannot send GPG key deletion message to %s",
+ rsrc.getUser().getAccount().preferredEmail());
+ }
+ break;
+ case LOCK_FAILURE:
+ case FORCED:
+ case IO_FAILURE:
+ case NEW:
+ case NOT_ATTEMPTED:
+ case REJECTED:
+ case REJECTED_CURRENT_BRANCH:
+ case RENAMED:
+ case REJECTED_MISSING_OBJECT:
+ case REJECTED_OTHER_REASON:
+ default:
+ throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
}
return Response.none();
}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
index 1edb284..8b53d70 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -43,7 +43,9 @@
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
@@ -184,8 +186,17 @@
}
}
- AllExternalIds allExternalIds =
- buildAllExternalIds(repo, oldExternalIds, additions, removals);
+ AllExternalIds allExternalIds;
+ try {
+ allExternalIds = buildAllExternalIds(repo, oldExternalIds, additions, removals);
+ } catch (IllegalArgumentException e) {
+ Set<String> additionKeys =
+ additions.keySet().stream().map(AnyObjectId::getName).collect(Collectors.toSet());
+ logger.atSevere().withCause(e).log(
+ "Failed to load external ID cache. Repository ref is %s, cache ref is %s, additions are %s",
+ extIdRef.getObjectId().getName(), parentWithCacheValue.getId().getName(), additionKeys);
+ throw e;
+ }
reloadCounter.increment(true);
reloadDifferential.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
return allExternalIds;
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 4875978..e6fc4e7 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -446,7 +446,8 @@
Joiner.on('~')
.join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
if (experimentFeatures.isFeatureEnabled(
- ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_RETURN_NEW_CHANGE_INFO_ID)) {
+ ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_RETURN_NEW_CHANGE_INFO_ID,
+ Project.nameKey(info.project))) {
info.id =
Joiner.on('~').join(Url.encode(info.project), Url.encode(String.valueOf(info._number)));
} else {
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index ed87c76..540e438 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -14,11 +14,13 @@
package com.google.gerrit.server.change;
+import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Objects.requireNonNull;
+import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
@@ -62,6 +64,7 @@
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.merge.MergeResult;
+import org.eclipse.jgit.merge.Merger;
import org.eclipse.jgit.merge.ResolveMerger;
import org.eclipse.jgit.merge.ThreeWayMerger;
import org.eclipse.jgit.revwalk.RevCommit;
@@ -108,6 +111,7 @@
private boolean storeCopiedVotes = true;
private boolean matchAuthorToCommitterDate = false;
private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
+ private String mergeStrategy;
private CodeReviewCommit rebasedCommit;
private PatchSet.Id rebasedPatchSetId;
@@ -264,6 +268,11 @@
return this;
}
+ public RebaseChangeOp setMergeStrategy(String strategy) {
+ this.mergeStrategy = strategy;
+ return this;
+ }
+
@Override
public void updateRepo(RepoContext ctx)
throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
@@ -430,9 +439,14 @@
throw new ResourceConflictException("Change is already up to date.");
}
- ThreeWayMerger merger =
- newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
- merger.setBase(parentCommit);
+ MergeUtil mergeUtil = newMergeUtil();
+ String strategy =
+ firstNonNull(Strings.emptyToNull(mergeStrategy), mergeUtil.mergeStrategyName());
+
+ Merger merger = MergeUtil.newMerger(ctx.getInserter(), ctx.getRepoView().getConfig(), strategy);
+ if (merger instanceof ThreeWayMerger) {
+ ((ThreeWayMerger) merger).setBase(parentCommit);
+ }
DirCache dc = DirCache.newInCore();
if (allowConflicts && merger instanceof ResolveMerger) {
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 56ab936..48b052f 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -552,6 +552,7 @@
private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
return op.setForceContentMerge(true)
.setAllowConflicts(input.allowConflicts)
+ .setMergeStrategy(input.strategy)
.setValidationOptions(
ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
.setFireRevisionCreated(true);
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index d6e234b..3373860 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -190,7 +190,7 @@
import com.google.gerrit.server.project.ProjectCacheImpl;
import com.google.gerrit.server.project.ProjectNameLockManager;
import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.PrologRulesBlockerValidator;
+import com.google.gerrit.server.project.PrologRulesWarningValidator;
import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
import com.google.gerrit.server.project.SubmitRuleEvaluator;
@@ -401,7 +401,7 @@
DynamicSet.setOf(binder(), CommitValidationListener.class);
DynamicSet.bind(binder(), CommitValidationListener.class)
.to(SubmitRequirementConfigValidator.class);
- DynamicSet.bind(binder(), CommitValidationListener.class).to(PrologRulesBlockerValidator.class);
+ DynamicSet.bind(binder(), CommitValidationListener.class).to(PrologRulesWarningValidator.class);
DynamicSet.setOf(binder(), CommentValidator.class);
DynamicSet.setOf(binder(), ChangeMessageModifier.class);
DynamicSet.setOf(binder(), RefOperationValidationListener.class);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index fd7d504..6052ddc 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -27,8 +27,8 @@
public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
/** On BatchUpdate, do not await index completion before returning to the user */
- public static String GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING =
- "GerritBackendFeature__do_not_await_change_indexing";
+ public static String GERRIT_BACKEND_REQUEST_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING =
+ "GerritBackendRequestFeature__do_not_await_change_indexing";
/**
* Sets ChangeInfo.id to "'<project>~<_number>'", instead of "'<project>~<branch>~<Change-Id>'",
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index ca19150..a1db847 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -41,8 +41,7 @@
import com.google.gerrit.server.update.RetryableAction.ActionType;
import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
import com.google.gerrit.server.validators.ValidationException;
-import com.google.template.soy.data.SanitizedContent.ContentKind;
-import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
+import com.google.template.soy.data.SanitizedContent;
import com.google.template.soy.jbcsrc.api.SoySauce;
import java.net.MalformedURLException;
import java.net.URL;
@@ -114,7 +113,7 @@
private final Set<Address> smtpBccRcptTo = new HashSet<>();
private Address smtpFromAddress;
private StringBuilder textBody;
- private StringBuilder htmlBody;
+ private ArrayList<SanitizedContent> htmlBodySections;
private MessageIdGenerator.MessageId messageId;
private Map<String, Object> soyContext;
private Map<String, Object> soyContextEmailData;
@@ -163,13 +162,9 @@
}
private String constructHtmlEmail() {
- soyContext.put(
- "body", UnsafeSanitizedContentOrdainer.ordainAsSafe(htmlBody.toString(), ContentKind.HTML));
- soyContext.put(
- "footer",
- UnsafeSanitizedContentOrdainer.ordainAsSafe(
- soyHtmlTemplate("FooterHtml"), ContentKind.HTML));
- return soyHtmlTemplate("EmailHtml");
+ soyContext.put("body_sections_html", htmlBodySections);
+ soyContext.put("footer_html", soyHtmlTemplate("FooterHtml"));
+ return soyHtmlTemplate("EmailHtml").toString();
}
/** Format and enqueue the message for delivery. */
@@ -401,7 +396,7 @@
setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
addFooter(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
textBody = new StringBuilder();
- htmlBody = new StringBuilder();
+ htmlBodySections = new ArrayList<>();
if (fromId != null && args.fromAddressGenerator.get().isGenericAddress(fromId)) {
appendText(getFromLine());
@@ -484,9 +479,9 @@
}
/** Append html to the outgoing email body. */
- public void appendHtml(String html) {
+ public void appendHtml(SanitizedContent html) {
if (html != null) {
- htmlBody.append(html);
+ htmlBodySections.add(html);
}
}
@@ -763,8 +758,8 @@
}
/** Renders a soy template of kind="html". */
- public String soyHtmlTemplate(String name) {
- return configureRenderer(name).renderHtml().get().toString();
+ public SanitizedContent soyHtmlTemplate(String name) {
+ return configureRenderer(name).renderHtml().get();
}
/** Configures a soy renderer for the given template name and rendering data map. */
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 115830e..500d3df 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -168,13 +168,14 @@
}
}
- public static String cleanPatch(final String patch) {
+ public static String normalizePatchForComparison(final String patch) {
String res = removePatchHeader(patch);
return res
- // Remove "index NN..NN" lines
- .replaceAll("(?m)^index.*", "")
- // Remove hunk-headers lines
- .replaceAll("(?m)^@@ .*", "")
+ // Remove any lines which are not diff lines or file header lines - such index,
+ // hunk-headers, and context lines.
+ .replaceAll("(?m)^[^+-].*", "")
+ .replaceAll("(?m)^[+]{3} [ab]/", "+++")
+ .replaceAll("(?m)^-{3} [ab]/", "+++")
// Remove empty lines
.replaceAll("\n+", "\n")
// Trim
@@ -184,21 +185,21 @@
public static String removePatchHeader(final String patch) {
String res = patch.trim();
if (!res.startsWith("diff --") && res.contains("\ndiff --")) {
- return res.substring(patch.indexOf("\ndiff --"), patch.length() - 1);
+ return res.substring(res.indexOf("\ndiff --"));
}
return res;
}
public static Optional<String> getPatchHeader(final String patch) {
- if (patch.startsWith("diff --")) {
+ String res = patch.trim();
+ if (res.startsWith("diff ")) {
return Optional.empty();
}
- return Optional.ofNullable(
- Strings.emptyToNull(patch.trim().substring(0, patch.indexOf("\ndiff --git"))));
+ return Optional.ofNullable(Strings.emptyToNull(res.substring(0, res.indexOf("\ndiff "))));
}
- public static String cleanPatch(BinaryResult bin) throws IOException {
- return cleanPatch(bin.asString());
+ public static String normalizePatchForComparison(BinaryResult bin) throws IOException {
+ return normalizePatchForComparison(bin.asString());
}
private static boolean isRootOrMergeCommit(RevCommit commit) {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 9899a6d..a3f8009 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -476,19 +476,19 @@
* {@code PluginConfig#withInheritance(ProjectState.Factory)}
*/
public PluginConfig getPluginConfig(String pluginName) {
- if (getConfig().getPluginConfigs().containsKey(pluginName)) {
- Config config = new Config();
+ Config config = new Config();
+ String cachedPluginConfig = getConfig().getPluginConfigs().get(pluginName);
+ if (cachedPluginConfig != null) {
try {
- config.fromText(getConfig().getPluginConfigs().get(pluginName));
+ config.fromText(cachedPluginConfig);
} catch (ConfigInvalidException e) {
// This is OK to propagate as IllegalStateException because it's a programmer error.
// The config was converted to a String using Config#toText. So #fromText must not
// throw a ConfigInvalidException
throw new IllegalStateException("invalid plugin config for " + pluginName, e);
}
- return PluginConfig.create(pluginName, config, getConfig());
}
- return PluginConfig.create(pluginName, new Config(), getConfig());
+ return PluginConfig.create(pluginName, config, getConfig());
}
public Optional<BranchOrderSection> getBranchOrderSection() {
diff --git a/java/com/google/gerrit/server/project/PrologRulesBlockerValidator.java b/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
similarity index 83%
rename from java/com/google/gerrit/server/project/PrologRulesBlockerValidator.java
rename to java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
index d4af2b2..5683fe7 100644
--- a/java/com/google/gerrit/server/project/PrologRulesBlockerValidator.java
+++ b/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
@@ -24,6 +24,7 @@
import com.google.gerrit.server.git.validators.CommitValidationException;
import com.google.gerrit.server.git.validators.CommitValidationListener;
import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.patch.DiffOperations;
import com.google.gerrit.server.patch.DiffOptions;
@@ -35,17 +36,17 @@
import java.util.stream.Collectors;
/**
- * A validator than bans creation of new Prolog rules. Modification and deletion will be allowed so
- * that clients can get rid of prolog rules. New rules should use submit-requirements instead.
+ * A validator than emits a warning for newly added prolog rules file via git push. Modification and
+ * deletion are allowed so that clients can get rid of prolog rules.
*/
@Singleton
-public class PrologRulesBlockerValidator implements CommitValidationListener {
+public class PrologRulesWarningValidator implements CommitValidationListener {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final DiffOperations diffOperations;
@Inject
- public PrologRulesBlockerValidator(DiffOperations diffOperations) {
+ public PrologRulesWarningValidator(DiffOperations diffOperations) {
this.diffOperations = diffOperations;
}
@@ -55,12 +56,11 @@
try {
if (receiveEvent.refName.equals(RefNames.REFS_CONFIG)
&& isFileAdded(receiveEvent, RULES_PL_FILE)) {
- throw new CommitValidationException(
- "Uploading 'rule.pl' not allowed",
+ return ImmutableList.of(
new CommitValidationMessage(
- "Uploading a new 'rules.pl' file is not allowed."
- + " Please add submit-requirements instead.",
- /*isError= */ true));
+ "Uploading a new 'rules.pl' file is discouraged."
+ + " Please consider adding submit-requirements instead.",
+ ValidationMessage.Type.WARNING));
}
} catch (DiffNotAvailableException e) {
logger.atWarning().withCause(e).log("Failed to retrieve the file diff.");
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 716cf10..961404a 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -30,7 +30,6 @@
import com.google.gerrit.index.query.QueryResult;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.account.AccountAttributeLoader;
-import com.google.gerrit.server.cache.PerThreadCache;
import com.google.gerrit.server.config.TrackingFooters;
import com.google.gerrit.server.data.ChangeAttribute;
import com.google.gerrit.server.data.PatchSetAttribute;
@@ -211,7 +210,7 @@
return;
}
- try (PerThreadCache ignored = PerThreadCache.create()) {
+ try {
final QueryStatsAttribute stats = new QueryStatsAttribute();
stats.runTimeMilliseconds = TimeUtil.nowMs();
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
index a5df0f8..1a252e5 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -175,8 +175,8 @@
}
private static Optional<String> verifyAppliedPatch(String originalPatch, String resultPatch) {
- String cleanOriginalPatch = DiffUtil.cleanPatch(originalPatch);
- String cleanResultPatch = DiffUtil.cleanPatch(resultPatch);
+ String cleanOriginalPatch = DiffUtil.normalizePatchForComparison(originalPatch);
+ String cleanResultPatch = DiffUtil.normalizePatchForComparison(resultPatch);
if (cleanOriginalPatch.equals(cleanResultPatch)) {
return Optional.empty();
}
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 14d781d..88d62b5 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -675,7 +675,7 @@
private boolean indexAsync() {
return user.getAccessPath().equals(AccessPath.WEB_BROWSER)
&& experimentFeatures.isFeatureEnabled(
- ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING,
+ ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING,
project);
}
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index 9df263b..a4e427d 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -23,6 +23,7 @@
import com.google.gerrit.server.InvalidDeadlineException;
import com.google.gerrit.server.RequestInfo;
import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.cache.PerThreadCache;
import com.google.gerrit.server.cancellation.RequestCancelledException;
import com.google.gerrit.server.cancellation.RequestStateContext;
import com.google.gerrit.server.config.GerritServerConfig;
@@ -62,7 +63,8 @@
public void start(ChannelSession channel, Environment env) throws IOException {
startThread(
() -> {
- try (DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
+ try (PerThreadCache ignored = PerThreadCache.create();
+ DynamicOptions pluginOptions = new DynamicOptions(injector, dynamicBeans)) {
parseCommandLine(pluginOptions);
stdout = toPrintWriter(out);
stderr = toPrintWriter(err);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index f31ae9b..6d51cb4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -21,7 +21,7 @@
import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.patch.DiffUtil.cleanPatch;
+import static com.google.gerrit.server.patch.DiffUtil.normalizePatchForComparison;
import static com.google.gerrit.server.patch.DiffUtil.removePatchHeader;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -157,6 +157,28 @@
}
@Test
+ public void applyValidTraditionalPatch_success() throws Exception {
+ final String fileName = "file_name.txt";
+ final String originalContent = "original line";
+ final String newContent = "new line\n";
+ final String diff =
+ "diff file_name.txt file_name.txt\n"
+ + "--- file_name.txt\n"
+ + "+++ file_name.txt\n"
+ + "@@ -1 +1 @@\n"
+ + "-original line\n"
+ + "+new line\n";
+ initBaseWithFile(fileName, originalContent);
+ ApplyPatchPatchSetInput in = buildInput(diff);
+
+ ChangeInfo result = applyPatch(in);
+
+ DiffInfo fileDiff = fetchDiffForFile(result, fileName);
+ assertDiffForFullyModifiedFile(
+ fileDiff, result.currentRevision, fileName, originalContent, newContent);
+ }
+
+ @Test
public void applyGerritBasedPatchWithSingleFile_success() throws Exception {
String head = getHead(repo(), HEAD).name();
createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
@@ -169,7 +191,8 @@
ChangeInfo result = applyPatch(in);
BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
- assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+ assertThat(normalizePatchForComparison(resultPatch))
+ .isEqualTo(normalizePatchForComparison(originalPatch));
}
@Test
@@ -191,7 +214,8 @@
ChangeInfo result = applyPatch(in);
BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
- assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+ assertThat(normalizePatchForComparison(resultPatch))
+ .isEqualTo(normalizePatchForComparison(originalPatch));
}
@Test
@@ -214,7 +238,8 @@
resp.assertOK();
BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
- assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+ assertThat(normalizePatchForComparison(resultPatch))
+ .isEqualTo(normalizePatchForComparison(originalPatch));
}
@Test
@@ -238,7 +263,8 @@
resp.assertOK();
BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
- assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalDecodedPatch));
+ assertThat(normalizePatchForComparison(resultPatch))
+ .isEqualTo(normalizePatchForComparison(originalDecodedPatch));
}
@Test
@@ -428,7 +454,8 @@
.isEqualTo(inputParent.getCommit().name());
BinaryResult resultPatch = gApi.changes().id(dest.getChangeId()).current().patch();
- assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(ADDED_FILE_DIFF));
+ assertThat(normalizePatchForComparison(resultPatch))
+ .isEqualTo(normalizePatchForComparison(ADDED_FILE_DIFF));
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 96c86d4..ec474f1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -159,6 +159,7 @@
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.git.ObjectIds;
import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
import com.google.gerrit.index.IndexConfig;
import com.google.gerrit.index.query.PostFilterPredicate;
@@ -3610,10 +3611,12 @@
+ "add_non_author_approval(S1,"
+ " [label('Non-Author-Code-Review', need(_)) | S1]).")
.to(RefNames.REFS_CONFIG);
- pushResult.assertErrorStatus();
+ pushResult.assertOkStatus();
pushResult.assertMessage(
- "Uploading a new 'rules.pl' file is not allowed."
- + " Please add submit-requirements instead.");
+ String.format(
+ "WARNING: commit %s: Uploading a new 'rules.pl' file is discouraged. "
+ + "Please consider adding submit-requirements instead.",
+ ObjectIds.abbreviateName(pushResult.getCommit())));
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 5ecb5a7..ade7dc6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -663,6 +663,87 @@
assertThat(r1.getPatchSetId().get()).isEqualTo(3);
}
+ private void rebaseWithConflict_strategy(String strategy) throws Exception {
+ String patchSetSubject = "patch set change";
+ String patchSetContent = "patch set content";
+ String baseSubject = "base change";
+ String baseContent = "base content";
+ String expectedContent = strategy.equals("theirs") ? baseContent : patchSetContent;
+
+ PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+ gApi.changes()
+ .id(r1.getChangeId())
+ .revision(r1.getCommit().name())
+ .review(ReviewInput.approve());
+ gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+ testRepo.reset("HEAD~1");
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ patchSetSubject,
+ PushOneCommit.FILE_NAME,
+ patchSetContent);
+ PushOneCommit.Result r2 = push.to("refs/for/master");
+ r2.assertOkStatus();
+
+ String changeId = r2.getChangeId();
+ RevCommit patchSet = r2.getCommit();
+ RevCommit base = r1.getCommit();
+
+ TestWorkInProgressStateChangedListener wipStateChangedListener =
+ new TestWorkInProgressStateChangedListener();
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.strategy = strategy;
+
+ testMetricMaker.reset();
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+ assertThat(changeInfo.containsGitConflicts).isNull();
+ assertThat(changeInfo.workInProgress).isNull();
+
+ // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false))
+ .isEqualTo(1);
+ }
+ assertThat(wipStateChangedListener.invoked).isFalse();
+ assertThat(wipStateChangedListener.wip).isNull();
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit)
+ .isEqualTo(base.name());
+
+ // Verify that the file content in the created patch set is correct.
+ BinaryResult bin =
+ gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ assertThat(fileContent).isEqualTo(expectedContent);
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message)
+ .isEqualTo("Patch Set 2: Patch Set 1 was rebased");
+ }
+
+ @Test
+ public void rebaseWithConflict_strategyAcceptTheirs() throws Exception {
+ rebaseWithConflict_strategy("theirs");
+ }
+
+ @Test
+ public void rebaseWithConflict_strategyAcceptOurs() throws Exception {
+ rebaseWithConflict_strategy("ours");
+ }
+
@Test
public void rebaseWithConflict_conflictsAllowed() throws Exception {
String patchSetSubject = "patch set change";
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
index 6e67d5f..e13661e 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -87,6 +87,35 @@
}
@Test
+ public void pluginConfig_inheritanceCanOverrideValuesAndKeepsRest() throws Exception {
+ try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(allProjects)) {
+ u.getConfig()
+ .updatePluginConfig(
+ "important-plugin2",
+ cfg -> {
+ cfg.setString("key", "kept");
+ cfg.setString("key2", "my-plugin-value2");
+ });
+ u.save();
+ }
+
+ try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(project)) {
+ u.getConfig()
+ .updatePluginConfig(
+ "important-plugin2",
+ cfg -> {
+ cfg.setString("key2", "overridden");
+ });
+ u.save();
+ }
+
+ PluginConfig pluginConfig =
+ pluginConfigFactory.getFromProjectConfigWithInheritance(project, "important-plugin2");
+ assertThat(pluginConfig.getString("key")).isEqualTo("kept");
+ assertThat(pluginConfig.getString("key2")).isEqualTo("overridden");
+ }
+
+ @Test
public void allProjectsProjectsConfig_ChangeInFileInvalidatesPersistedCache() throws Exception {
assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isTrue();
// Change etc/All-Projects-project.config
diff --git a/modules/jgit b/modules/jgit
index 5ae8d28..74fa245 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 5ae8d28faaf6168921f673c89a4e6d601ffad78d
+Subproject commit 74fa245b3c3ccf13afcbec7911c7c8459e48527d
diff --git a/plugins/delete-project b/plugins/delete-project
index 79674d9..0322a37 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 79674d9e00f8458a2f4f6d3a91bd032579c3f25c
+Subproject commit 0322a37009071da525bfd8569e98538c2e8891d5
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 6df4456..925820c 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -180,9 +180,9 @@
"**/*_test.ts",
],
) + [
+ "@npm//typescript",
"@ui_dev_npm//:node_modules",
"@ui_npm//:node_modules",
- "@npm//typescript",
],
)
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 4bf253d..b968553 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -302,11 +302,15 @@
code_range: LineRange;
}
-/** LOST LineNumber is for ported comments without a range, they have their own
- * line number and are added on top of the FILE row in gr-diff
+/**
+ * LOST LineNumber is for ported comments without a range, they have their own
+ * line number and are added on top of the FILE row in <gr-diff>.
*/
export declare type LineNumber = number | 'FILE' | 'LOST';
+export const FILE: LineNumber = 'FILE';
+export const LOST: LineNumber = 'LOST';
+
/** The detail of the 'create-comment' event dispatched by gr-diff. */
export declare interface CreateCommentEventDetail {
side: Side;
@@ -360,6 +364,7 @@
export declare interface DiffContextExpandedExternalDetail {
expandedLines: number;
buttonType: ContextButtonType;
+ numLines: number;
}
/**
diff --git a/polygerrit-ui/app/api/package-lock.json b/polygerrit-ui/app/api/package-lock.json
new file mode 100644
index 0000000..75fb4dc
--- /dev/null
+++ b/polygerrit-ui/app/api/package-lock.json
@@ -0,0 +1,5 @@
+{
+ "name": "@gerritcodereview/typescript-api",
+ "version": "3.8.0",
+ "lockfileVersion": 1
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 7f03d1d..ff7a528 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -127,8 +127,9 @@
}
override willUpdate(changedProperties: PropertyValues<GrPermission>): void {
- if (changedProperties.has('editing')) {
- this.handleEditingChanged(changedProperties.get('editing'));
+ const oldEditing = changedProperties.get('editing');
+ if (oldEditing !== null && oldEditing !== undefined) {
+ this.handleEditingChanged(oldEditing);
}
if (
changedProperties.has('permission') ||
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index e0e1364..93ef3e3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -473,7 +473,7 @@
});
test('Push Certificate Validation test BAD', () => {
- change!.revisions.rev1!.push_certificate = {
+ change!.revisions.rev1.push_certificate = {
certificate: 'Push certificate',
key: {
status: GpgKeyInfoStatus.BAD,
@@ -493,7 +493,7 @@
});
test('Push Certificate Validation test TRUSTED', () => {
- change!.revisions.rev1!.push_certificate = {
+ change!.revisions.rev1.push_certificate = {
certificate: 'Push certificate',
key: {
status: GpgKeyInfoStatus.TRUSTED,
@@ -531,7 +531,7 @@
});
test('isEnabledSignedPushOnRepo', () => {
- change!.revisions.rev1!.push_certificate = {
+ change!.revisions.rev1.push_certificate = {
certificate: 'Push certificate',
key: {
status: GpgKeyInfoStatus.TRUSTED,
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 02fdb34..d4d1729 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -2253,8 +2253,10 @@
private async reportChangeDisplayed() {
await waitUntil(() => !!this.metadata);
await untilRendered(this.metadata!);
- await waitUntil(() => !!this.fileList);
- await untilRendered(this.fileList!);
+ if (this.activeTab === Tab.FILES) {
+ await waitUntil(() => !!this.fileList);
+ await untilRendered(this.fileList!);
+ }
await waitUntil(() => !!this.messagesList);
await untilRendered(this.messagesList!);
// We are ending the timer after each change view update, because ending a
@@ -2267,8 +2269,10 @@
private async reportFullyLoaded() {
await waitUntil(() => !!this.metadata);
await untilRendered(this.metadata!);
- await waitUntil(() => !!this.fileList);
- await untilRendered(this.fileList!);
+ if (this.activeTab === Tab.FILES) {
+ await waitUntil(() => !!this.fileList);
+ await untilRendered(this.fileList!);
+ }
await waitUntil(() => !!this.messagesList);
await untilRendered(this.messagesList!);
await waitUntil(() => this.mergeable !== undefined);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index fd3ddac..adea275 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -3,7 +3,7 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
import '../../diff/gr-patch-range-select/gr-patch-range-select';
import '../../edit/gr-edit-controls/gr-edit-controls';
import '../../shared/gr-select/gr-select';
@@ -23,7 +23,7 @@
PatchSetNumber,
} from '../../../types/common';
import {DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
import {GrButton} from '../../shared/gr-button/gr-button';
import {fire, fireNoBubbleNoCompose} from '../../../utils/event-util';
import {css, html, LitElement, nothing} from 'lit';
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 9665b03..24f5932 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -5,7 +5,6 @@
*/
import '../../../styles/gr-a11y-styles';
import '../../../styles/shared-styles';
-import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
import '../../diff/gr-diff-host/gr-diff-host';
import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
@@ -48,6 +47,7 @@
import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor as GrDiffCursorNew} from '../../../embed/diff-new/gr-diff-cursor/gr-diff-cursor';
import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
@@ -86,6 +86,7 @@
import {userModelToken} from '../../../models/user/user-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {FileMode, fileModeToString} from '../../../utils/file-util';
+import {isNewDiff} from '../../../embed/diff/gr-diff/gr-diff-utils';
export const DEFAULT_NUM_FILES_SHOWN = 200;
@@ -316,7 +317,8 @@
fileCursor = new GrCursorManager();
// private but used in test
- diffCursor?: GrDiffCursor;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ diffCursor?: GrDiffCursor | GrDiffCursorNew;
static override get styles() {
return [
@@ -904,7 +906,8 @@
);
}
});
- this.diffCursor = new GrDiffCursor();
+ // TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+ this.diffCursor = isNewDiff() ? new GrDiffCursorNew() : new GrDiffCursor();
this.diffCursor.replaceDiffs(this.diffs);
}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 939ad06..daf0891 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -57,6 +57,8 @@
import {Modifier} from '../../../utils/dom-util';
import {testResolver} from '../../../test/common-test-setup';
import {FileMode} from '../../../utils/file-util';
+import {SinonStubbedMember} from 'sinon';
+import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
suite('gr-diff a11y test', () => {
test('audit', async () => {
@@ -2152,7 +2154,7 @@
suite('n key presses', () => {
let nextCommentStub: sinon.SinonStub;
- let nextChunkStub: sinon.SinonStub;
+ let nextChunkStub: SinonStubbedMember<GrDiffCursor['moveToNextChunk']>;
let fileRows: NodeListOf<HTMLDivElement>;
setup(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index d5da9c9..3c2b792 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -21,14 +21,14 @@
PatchSetNum,
VotingRangeInfo,
isRobot,
- EDIT,
- PARENT,
+ PatchSetNumber,
} from '../../../types/common';
import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
import {getVotingRange} from '../../../utils/label-util';
import {
FormattedReviewerUpdateInfo,
ParsedChangeInfo,
+ isPatchSetNumber,
} from '../../../types/types';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {changeModelToken} from '../../../models/change/change-model';
@@ -157,27 +157,17 @@
message: CombinedMessage,
allMessages: CombinedMessage[]
): PatchSetNum | undefined {
- if (
- message._revision_number !== undefined &&
- message._revision_number !== 0 &&
- message._revision_number !== PARENT &&
- message._revision_number !== EDIT
- ) {
+ if (isPatchSetNumber(message._revision_number)) {
return message._revision_number;
}
- let revision: PatchSetNum = 0 as PatchSetNum;
+ let revision: PatchSetNumber | undefined = undefined;
for (const m of allMessages) {
if (m.date > message.date) break;
- if (
- m._revision_number !== undefined &&
- m._revision_number !== 0 &&
- m._revision_number !== PARENT &&
- m._revision_number !== EDIT
- ) {
+ if (isPatchSetNumber(m._revision_number)) {
revision = m._revision_number;
}
}
- return revision > 0 ? revision : undefined;
+ return revision;
}
/**
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 db19329..3bc2770 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
@@ -25,6 +25,8 @@
import {nothing} from 'lit';
import {fire} from '../../../utils/event-util';
import {ShowReplyDialogEvent} from '../../../types/events';
+import {repeat} from 'lit/directives/repeat.js';
+import {accountKey} from '../../../utils/account-util';
@customElement('gr-reviewer-list')
export class GrReviewerList extends LitElement {
@@ -102,8 +104,10 @@
return html`
<div class="container">
<div>
- ${this.displayedReviewers.map(reviewer =>
- this.renderAccountChip(reviewer)
+ ${repeat(
+ this.displayedReviewers,
+ reviewer => accountKey(reviewer),
+ reviewer => this.renderAccountChip(reviewer)
)}
<div class="controlsContainer" ?hidden=${!this.mutable}>
<gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 75845f6..5c40050 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -96,7 +96,7 @@
return specialFilePathCompare(c1.path, c2.path);
}
- // Convert 'FILE' and 'LOST' to undefined.
+ // Convert FILE and LOST to undefined.
const line1 = typeof c1.line === 'number' ? c1.line : undefined;
const line2 = typeof c2.line === 'number' ? c2.line : undefined;
if (line1 !== line2) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
index a4357bb..3a06f8d 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -41,6 +41,7 @@
import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
import {fixture, html, assert} from '@open-wc/testing';
import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {FILE} from '../../../api/diff';
suite('gr-thread-list tests', () => {
let element: GrThreadList;
@@ -665,7 +666,7 @@
test('file level comment before line', () => {
t1.line = 123;
- t2.line = 'FILE';
+ t2.line = FILE;
checkOrder([t2, t1]);
});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 0f3810a..d778546 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -47,6 +47,7 @@
otherPrimaryLinks,
secondaryLinks,
tooltipForLink,
+ computeIsExpandable,
} from '../../models/checks/checks-util';
import {assertIsDefined, assert, unique} from '../../utils/common-util';
import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
@@ -322,20 +323,12 @@
];
}
- override updated(changedProperties: PropertyValues) {
+ override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('result')) {
- this.isExpandable = this.computeIsExpandable();
+ this.isExpandable = computeIsExpandable(this.result);
}
}
- private computeIsExpandable() {
- const hasSummary = !!this.result?.summary;
- const hasMessage = !!this.result?.message;
- const hasMultipleLinks = (this.result?.links ?? []).length > 1;
- const hasPointers = (this.result?.codePointers ?? []).length > 0;
- return hasSummary && (hasMessage || hasMultipleLinks || hasPointers);
- }
-
override focus() {
if (this.nameEl) this.nameEl.focus();
}
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index efc6efe..1999e1f 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -9,6 +9,7 @@
import {customElement, property, state} from 'lit/decorators.js';
import {RunResult} from '../../models/checks/checks-model';
import {
+ computeIsExpandable,
createFixAction,
createPleaseFixComment,
iconFor,
@@ -244,9 +245,9 @@
`;
}
- override updated(changedProperties: PropertyValues) {
+ override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('result')) {
- this.isExpandable = !!this.result?.summary && !!this.result?.message;
+ this.isExpandable = computeIsExpandable(this.result);
}
}
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 0377e0e..51ee41d 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -45,6 +45,13 @@
There is a lot to be said. A lot. I say, a lot.
So please keep reading.
</div>
+ <div aria-checked="false"
+ aria-label="Expand result row"
+ class="show-hide"
+ role="switch"
+ tabindex="0">
+ <gr-icon icon="expand_more"></gr-icon>
+ </div>
</div>
<div class="details"></div>
</div>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index aa6bb7a4..6892188 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -96,7 +96,7 @@
getPatchRangeForCommentUrl,
isInBaseOfPatchRange,
} from '../../../utils/comment-util';
-import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {isFileUnchanged} from '../../../utils/diff-util';
import {Route, ViewState} from '../../../models/views/base';
import {Model} from '../../../models/model';
import {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 4f1ce7a..8e30143 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -7,6 +7,7 @@
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-icon/gr-icon';
import '../../../embed/diff/gr-diff/gr-diff';
+import '../../../embed/diff-new/gr-diff/gr-diff';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {
NumericChangeId,
@@ -35,7 +36,7 @@
import {modalStyles} from '../../../styles/gr-modal-styles';
import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
import {highlightServiceToken} from '../../../services/highlight/highlight-service';
-import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {anyLineTooLong} from '../../../utils/diff-util';
import {fireReload} from '../../../utils/event-util';
import {when} from 'lit/directives/when.js';
import {Timing} from '../../../constants/reporting';
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 2ccae8d..2aae2ea 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
@@ -6,13 +6,12 @@
import '../../shared/gr-comment-thread/gr-comment-thread';
import '../../checks/gr-diff-check-result';
import '../../../embed/diff/gr-diff/gr-diff';
+import '../../../embed/diff-new/gr-diff/gr-diff';
import {
anyLineTooLong,
getDiffLength,
- getLine,
- getSide,
SYNTAX_MAX_LINE_LENGTH,
-} from '../../../embed/diff/gr-diff/gr-diff-utils';
+} from '../../../utils/diff-util';
import {getAppContext} from '../../../services/app-context';
import {
getParentIndex,
@@ -47,13 +46,10 @@
IgnoreWhitespaceType,
WebLinkInfo,
} from '../../../types/diff';
-import {
- CreateCommentEventDetail,
- GrDiff,
-} from '../../../embed/diff/gr-diff/gr-diff';
+import {GrDiff} from '../../../embed/diff/gr-diff/gr-diff';
+import {GrDiff as GrDiffNew} from '../../../embed/diff-new/gr-diff/gr-diff';
import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
-import {LineNumber, FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
import {KnownExperimentId} from '../../../services/flags/flags';
import {
@@ -64,14 +60,18 @@
waitForEventOnce,
} from '../../../utils/event-util';
import {assertIsDefined} from '../../../utils/common-util';
-import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
import {Timing} from '../../../constants/reporting';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
import {Subscription} from 'rxjs';
import {
+ CreateCommentEventDetail,
+ DiffContextExpandedExternalDetail,
DisplayLine,
+ FILE,
+ LineNumber,
LineSelectedEventDetail,
+ LOST,
RenderPreferences,
} from '../../../api/diff';
import {resolve} from '../../../models/dependency';
@@ -125,7 +125,6 @@
interface HTMLElementEventMap {
// prettier-ignore
'render': CustomEvent<{}>;
- 'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
'create-comment': CustomEvent<CreateCommentEventDetail>;
'is-blame-loaded-changed': ValueChangedEvent<boolean>;
'diff-changed': ValueChangedEvent<DiffInfo | undefined>;
@@ -148,8 +147,9 @@
*/
@customElement('gr-diff-host')
export class GrDiffHost extends LitElement {
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
@query('#diff')
- diffElement?: GrDiff;
+ diffElement?: GrDiff | GrDiffNew;
@property({type: Number})
changeNum?: NumericChangeId;
@@ -754,7 +754,7 @@
const pointer = check.codePointers?.[0];
assertIsDefined(pointer, 'code pointer of check result in diff');
const line: LineNumber =
- pointer.range?.end_line || pointer.range?.start_line || 'FILE';
+ pointer.range?.end_line || pointer.range?.start_line || FILE;
const el = document.createElement('gr-diff-check-result');
// This is what gr-diff expects, even though this is a check, not a comment.
el.className = 'comment-thread';
@@ -908,11 +908,6 @@
);
}
- addDraftAtLine(el: Element) {
- assertIsDefined(this.diffElement);
- this.diffElement.addDraftAtLine(el);
- }
-
clearDiffContent() {
this.diffElement?.clearDiffContent();
}
@@ -1212,53 +1207,15 @@
threadEl.showPortedComment = !!thread.ported;
// These attributes are the "interface" between comment threads and gr-diff.
// <gr-comment-thread> does not care about them and is not affected by them.
- threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`);
+ threadEl.setAttribute('slot', `${diffSide}-${thread.line || LOST}`);
threadEl.setAttribute('diff-side', `${diffSide}`);
- threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`);
+ threadEl.setAttribute('line-num', `${thread.line || LOST}`);
if (thread.range) {
threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`);
}
return threadEl;
}
- // Private but used in tests.
- filterThreadElsForLocation(
- threadEls: GrCommentThread[],
- lineInfo: LineInfo,
- side: Side
- ) {
- function matchesLeftLine(threadEl: GrCommentThread) {
- return (
- getSide(threadEl) === Side.LEFT &&
- getLine(threadEl) === lineInfo.beforeNumber
- );
- }
- function matchesRightLine(threadEl: GrCommentThread) {
- return (
- getSide(threadEl) === Side.RIGHT &&
- getLine(threadEl) === lineInfo.afterNumber
- );
- }
- function matchesFileComment(threadEl: GrCommentThread) {
- return getSide(threadEl) === side && getLine(threadEl) === FILE;
- }
-
- // Select the appropriate matchers for the desired side and line
- const matchers: ((thread: GrCommentThread) => boolean)[] = [];
- if (side === Side.LEFT) {
- matchers.push(matchesLeftLine);
- }
- if (side === Side.RIGHT) {
- matchers.push(matchesRightLine);
- }
- if (lineInfo.afterNumber === FILE || lineInfo.beforeNumber === FILE) {
- matchers.push(matchesFileComment);
- }
- return threadEls.filter(threadEl =>
- matchers.some(matcher => matcher(threadEl))
- );
- }
-
private getIgnoreWhitespace(): IgnoreWhitespaceType {
if (!this.prefs || !this.prefs.ignore_whitespace) {
return 'IGNORE_NONE';
@@ -1338,7 +1295,7 @@
}
private handleDiffContextExpanded(
- e: CustomEvent<DiffContextExpandedEventDetail>
+ e: CustomEvent<DiffContextExpandedExternalDetail>
) {
this.reporting.reportInteraction('diff-context-expanded', {
numLines: e.detail.numLines,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 43045f7..0abcaf5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -43,8 +43,7 @@
UrlEncodedCommentId,
} from '../../../types/common';
import {CoverageType} from '../../../types/types';
-import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image';
-import {GrDiffHost, LineInfo} from './gr-diff-host';
+import {GrDiffHost} from './gr-diff-host';
import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
import {ErrorCallback} from '../../../api/rest';
import {SinonStub, SinonStubbedMember} from 'sinon';
@@ -318,10 +317,6 @@
// Recognizes that it should be an image diff.
assert.isTrue(element.isImageDiff);
assertIsDefined(element.diffElement);
- assert.instanceOf(
- element.diffElement.diffBuilder.builder,
- GrDiffBuilderImage
- );
// Left image rendered with the parent commit's version of the file.
assertIsDefined(element.diffElement);
@@ -393,10 +388,6 @@
// Recognizes that it should be an image diff.
assert.isTrue(element.isImageDiff);
assertIsDefined(element.diffElement);
- assert.instanceOf(
- element.diffElement.diffBuilder.builder,
- GrDiffBuilderImage
- );
// Left image rendered with the parent commit's version of the file.
assertIsDefined(element.diffElement.diffTable);
@@ -464,10 +455,6 @@
// Recognizes that it should be an image diff.
assert.isTrue(element.isImageDiff);
assertIsDefined(element.diffElement);
- assert.instanceOf(
- element.diffElement.diffBuilder.builder,
- GrDiffBuilderImage
- );
assertIsDefined(element.diffElement.diffTable);
const diffTable = element.diffElement.diffTable;
@@ -512,11 +499,6 @@
// Recognizes that it should be an image diff.
assert.isTrue(element.isImageDiff);
assertIsDefined(element.diffElement);
- assert.instanceOf(
- element.diffElement.diffBuilder.builder,
- GrDiffBuilderImage
- );
-
assertIsDefined(element.diffElement.diffTable);
const diffTable = element.diffElement.diffTable;
@@ -566,10 +548,6 @@
// Recognizes that it should be an image diff.
assert.isTrue(element.isImageDiff);
assertIsDefined(element.diffElement);
- assert.instanceOf(
- element.diffElement.diffBuilder.builder,
- GrDiffBuilderImage
- );
assertIsDefined(element.diffElement.diffTable);
const diffTable = element.diffElement.diffTable;
@@ -731,16 +709,6 @@
assert.deepEqual(element.getThreadEls(), [threadEl]);
});
- test('delegates addDraftAtLine(el)', () => {
- const param0 = document.createElement('b');
- assertIsDefined(element.diffElement);
- const stub = sinon.stub(element.diffElement, 'addDraftAtLine');
- element.addDraftAtLine(param0);
- assert.isTrue(stub.calledOnce);
- assert.equal(stub.lastCall.args.length, 1);
- assert.equal(stub.lastCall.args[0], param0);
- });
-
test('delegates clearDiffContent()', () => {
assertIsDefined(element.diffElement);
const stub = sinon.stub(element.diffElement, 'clearDiffContent');
@@ -1299,71 +1267,6 @@
});
});
- test('filterThreadElsForLocation with no threads', () => {
- const line = {beforeNumber: 3, afterNumber: 5};
- const threads: GrCommentThread[] = [];
- assert.deepEqual(
- element.filterThreadElsForLocation(threads, line, Side.LEFT),
- []
- );
- assert.deepEqual(
- element.filterThreadElsForLocation(threads, line, Side.RIGHT),
- []
- );
- });
-
- test('filterThreadElsForLocation for line comments', () => {
- const line = {beforeNumber: 3, afterNumber: 5};
-
- const l3 = document.createElement('gr-comment-thread');
- l3.setAttribute('line-num', '3');
- l3.setAttribute('diff-side', Side.LEFT);
-
- const l5 = document.createElement('gr-comment-thread');
- l5.setAttribute('line-num', '5');
- l5.setAttribute('diff-side', Side.LEFT);
-
- const r3 = document.createElement('gr-comment-thread');
- r3.setAttribute('line-num', '3');
- r3.setAttribute('diff-side', Side.RIGHT);
-
- const r5 = document.createElement('gr-comment-thread');
- r5.setAttribute('line-num', '5');
- r5.setAttribute('diff-side', Side.RIGHT);
-
- const threadEls: GrCommentThread[] = [l3, l5, r3, r5];
- assert.deepEqual(
- element.filterThreadElsForLocation(threadEls, line, Side.LEFT),
- [l3]
- );
- assert.deepEqual(
- element.filterThreadElsForLocation(threadEls, line, Side.RIGHT),
- [r5]
- );
- });
-
- test('filterThreadElsForLocation for file comments', () => {
- const line: LineInfo = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
- const l = document.createElement('gr-comment-thread');
- l.setAttribute('diff-side', Side.LEFT);
- l.setAttribute('line-num', 'FILE');
-
- const r = document.createElement('gr-comment-thread');
- r.setAttribute('diff-side', Side.RIGHT);
- r.setAttribute('line-num', 'FILE');
-
- const threadEls: GrCommentThread[] = [l, r];
- assert.deepEqual(
- element.filterThreadElsForLocation(threadEls, line, Side.LEFT),
- [l]
- );
- assert.deepEqual(
- element.filterThreadElsForLocation(threadEls, line, Side.RIGHT),
- [r]
- );
- });
-
suite('syntax layer with syntax_highlighting on', async () => {
setup(async () => {
const prefs = {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
similarity index 95%
rename from polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
rename to polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index a9bdab8..1d46841 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -5,8 +5,8 @@
*/
import {Subscription} from 'rxjs';
import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import '../../../elements/shared/gr-button/gr-button';
-import '../../../elements/shared/gr-icon/gr-icon';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
import {DiffViewMode} from '../../../constants/constants';
import {customElement, property, state} from 'lit/decorators.js';
import {fireIronAnnounce} from '../../../utils/event-util';
@@ -15,7 +15,7 @@
import {css, html, LitElement} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
import {userModelToken} from '../../../models/user/user-model';
-import {ironAnnouncerRequestAvailability} from '../../../elements/polymer-util';
+import {ironAnnouncerRequestAvailability} from '../../polymer-util';
@customElement('gr-diff-mode-selector')
export class GrDiffModeSelector extends LitElement {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
similarity index 98%
rename from polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
rename to polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index d646988..0b6a5b0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -16,7 +16,7 @@
} from '../../../models/browser/browser-model';
import {UserModel, userModelToken} from '../../../models/user/user-model';
import {createPreferences} from '../../../test/test-data-generators';
-import {GrButton} from '../../../elements/shared/gr-button/gr-button';
+import {GrButton} from '../../shared/gr-button/gr-button';
import {testResolver} from '../../../test/common-test-setup';
suite('gr-diff-mode-selector tests', () => {
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
index 0f54ad1..7ab8c2c 100644
--- 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
@@ -14,10 +14,9 @@
import '../../shared/gr-select/gr-select';
import '../../shared/gr-weblink/gr-weblink';
import '../../shared/revision-info/revision-info';
-import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
import '../gr-diff-host/gr-diff-host';
-import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+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 '../../change/gr-download-dialog/gr-download-dialog';
@@ -55,6 +54,7 @@
PatchRangeChangeEvent,
} from '../gr-patch-range-select/gr-patch-range-select';
import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor as GrDiffCursorNew} from '../../../embed/diff-new/gr-diff-cursor/gr-diff-cursor';
import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events';
@@ -96,6 +96,7 @@
FileNameToNormalizedFileInfoMap,
filesModelToken,
} from '../../../models/change/files-model';
+import {isNewDiff} from '../../../embed/diff/gr-diff/gr-diff-utils';
const LOADING_BLAME = 'Loading blame...';
const LOADED_BLAME = 'Blame loaded';
@@ -240,8 +241,9 @@
private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
@state()
- cursor?: GrDiffCursor;
+ cursor?: GrDiffCursor | GrDiffCursorNew;
private readonly shortcutsController = new ShortcutController(this);
@@ -658,7 +660,8 @@
this.handleToggleFileReviewed()
);
this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
- this.cursor = new GrDiffCursor();
+ // TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+ this.cursor = isNewDiff() ? new GrDiffCursorNew() : new GrDiffCursor();
if (this.diffHost) this.reInitCursor();
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 737e964..6896ca8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -57,7 +57,7 @@
LoadingStatus,
} from '../../../models/change/change-model';
import {assertIsDefined} from '../../../utils/common-util';
-import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
import {fixture, html, assert} from '@open-wc/testing';
import {GrButton} from '../../shared/gr-button/gr-button';
import {testResolver} from '../../../test/common-test-setup';
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 8339df9..07209db 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -36,6 +36,7 @@
import {PaperInputElement} from '@polymer/paper-input/paper-input';
import {IronInputElement} from '@polymer/iron-input';
import {ReviewerState} from '../../../api/rest-api';
+import {repeat} from 'lit/directives/repeat.js';
const VALID_EMAIL_ALERT = 'Please input a valid email.';
const VALID_USER_GROUP_ALERT = 'Please input a valid user or group.';
@@ -186,7 +187,9 @@
override render() {
return html`<div class="list">
- ${this.accounts.map(
+ ${repeat(
+ this.accounts,
+ account => getUserId(account),
account => html`
<gr-account-chip
.account=${account}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index c76f04c..64dff29 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -8,6 +8,7 @@
import '../gr-comment/gr-comment';
import '../gr-icon/gr-icon';
import '../../../embed/diff/gr-diff/gr-diff';
+import '../../../embed/diff-new/gr-diff/gr-diff';
import '../gr-copy-clipboard/gr-copy-clipboard';
import {css, html, nothing, LitElement, PropertyValues} from 'lit';
import {
@@ -44,10 +45,9 @@
UrlEncodedCommentId,
} from '../../../types/common';
import {CommentEditingChangedDetail, GrComment} from '../gr-comment/gr-comment';
-import {FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
import {GrButton} from '../gr-button/gr-button';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {DiffLayer, RenderPreferences} from '../../../api/diff';
+import {DiffLayer, FILE, RenderPreferences} from '../../../api/diff';
import {
assert,
assertIsDefined,
@@ -56,7 +56,7 @@
import {fire} from '../../../utils/event-util';
import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {anyLineTooLong} from '../../../utils/diff-util';
import {getUserName} from '../../../utils/display-name-util';
import {generateAbsoluteUrl} from '../../../utils/url-util';
import {sharedStyles} from '../../../styles/shared-styles';
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 5947cc3..74c5806 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -55,7 +55,7 @@
import {subscribe} from '../../lit/subscription-controller';
import {ShortcutController} from '../../lit/shortcut-controller';
import {classMap} from 'lit/directives/class-map.js';
-import {LineNumber} from '../../../api/diff';
+import {FILE, LineNumber} from '../../../api/diff';
import {CommentSide, SpecialFilePath} from '../../../constants/constants';
import {Subject} from 'rxjs';
import {debounceTime} from 'rxjs/operators';
@@ -66,8 +66,6 @@
import {userModelToken} from '../../../models/user/user-model';
import {modalStyles} from '../../../styles/gr-modal-styles';
-const FILE = 'FILE';
-
// visible for testing
export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
@@ -661,7 +659,7 @@
private renderDateInner() {
if (isError(this.comment)) return 'Error';
- if (isSaving(this.comment)) return 'Saving';
+ if (isSaving(this.comment) && !this.autoSaving) return 'Saving';
if (isNew(this.comment)) return 'New';
return html`
<gr-date-formatter
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index 4977ec5..70e653a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -10,6 +10,7 @@
import {fixture, html, assert} from '@open-wc/testing';
import {PluginApi} from '../../../api/plugin';
import {
+ ActionPriority,
ActionType,
ChangeActionsPluginApi,
PrimaryActionKey,
@@ -169,7 +170,11 @@
let buttons = queryAll<GrButton>(element, '[data-action-key]');
assert.equal(buttons[0].getAttribute('data-action-key'), key1);
assert.equal(buttons[1].getAttribute('data-action-key'), key2);
- changeActions.setActionPriority(ActionType.REVISION, key1, 10);
+ changeActions.setActionPriority(
+ ActionType.REVISION,
+ key1,
+ ActionPriority.PRIMARY
+ );
await element.updateComplete;
buttons = queryAll<GrButton>(element, '[data-action-key]');
assert.equal(buttons[0].getAttribute('data-action-key'), key2);
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 386a166..46c759b 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
@@ -7,6 +7,7 @@
import {
ChangeInfo,
LabelNameToValueMap,
+ PARENT,
ReviewInput,
RevisionInfo,
} from '../../../types/common';
@@ -98,20 +99,22 @@
return detail.info && detail.info.mergeable;
},
};
- const patchNum = detail.patchNum;
- const info = detail.info;
+ const {patchNum, info, basePatchNum} = detail;
let revision;
+ let baseRevision;
for (const rev of Object.values(change.revisions || {})) {
if (rev._number === patchNum) {
revision = rev;
- break;
+ }
+ if (rev._number === basePatchNum) {
+ baseRevision = rev;
}
}
for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
try {
- cb(change, revision, info);
+ cb(change, revision, info, baseRevision ?? PARENT);
} catch (err: unknown) {
this.reporting.error(
'GrJsApiInterface',
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index a10beab..8e3a87d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -6,6 +6,7 @@
import {
ActionInfo,
ChangeInfo,
+ BasePatchSetNum,
PatchSetNum,
ReviewInput,
RevisionInfo,
@@ -17,6 +18,7 @@
export interface ShowChangeDetail {
change?: ParsedChangeInfo;
+ basePatchNum?: BasePatchSetNum;
patchNum?: PatchSetNum;
info: {mergeable: boolean | null};
}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls-section.ts
new file mode 100644
index 0000000..81fba83
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls-section.ts
@@ -0,0 +1,140 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-button/gr-button';
+import {html, LitElement} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {diffClasses, isNewDiff} from '../../diff/gr-diff/gr-diff-utils';
+import {getShowConfig} from './gr-context-controls';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+
+export class GrContextControlsSection extends LitElement {
+ /** Should context controls be rendered for expanding above the section? */
+ @property({type: Boolean}) showAbove = false;
+
+ /** Should context controls be rendered for expanding below the section? */
+ @property({type: Boolean}) showBelow = false;
+
+ /** Must be of type GrDiffGroupType.CONTEXT_CONTROL. */
+ @property({type: Object})
+ group?: GrDiffGroup;
+
+ @property({type: Object})
+ diff?: DiffInfo;
+
+ @property({type: Object})
+ renderPrefs?: RenderPreferences;
+
+ /**
+ * Semantic DOM diff testing does not work with just table fragments, so when
+ * running such tests the render() method has to wrap the DOM in a proper
+ * <table> element.
+ */
+ @state()
+ addTableWrapperForTesting = false;
+
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ private renderPaddingRow(whereClass: 'above' | 'below') {
+ if (!this.showAbove && whereClass === 'above') return;
+ if (!this.showBelow && whereClass === 'below') return;
+ const modeClass = this.isSideBySide() ? 'side-by-side' : 'unified';
+ const type = this.isSideBySide()
+ ? GrDiffGroupType.CONTEXT_CONTROL
+ : undefined;
+ return html`
+ <tr
+ class=${diffClasses('contextBackground', modeClass, whereClass)}
+ left-type=${ifDefined(type)}
+ right-type=${ifDefined(type)}
+ >
+ <td class=${diffClasses('blame')} data-line-number="0"></td>
+ <td class=${diffClasses('contextLineNum')}></td>
+ ${when(
+ this.isSideBySide(),
+ () => html`
+ <td class=${diffClasses('sign')}></td>
+ <td class=${diffClasses()}></td>
+ `
+ )}
+ <td class=${diffClasses('contextLineNum')}></td>
+ ${when(
+ this.isSideBySide(),
+ () => html`<td class=${diffClasses('sign')}></td>`
+ )}
+ <td class=${diffClasses()}></td>
+ </tr>
+ `;
+ }
+
+ private isSideBySide() {
+ return this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED;
+ }
+
+ private createContextControlRow() {
+ // Note that <td> table cells that have `display: none` don't count!
+ const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+ const showConfig = getShowConfig(this.showAbove, this.showBelow);
+ return html`
+ <tr class=${diffClasses('dividerRow', `show-${showConfig}`)}>
+ <td class=${diffClasses('blame')} data-line-number="0"></td>
+ ${when(
+ this.isSideBySide(),
+ () => html`<td class=${diffClasses()}></td>`
+ )}
+ <td class=${diffClasses('dividerCell')} colspan=${colspan}>
+ <gr-context-controls
+ class=${diffClasses()}
+ .diff=${this.diff}
+ .renderPreferences=${this.renderPrefs}
+ .group=${this.group}
+ .showConfig=${showConfig}
+ >
+ </gr-context-controls>
+ </td>
+ </tr>
+ `;
+ }
+
+ override render() {
+ const rows = html`
+ ${this.renderPaddingRow('above')} ${this.createContextControlRow()}
+ ${this.renderPaddingRow('below')}
+ `;
+ if (this.addTableWrapperForTesting) {
+ return html`<table>
+ ${rows}
+ </table>`;
+ }
+ return rows;
+ }
+}
+
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+ customElements.define(
+ 'gr-context-controls-section',
+ GrContextControlsSection
+ );
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-context-controls-section': LitElement;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls-section_test.ts
new file mode 100644
index 0000000..5ac57e7
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls-section_test.ts
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+
+import './gr-context-controls-section';
+import {GrContextControlsSection} from './gr-context-controls-section';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-context-controls-section test', () => {
+ let element: GrContextControlsSection;
+
+ setup(async () => {
+ element = await fixture<GrContextControlsSection>(
+ html`<gr-context-controls-section></gr-context-controls-section>`
+ );
+ element.addTableWrapperForTesting = true;
+ await element.updateComplete;
+ });
+
+ test('render: normal with showAbove and showBelow', async () => {
+ element.showAbove = true;
+ element.showBelow = true;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ class="above contextBackground gr-diff side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ <tr class="dividerRow gr-diff show-both">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff"></td>
+ <td class="dividerCell gr-diff" colspan="3">
+ <gr-context-controls class="gr-diff" showconfig="both">
+ </gr-context-controls>
+ </td>
+ </tr>
+ <tr
+ class="below contextBackground gr-diff side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ </tbody>
+ </table>
+ `
+ );
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls.ts
new file mode 100644
index 0000000..0ea6490
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls.ts
@@ -0,0 +1,529 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '@polymer/paper-button/paper-button';
+import '@polymer/paper-card/paper-card';
+import '@polymer/paper-checkbox/paper-checkbox';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import '@polymer/paper-tooltip/paper-tooltip';
+import {of, EMPTY, Subject} from 'rxjs';
+import {switchMap, delay} from 'rxjs/operators';
+
+import '../../../elements/shared/gr-button/gr-button';
+import {pluralize} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {DiffInfo} from '../../../types/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {css, html, LitElement, TemplateResult} from 'lit';
+import {property} from 'lit/decorators.js';
+import {subscribe} from '../../../elements/lit/subscription-controller';
+
+import {
+ ContextButtonType,
+ DiffContextButtonHoveredDetail,
+ RenderPreferences,
+ SyntaxBlock,
+} from '../../../api/diff';
+
+import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+import {isNewDiff} from '../../diff/gr-diff/gr-diff-utils';
+
+declare global {
+ interface HTMLElementEventMap {
+ 'diff-context-button-hovered': CustomEvent<DiffContextButtonHoveredDetail>;
+ }
+}
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+/**
+ * Traverses a hierarchical structure of syntax blocks and
+ * finds the most local/nested block that can be associated line.
+ * It finds the closest block that contains the whole line and
+ * returns the whole path from the syntax layer (blocks) sent as parameter
+ * to the most nested block - the complete path from the top to bottom layer of
+ * a syntax tree. Example: [myNamespace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
+ *
+ * @param lineNum line number for the targeted line.
+ * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
+ */
+function findBlockTreePathForLine(
+ lineNum: number,
+ blocks?: SyntaxBlock[]
+): SyntaxBlock[] {
+ const containingBlock = blocks?.find(
+ ({range}) => range.start_line < lineNum && range.end_line > lineNum
+ );
+ if (!containingBlock) return [];
+ const innerPathInChild = findBlockTreePathForLine(
+ lineNum,
+ containingBlock?.children
+ );
+ return [containingBlock].concat(innerPathInChild);
+}
+
+export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
+
+export function getShowConfig(
+ showAbove: boolean,
+ showBelow: boolean
+): GrContextControlsShowConfig {
+ if (showAbove && !showBelow) return 'above';
+ if (!showAbove && showBelow) return 'below';
+
+ // Note that !showAbove && !showBelow also intentionally returns 'both'.
+ // This means the file is completely collapsed, which is unusual, but at least
+ // happens in one test.
+ return 'both';
+}
+
+export class GrContextControls extends LitElement {
+ @property({type: Object}) renderPreferences?: RenderPreferences;
+
+ @property({type: Object}) diff?: DiffInfo;
+
+ @property({type: Object}) group?: GrDiffGroup;
+
+ @property({type: String, reflect: true})
+ showConfig: GrContextControlsShowConfig = 'both';
+
+ private expandButtonsHover = new Subject<{
+ eventType: 'enter' | 'leave';
+ buttonType: ContextButtonType;
+ linesToExpand: number;
+ }>();
+
+ static override styles = css`
+ :host {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ position: relative;
+ }
+
+ :host([showConfig='above']) {
+ justify-content: flex-end;
+ margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
+ margin-bottom: var(--gr-context-controls-margin-bottom);
+ height: calc(var(--line-height-normal) + var(--spacing-s));
+ .horizontalFlex {
+ align-items: end;
+ }
+ }
+
+ :host([showConfig='below']) {
+ justify-content: flex-start;
+ margin-top: 1px;
+ margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+ .horizontalFlex {
+ align-items: start;
+ }
+ }
+
+ :host([showConfig='both']) {
+ margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
+ margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+ height: calc(
+ 2 * var(--line-height-normal) + 2 * var(--spacing-s) +
+ var(--divider-height)
+ );
+ .horizontalFlex {
+ align-items: center;
+ }
+ }
+
+ .contextControlButton {
+ background-color: var(--default-button-background-color);
+ font: var(--context-control-button-font, inherit);
+ }
+
+ paper-button {
+ text-transform: none;
+ align-items: center;
+ background-color: var(--background-color);
+ font-family: inherit;
+ margin: var(--margin, 0);
+ min-width: var(--border, 0);
+ color: var(--diff-context-control-color);
+ border: solid var(--border-color);
+ border-width: 1px;
+ border-radius: var(--border-radius);
+ padding: var(--spacing-s) var(--spacing-l);
+ }
+
+ paper-button:hover {
+ /* same as defined in gr-button */
+ background: rgba(0, 0, 0, 0.12);
+ }
+ paper-button:focus-visible {
+ /* paper-button sets this to 0, thus preventing focus-based styling. */
+ outline-width: 1px;
+ }
+
+ .aboveBelowButtons {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin-left: var(--spacing-m);
+ position: relative;
+ }
+ .aboveBelowButtons:first-child {
+ margin-left: 0;
+ /* Places a default background layer behind the "all button" that can have opacity */
+ background-color: var(--default-button-background-color);
+ }
+
+ .horizontalFlex {
+ display: flex;
+ justify-content: center;
+ align-items: var(--gr-context-controls-horizontal-align-items, center);
+ }
+
+ .aboveButton {
+ border-bottom-width: 0;
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+ padding: var(--spacing-xxs) var(--spacing-l);
+ }
+ .belowButton {
+ border-top-width: 0;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ padding: var(--spacing-xxs) var(--spacing-l);
+ margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
+ }
+ .belowButton:first-child {
+ margin-top: 0;
+ }
+ .breadcrumbTooltip {
+ white-space: nowrap;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.setupButtonHoverHandler();
+ }
+
+ private showBoth() {
+ return this.showConfig === 'both';
+ }
+
+ private showAbove() {
+ return this.showBoth() || this.showConfig === 'above';
+ }
+
+ private showBelow() {
+ return this.showBoth() || this.showConfig === 'below';
+ }
+
+ private setupButtonHoverHandler() {
+ subscribe(
+ this,
+ () =>
+ this.expandButtonsHover.pipe(
+ switchMap(e => {
+ if (e.eventType === 'leave') {
+ // cancel any previous delay
+ // for mouse enter
+ return EMPTY;
+ }
+ return of(e).pipe(delay(500));
+ })
+ ),
+ ({buttonType, linesToExpand}) => {
+ fire(this, 'diff-context-button-hovered', {
+ buttonType,
+ linesToExpand,
+ });
+ }
+ );
+ }
+
+ private numLines() {
+ assertIsDefined(this.group);
+ // In context groups, there is the same number of lines left and right
+ const left = this.group.lineRange.left;
+ // Both start and end inclusive, so we need to add 1.
+ return left.end_line - left.start_line + 1;
+ }
+
+ private createExpandAllButtonContainer() {
+ return html` <div class="gr-diff aboveBelowButtons fullExpansion">
+ ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
+ </div>`;
+ }
+
+ /**
+ * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
+ */
+ private createContextButton(
+ type: ContextButtonType,
+ linesToExpand: number,
+ tooltip?: TemplateResult
+ ) {
+ if (!this.group) return;
+ let text = '';
+ let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+ let ariaLabel = '';
+ let classes = 'contextControlButton showContext ';
+
+ if (type === ContextButtonType.ALL) {
+ text = `+${pluralize(linesToExpand, 'common line')}`;
+ ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+ classes += this.showBoth()
+ ? 'centeredButton'
+ : this.showAbove()
+ ? 'aboveButton'
+ : 'belowButton';
+ if (this.group?.hasSkipGroup()) {
+ // Expanding content would require load of more data
+ text += ' (too large)';
+ }
+ groups.push(...this.group.contextGroups);
+ } else if (type === ContextButtonType.ABOVE) {
+ groups = hideInContextControl(
+ this.group.contextGroups,
+ linesToExpand,
+ this.numLines()
+ );
+ text = `+${linesToExpand}`;
+ classes += 'aboveButton';
+ ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
+ } else if (type === ContextButtonType.BELOW) {
+ groups = hideInContextControl(
+ this.group.contextGroups,
+ 0,
+ this.numLines() - linesToExpand
+ );
+ text = `+${linesToExpand}`;
+ classes += 'belowButton';
+ ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
+ } else if (type === ContextButtonType.BLOCK_ABOVE) {
+ groups = hideInContextControl(
+ this.group.contextGroups,
+ linesToExpand,
+ this.numLines()
+ );
+ text = '+Block';
+ classes += 'aboveButton';
+ ariaLabel = 'Show block above';
+ } else if (type === ContextButtonType.BLOCK_BELOW) {
+ groups = hideInContextControl(
+ this.group.contextGroups,
+ 0,
+ this.numLines() - linesToExpand
+ );
+ text = '+Block';
+ classes += 'belowButton';
+ ariaLabel = 'Show block below';
+ }
+ const expandHandler = this.createExpansionHandler(
+ linesToExpand,
+ type,
+ groups
+ );
+
+ const mouseHandler = (eventType: 'enter' | 'leave') => {
+ this.expandButtonsHover.next({
+ eventType,
+ buttonType: type,
+ linesToExpand,
+ });
+ };
+
+ const button = html` <paper-button
+ class=${classes}
+ aria-label=${ariaLabel}
+ @click=${expandHandler}
+ @mouseenter=${() => mouseHandler('enter')}
+ @mouseleave=${() => mouseHandler('leave')}
+ >
+ <span class="showContext">${text}</span>
+ ${tooltip}
+ </paper-button>`;
+ return button;
+ }
+
+ private createExpansionHandler(
+ linesToExpand: number,
+ type: ContextButtonType,
+ groups: GrDiffGroup[]
+ ) {
+ return (e: Event) => {
+ assertIsDefined(this.group);
+ e.stopPropagation();
+ if (type === ContextButtonType.ALL && this.group?.hasSkipGroup()) {
+ fire(this, 'content-load-needed', {
+ lineRange: this.group.lineRange,
+ });
+ } else {
+ fire(this, 'diff-context-expanded', {
+ numLines: this.numLines(),
+ buttonType: type,
+ expandedLines: linesToExpand,
+ });
+ fire(this, 'diff-context-expanded-internal-new', {
+ contextGroup: this.group,
+ groups,
+ numLines: this.numLines(),
+ buttonType: type,
+ expandedLines: linesToExpand,
+ });
+ }
+ };
+ }
+
+ private showPartialLinks() {
+ return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
+ }
+
+ /**
+ * Creates a container div with partial (+10) expansion buttons (above and/or below).
+ */
+ private createPartialExpansionButtons() {
+ if (!this.showPartialLinks()) {
+ return undefined;
+ }
+ let aboveButton;
+ let belowButton;
+ if (this.showAbove()) {
+ aboveButton = this.createContextButton(
+ ContextButtonType.ABOVE,
+ PARTIAL_CONTEXT_AMOUNT
+ );
+ }
+ if (this.showBelow()) {
+ belowButton = this.createContextButton(
+ ContextButtonType.BELOW,
+ PARTIAL_CONTEXT_AMOUNT
+ );
+ }
+ return aboveButton || belowButton
+ ? html` <div class="aboveBelowButtons partialExpansion">
+ ${aboveButton} ${belowButton}
+ </div>`
+ : undefined;
+ }
+
+ /**
+ * Creates a container div with block expansion buttons (above and/or below).
+ */
+ private createBlockExpansionButtons() {
+ assertIsDefined(this.group, 'group');
+ if (
+ !this.showPartialLinks() ||
+ !this.renderPreferences?.use_block_expansion ||
+ this.group?.hasSkipGroup()
+ ) {
+ return undefined;
+ }
+ let aboveBlockButton;
+ let belowBlockButton;
+ if (this.showAbove()) {
+ aboveBlockButton = this.createBlockButton(
+ ContextButtonType.BLOCK_ABOVE,
+ this.numLines(),
+ this.group.lineRange.right.start_line - 1
+ );
+ }
+ if (this.showBelow()) {
+ belowBlockButton = this.createBlockButton(
+ ContextButtonType.BLOCK_BELOW,
+ this.numLines(),
+ this.group.lineRange.right.end_line + 1
+ );
+ }
+ if (aboveBlockButton || belowBlockButton) {
+ return html` <div class="aboveBelowButtons blockExpansion">
+ ${aboveBlockButton} ${belowBlockButton}
+ </div>`;
+ }
+ return undefined;
+ }
+
+ private createBlockButtonTooltip(
+ buttonType: ContextButtonType,
+ syntaxPath: SyntaxBlock[],
+ linesToExpand: number
+ ) {
+ // Create breadcrumb string:
+ // myNamespace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+ const tooltipText = syntaxPath.length
+ ? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
+ : `${linesToExpand} common lines`;
+
+ const position =
+ buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
+ return html`<paper-tooltip offset="10" position=${position}
+ ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
+ >`;
+ }
+
+ private createBlockButton(
+ buttonType: ContextButtonType,
+ numLines: number,
+ referenceLine: number
+ ) {
+ if (!this.diff?.meta_b) return;
+ const syntaxTree = this.diff.meta_b.syntax_tree;
+ const outlineSyntaxPath = findBlockTreePathForLine(
+ referenceLine,
+ syntaxTree
+ );
+ let linesToExpand = numLines;
+ if (outlineSyntaxPath.length) {
+ const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
+ const targetLine =
+ buttonType === ContextButtonType.BLOCK_ABOVE
+ ? range.end_line
+ : range.start_line;
+ const distanceToTargetLine = Math.abs(targetLine - referenceLine);
+ if (distanceToTargetLine < numLines) {
+ linesToExpand = distanceToTargetLine;
+ }
+ }
+ const tooltip = this.createBlockButtonTooltip(
+ buttonType,
+ outlineSyntaxPath,
+ linesToExpand
+ );
+ return this.createContextButton(buttonType, linesToExpand, tooltip);
+ }
+
+ private hasValidProperties() {
+ return !!(this.diff && this.group?.contextGroups?.length);
+ }
+
+ override render() {
+ if (!this.hasValidProperties()) {
+ console.error('Invalid properties for gr-context-controls!');
+ return html`<p>invalid properties</p>`;
+ }
+ return html`
+ <div class="horizontalFlex">
+ ${this.createExpandAllButtonContainer()}
+ ${this.createPartialExpansionButtons()}
+ ${this.createBlockExpansionButtons()}
+ </div>
+ `;
+ }
+}
+
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+ customElements.define('gr-context-controls', GrContextControls);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-context-controls': LitElement;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls_test.ts
new file mode 100644
index 0000000..7f5827c
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-context-controls/gr-context-controls_test.ts
@@ -0,0 +1,375 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff-group';
+import './gr-context-controls';
+import {GrContextControls} from './gr-context-controls';
+
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {
+ DiffFileMetaInfo,
+ DiffInfo,
+ GrDiffLineType,
+ SyntaxBlock,
+} from '../../../api/diff';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitEventLoop} from '../../../test/test-utils';
+
+suite('gr-context-control tests', () => {
+ let element: GrContextControls;
+
+ setup(async () => {
+ // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
+ element = document.createElement(
+ 'gr-context-controls'
+ ) as GrContextControls;
+ element.diff = {content: []} as any as DiffInfo;
+ element.renderPreferences = {};
+ const div = await fixture(html`<div></div>`);
+ div.appendChild(element);
+ await waitEventLoop();
+ });
+
+ function createContextGroup(options: {offset?: number; count?: number}) {
+ const offset = options.offset || 0;
+ const numLines = options.count || 10;
+ const lines = [];
+ for (let i = 0; i < numLines; i++) {
+ const line = new GrDiffLine(GrDiffLineType.BOTH);
+ line.beforeNumber = offset + i + 1;
+ line.afterNumber = offset + i + 1;
+ line.text = 'lorem upsum';
+ lines.push(line);
+ }
+ return new GrDiffGroup({
+ type: GrDiffGroupType.CONTEXT_CONTROL,
+ contextGroups: [new GrDiffGroup({type: GrDiffGroupType.BOTH, lines})],
+ });
+ }
+
+ test('no +10 buttons for 10 or less lines', async () => {
+ element.group = createContextGroup({count: 10});
+
+ await waitEventLoop();
+
+ const buttons = element.shadowRoot!.querySelectorAll(
+ 'paper-button.showContext'
+ );
+ assert.equal(buttons.length, 1);
+ assert.equal(buttons[0].textContent!.trim(), '+10 common lines');
+ });
+
+ test('context control at the top', async () => {
+ element.group = createContextGroup({offset: 0, count: 20});
+ element.showConfig = 'below';
+
+ await waitEventLoop();
+
+ const buttons = element.shadowRoot!.querySelectorAll(
+ 'paper-button.showContext'
+ );
+
+ assert.equal(buttons.length, 2);
+ assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+ assert.equal(buttons[1].textContent!.trim(), '+10');
+
+ assert.include([...buttons[0].classList.values()], 'belowButton');
+ assert.include([...buttons[1].classList.values()], 'belowButton');
+ });
+
+ test('context control in the middle', async () => {
+ element.group = createContextGroup({offset: 10, count: 20});
+ element.showConfig = 'both';
+
+ await waitEventLoop();
+
+ const buttons = element.shadowRoot!.querySelectorAll(
+ 'paper-button.showContext'
+ );
+
+ assert.equal(buttons.length, 3);
+ assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+ assert.equal(buttons[1].textContent!.trim(), '+10');
+ assert.equal(buttons[2].textContent!.trim(), '+10');
+
+ assert.include([...buttons[0].classList.values()], 'centeredButton');
+ assert.include([...buttons[1].classList.values()], 'aboveButton');
+ assert.include([...buttons[2].classList.values()], 'belowButton');
+ });
+
+ test('context control at the bottom', async () => {
+ element.group = createContextGroup({offset: 30, count: 20});
+ element.showConfig = 'above';
+
+ await waitEventLoop();
+
+ const buttons = element.shadowRoot!.querySelectorAll(
+ 'paper-button.showContext'
+ );
+
+ assert.equal(buttons.length, 2);
+ assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+ assert.equal(buttons[1].textContent!.trim(), '+10');
+
+ assert.include([...buttons[0].classList.values()], 'aboveButton');
+ assert.include([...buttons[1].classList.values()], 'aboveButton');
+ });
+
+ function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
+ element.renderPreferences!.use_block_expansion = true;
+ element.diff!.meta_b = {
+ syntax_tree: syntaxTree,
+ } as any as DiffFileMetaInfo;
+ }
+
+ test('context control with block expansion at the top', async () => {
+ prepareForBlockExpansion([]);
+ element.group = createContextGroup({offset: 0, count: 20});
+ element.showConfig = 'below';
+
+ await waitEventLoop();
+
+ const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.fullExpansion paper-button'
+ );
+ const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.partialExpansion paper-button'
+ );
+ const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.blockExpansion paper-button'
+ );
+ assert.equal(fullExpansionButtons.length, 1);
+ assert.equal(partialExpansionButtons.length, 1);
+ assert.equal(blockExpansionButtons.length, 1);
+ assert.equal(
+ blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+ '+Block'
+ );
+ assert.include(
+ [...blockExpansionButtons[0].classList.values()],
+ 'belowButton'
+ );
+ });
+
+ test('context control with block expansion in the middle', async () => {
+ prepareForBlockExpansion([]);
+ element.group = createContextGroup({offset: 10, count: 20});
+ element.showConfig = 'both';
+
+ await waitEventLoop();
+
+ const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.fullExpansion paper-button'
+ );
+ const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.partialExpansion paper-button'
+ );
+ const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.blockExpansion paper-button'
+ );
+ assert.equal(fullExpansionButtons.length, 1);
+ assert.equal(partialExpansionButtons.length, 2);
+ assert.equal(blockExpansionButtons.length, 2);
+ assert.equal(
+ blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+ '+Block'
+ );
+ assert.equal(
+ blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
+ '+Block'
+ );
+ assert.include(
+ [...blockExpansionButtons[0].classList.values()],
+ 'aboveButton'
+ );
+ assert.include(
+ [...blockExpansionButtons[1].classList.values()],
+ 'belowButton'
+ );
+ });
+
+ test('context control with block expansion at the bottom', async () => {
+ prepareForBlockExpansion([]);
+ element.group = createContextGroup({offset: 30, count: 20});
+ element.showConfig = 'above';
+
+ await waitEventLoop();
+
+ const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.fullExpansion paper-button'
+ );
+ const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.partialExpansion paper-button'
+ );
+ const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.blockExpansion paper-button'
+ );
+ assert.equal(fullExpansionButtons.length, 1);
+ assert.equal(partialExpansionButtons.length, 1);
+ assert.equal(blockExpansionButtons.length, 1);
+ assert.equal(
+ blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+ '+Block'
+ );
+ assert.include(
+ [...blockExpansionButtons[0].classList.values()],
+ 'aboveButton'
+ );
+ });
+
+ test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+ prepareForBlockExpansion([
+ {
+ name: 'aSpecificFunction',
+ range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
+ children: [],
+ },
+ {
+ name: 'anotherFunction',
+ range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
+ children: [],
+ },
+ ]);
+ element.group = createContextGroup({offset: 10, count: 20});
+ element.showConfig = 'both';
+
+ await waitEventLoop();
+
+ const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.blockExpansion paper-button'
+ );
+ assert.equal(
+ blockExpansionButtons[0]
+ .querySelector('.breadcrumbTooltip')!
+ .textContent?.trim(),
+ 'aSpecificFunction'
+ );
+ assert.equal(
+ blockExpansionButtons[1]
+ .querySelector('.breadcrumbTooltip')!
+ .textContent?.trim(),
+ 'anotherFunction'
+ );
+ });
+
+ test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
+ prepareForBlockExpansion([
+ {
+ name: 'aSpecificNamespace',
+ range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+ children: [
+ {
+ name: 'MyClass',
+ range: {
+ start_line: 2,
+ start_column: 0,
+ end_line: 100,
+ end_column: 0,
+ },
+ children: [
+ {
+ name: 'aMethod',
+ range: {
+ start_line: 5,
+ start_column: 0,
+ end_line: 80,
+ end_column: 0,
+ },
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+ element.group = createContextGroup({offset: 10, count: 20});
+ element.showConfig = 'both';
+
+ await waitEventLoop();
+
+ const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.blockExpansion paper-button'
+ );
+ assert.equal(
+ blockExpansionButtons[0]
+ .querySelector('.breadcrumbTooltip')!
+ .textContent?.trim(),
+ 'aSpecificNamespace > MyClass > aMethod'
+ );
+ });
+
+ test('+Block tooltip shows (anonymous) for empty blocks', async () => {
+ prepareForBlockExpansion([
+ {
+ name: 'aSpecificNamespace',
+ range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+ children: [
+ {
+ name: '',
+ range: {
+ start_line: 2,
+ start_column: 0,
+ end_line: 100,
+ end_column: 0,
+ },
+ children: [
+ {
+ name: 'aMethod',
+ range: {
+ start_line: 5,
+ start_column: 0,
+ end_line: 80,
+ end_column: 0,
+ },
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+ element.group = createContextGroup({offset: 10, count: 20});
+ element.showConfig = 'both';
+ await waitEventLoop();
+
+ const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.blockExpansion paper-button'
+ );
+ assert.equal(
+ blockExpansionButtons[0]
+ .querySelector('.breadcrumbTooltip')!
+ .textContent?.trim(),
+ 'aSpecificNamespace > (anonymous) > aMethod'
+ );
+ });
+
+ test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
+ prepareForBlockExpansion([]);
+
+ element.group = createContextGroup({offset: 10, count: 20});
+ element.showConfig = 'both';
+ await waitEventLoop();
+
+ const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+ '.blockExpansion paper-button'
+ );
+ const tooltipAbove =
+ blockExpansionButtons[0].querySelector('paper-tooltip')!;
+ const tooltipBelow =
+ blockExpansionButtons[1].querySelector('paper-tooltip')!;
+ assert.equal(
+ tooltipAbove.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+ '20 common lines'
+ );
+ assert.equal(
+ tooltipBelow.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+ '20 common lines'
+ );
+ assert.equal(tooltipAbove.getAttribute('position'), 'top');
+ assert.equal(tooltipBelow.getAttribute('position'), 'bottom');
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-binary.ts
new file mode 100644
index 0000000..9467654
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-binary.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffBuilder} from './gr-diff-builder';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {createElementDiff} from '../../diff/gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {html, render} from 'lit';
+import {FILE} from '../../../api/diff';
+
+export class GrDiffBuilderBinary extends GrDiffBuilder {
+ constructor(
+ diff: DiffInfo,
+ prefs: DiffPreferencesInfo,
+ outputEl: HTMLElement
+ ) {
+ super(diff, prefs, outputEl);
+ }
+
+ override buildSectionElement(group: GrDiffGroup): HTMLElement {
+ const section = createElementDiff('tbody', 'binary-diff');
+ // Do not create a diff row for LOST.
+ if (group.lines[0].beforeNumber !== FILE) return section;
+ return super.buildSectionElement(group);
+ }
+
+ public renderBinaryDiff() {
+ render(
+ html`
+ <tbody class="gr-diff binary-diff">
+ <tr class="gr-diff">
+ <td colspan="5" class="gr-diff">
+ <span>Difference in binary files</span>
+ </td>
+ </tr>
+ </tbody>
+ `,
+ this.outputEl
+ );
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-element.ts
new file mode 100644
index 0000000..009cbf9
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-element.ts
@@ -0,0 +1,574 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-diff-processor/gr-diff-processor';
+import '../../../elements/shared/gr-hovercard/gr-hovercard';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {
+ GrDiffBuilder,
+ DiffContextExpandedEventDetail,
+ isImageDiffBuilder,
+ isBinaryDiffBuilder,
+} from './gr-diff-builder';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
+import {BlameInfo, ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {CoverageRange, DiffLayer} from '../../../types/types';
+import {
+ GrDiffProcessor,
+ GroupConsumer,
+ KeyLocations,
+} from '../gr-diff-processor/gr-diff-processor';
+import {
+ CommentRangeLayer,
+ GrRangedCommentLayer,
+} from '../../diff/gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {GrCoverageLayer} from '../../diff/gr-coverage-layer/gr-coverage-layer';
+import {DiffViewMode, LineNumber, RenderPreferences} from '../../../api/diff';
+import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {
+ GrDiffGroup,
+ GrDiffGroupType,
+ hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {getLineNumber, getSideByLineEl} from '../../diff/gr-diff/gr-diff-utils';
+import {fireAlert, fire} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+
+const TRAILING_WHITESPACE_PATTERN = /\s+$/;
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+declare global {
+ interface HTMLElementEventMap {
+ /**
+ * Fired when the diff begins rendering - both for full renders and for
+ * partial rerenders.
+ */
+ 'render-start': CustomEvent<{}>;
+ /**
+ * Fired when the diff finishes rendering text content - both for full
+ * renders and for partial rerenders.
+ */
+ 'render-content': CustomEvent<{}>;
+ }
+}
+
+export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+ return prefs.font_size * 4;
+}
+
+function annotateSymbols(
+ contentEl: HTMLElement,
+ line: GrDiffLine,
+ separator: string | RegExp,
+ className: string
+) {
+ const split = line.text.split(separator);
+ if (!split || split.length < 2) {
+ return;
+ }
+ for (let i = 0, pos = 0; i < split.length - 1; i++) {
+ // Skip forward by the length of the content
+ pos += split[i].length;
+
+ GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
+
+ pos++;
+ }
+}
+
+// TODO: Rename the class and the file and remove "element". This is not an
+// element anymore.
+export class GrDiffBuilderElement implements GroupConsumer {
+ diff?: DiffInfo;
+
+ diffElement?: HTMLTableElement;
+
+ viewMode?: string;
+
+ isImageDiff?: boolean;
+
+ baseImage: ImageInfo | null = null;
+
+ revisionImage: ImageInfo | null = null;
+
+ path?: string;
+
+ prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+ renderPrefs?: RenderPreferences;
+
+ useNewImageDiffUi = false;
+
+ /**
+ * Layers passed in from the outside.
+ *
+ * See `layersInternal` for where these layers will end up together with the
+ * internal layers.
+ */
+ layers: DiffLayer[] = [];
+
+ // visible for testing
+ builder?: GrDiffBuilder;
+
+ /**
+ * All layers, both from the outside and the default ones. See `layers` for
+ * the property that can be set from the outside.
+ */
+ // visible for testing
+ layersInternal: DiffLayer[] = [];
+
+ // visible for testing
+ showTabs?: boolean;
+
+ // visible for testing
+ showTrailingWhitespace?: boolean;
+
+ private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+ private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+ private rangeLayer?: GrRangedCommentLayer;
+
+ // visible for testing
+ processor?: GrDiffProcessor;
+
+ /**
+ * Groups are mostly just passed on to the diff builder (this.builder). But
+ * we also keep track of them here for being able to fire a `render-content`
+ * event when .element of each group has rendered.
+ *
+ * TODO: Refactor DiffBuilderElement and DiffBuilders with a cleaner
+ * separation of responsibilities.
+ */
+ private groups: GrDiffGroup[] = [];
+
+ updateCommentRanges(ranges: CommentRangeLayer[]) {
+ this.rangeLayer?.updateRanges(ranges);
+ }
+
+ updateCoverageRanges(rs: CoverageRange[]) {
+ this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+ this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
+ }
+
+ render(keyLocations: KeyLocations): Promise<void> {
+ assertIsDefined(this.diff, 'diff');
+ assertIsDefined(this.diffElement, 'diff table');
+
+ // Setting up annotation layers must happen after plugins are
+ // installed, and |render| satisfies the requirement, however,
+ // |attached| doesn't because in the diff view page, the element is
+ // attached before plugins are installed.
+ this.setupAnnotationLayers();
+
+ this.showTabs = this.prefs.show_tabs;
+ this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
+
+ this.cleanup();
+ this.builder = this.getDiffBuilder();
+ this.init();
+
+ // TODO: Just pass along the diff model here instead of setting many
+ // individual properties.
+ this.processor = new GrDiffProcessor();
+ this.processor.consumer = this;
+ this.processor.context = this.prefs.context;
+ this.processor.keyLocations = keyLocations;
+ if (this.renderPrefs?.num_lines_rendered_at_once) {
+ this.processor.asyncThreshold =
+ this.renderPrefs.num_lines_rendered_at_once;
+ }
+
+ this.clearDiffContent();
+ this.builder.addColumns(
+ this.diffElement,
+ getLineNumberCellWidth(this.prefs)
+ );
+
+ const isBinary = !!(this.isImageDiff || this.diff.binary);
+
+ fire(this.diffElement, 'render-start', {});
+ return (
+ this.processor
+ .process(this.diff.content, isBinary)
+ .then(async () => {
+ if (isImageDiffBuilder(this.builder)) {
+ this.builder.renderImageDiff();
+ } else if (isBinaryDiffBuilder(this.builder)) {
+ this.builder.renderBinaryDiff();
+ }
+ await this.untilGroupsRendered();
+ fire(this.diffElement, 'render-content', {});
+ })
+ // Mocha testing does not like uncaught rejections, so we catch
+ // the cancels which are expected and should not throw errors in
+ // tests.
+ .catch(e => {
+ if (!e.isCanceled) return Promise.reject(e);
+ return;
+ })
+ );
+ }
+
+ // visible for testing
+ async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+ return Promise.all(groups.map(g => g.waitUntilRendered()));
+ }
+
+ private onDiffContextExpanded = (
+ e: CustomEvent<DiffContextExpandedEventDetail>
+ ) => {
+ // Don't stop propagation. The host may listen for reporting or
+ // resizing.
+ this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+ };
+
+ // visible for testing
+ setupAnnotationLayers() {
+ this.rangeLayer = new GrRangedCommentLayer();
+
+ const layers: DiffLayer[] = [
+ this.createTrailingWhitespaceLayer(),
+ this.createIntralineLayer(),
+ this.createTabIndicatorLayer(),
+ this.createSpecialCharacterIndicatorLayer(),
+ this.rangeLayer,
+ this.coverageLayerLeft,
+ this.coverageLayerRight,
+ ];
+
+ if (this.layers) {
+ layers.push(...this.layers);
+ }
+ this.layersInternal = layers;
+ }
+
+ getContentTdByLine(lineNumber: LineNumber, side?: Side) {
+ if (!this.builder) return undefined;
+ return this.builder.getContentTdByLine(lineNumber, side);
+ }
+
+ getContentTdByLineEl(lineEl?: Element): Element | undefined {
+ if (!lineEl) return undefined;
+ const line = getLineNumber(lineEl);
+ if (!line) return undefined;
+ const side = getSideByLineEl(lineEl);
+ return this.getContentTdByLine(line, side);
+ }
+
+ getLineElByNumber(lineNumber: LineNumber, side?: Side) {
+ if (!this.builder) return undefined;
+ return this.builder.getLineElByNumber(lineNumber, side);
+ }
+
+ getLineNumberRows() {
+ if (!this.builder) return [];
+ return this.builder.getLineNumberRows();
+ }
+
+ getLineNumEls(side: Side) {
+ if (!this.builder) return [];
+ return this.builder.getLineNumEls(side);
+ }
+
+ /**
+ * When the line is hidden behind a context expander, expand it.
+ *
+ * @param lineNum A line number to expand. Using number here because other
+ * special case line numbers are never hidden, so it does not make sense
+ * to expand them.
+ * @param side The side the line number refer to.
+ */
+ unhideLine(lineNum: number, side: Side) {
+ if (!this.builder) return;
+ const group = this.builder.findGroup(side, lineNum);
+ // Cannot unhide a line that is not part of the diff.
+ if (!group) return;
+ // If it's already visible, great!
+ if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+ const lineRange = group.lineRange[side];
+ const lineOffset = lineNum - lineRange.start_line;
+ const newGroups = [];
+ const groups = hideInContextControl(
+ group.contextGroups,
+ 0,
+ lineOffset - 1 - this.prefs.context
+ );
+ // If there is a context group, it will be the first group because we
+ // start hiding from 0 offset
+ if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
+ newGroups.push(groups.shift()!);
+ }
+ newGroups.push(
+ ...hideInContextControl(
+ groups,
+ lineOffset + 1 + this.prefs.context,
+ // Both ends inclusive, so difference is the offset of the last line.
+ // But we need to pass the first line not to hide, which is the element
+ // after.
+ lineRange.end_line - lineRange.start_line + 1
+ )
+ );
+ this.replaceGroup(group, newGroups);
+ }
+
+ /**
+ * Replace the group of a context control section by rendering the provided
+ * groups instead. This happens in response to expanding a context control
+ * group.
+ *
+ * @param contextGroup The context control group to replace
+ * @param newGroups The groups that are replacing the context control group
+ */
+ private replaceGroup(
+ contextGroup: GrDiffGroup,
+ newGroups: readonly GrDiffGroup[]
+ ) {
+ if (!this.builder) return;
+ fire(this.diffElement, 'render-start', {});
+ this.builder.replaceGroup(contextGroup, newGroups);
+ this.groups = this.groups.filter(g => g !== contextGroup);
+ this.groups.push(...newGroups);
+ this.untilGroupsRendered(newGroups).then(() => {
+ fire(this.diffElement, 'render-content', {});
+ });
+ }
+
+ /**
+ * This is meant to be called when the gr-diff component re-connects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with cleanup(), which is called
+ * when gr-diff disconnects.
+ */
+ init() {
+ this.cleanup();
+ this.diffElement?.addEventListener(
+ 'diff-context-expanded-internal-new',
+ this.onDiffContextExpanded
+ );
+ this.builder?.init();
+ }
+
+ /**
+ * This is meant to be called when the gr-diff component disconnects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with init(), which is called when
+ * gr-diff re-connects.
+ */
+ cleanup() {
+ this.processor?.cancel();
+ this.builder?.cleanup();
+ this.diffElement?.removeEventListener(
+ 'diff-context-expanded-internal-new',
+ this.onDiffContextExpanded
+ );
+ }
+
+ // visible for testing
+ handlePreferenceError(pref: string): never {
+ const message =
+ `The value of the '${pref}' user preference is ` +
+ 'invalid. Fix in diff preferences';
+ assertIsDefined(this.diffElement, 'diff table');
+ fireAlert(this.diffElement, message);
+ throw Error(`Invalid preference value: ${pref}`);
+ }
+
+ // visible for testing
+ getDiffBuilder(): GrDiffBuilder {
+ assertIsDefined(this.diff, 'diff');
+ assertIsDefined(this.diffElement, 'diff table');
+ if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
+ this.handlePreferenceError('tab size');
+ }
+
+ if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
+ this.handlePreferenceError('diff width');
+ }
+
+ const localPrefs = {...this.prefs};
+ if (this.path === COMMIT_MSG_PATH) {
+ // override line_length for commit msg the same way as
+ // in gr-diff
+ localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
+ }
+
+ let builder = null;
+ if (this.isImageDiff) {
+ builder = new GrDiffBuilderImage(
+ this.diff,
+ localPrefs,
+ this.diffElement,
+ this.baseImage,
+ this.revisionImage,
+ this.renderPrefs,
+ this.useNewImageDiffUi
+ );
+ } else if (this.diff.binary) {
+ return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
+ } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+ this.renderPrefs = {
+ ...this.renderPrefs,
+ view_mode: DiffViewMode.SIDE_BY_SIDE,
+ };
+ builder = new GrDiffBuilder(
+ this.diff,
+ localPrefs,
+ this.diffElement,
+ this.layersInternal,
+ this.renderPrefs
+ );
+ } else if (this.viewMode === DiffViewMode.UNIFIED) {
+ this.renderPrefs = {
+ ...this.renderPrefs,
+ view_mode: DiffViewMode.UNIFIED,
+ };
+ builder = new GrDiffBuilder(
+ this.diff,
+ localPrefs,
+ this.diffElement,
+ this.layersInternal,
+ this.renderPrefs
+ );
+ }
+ if (!builder) {
+ throw Error(`Unsupported diff view mode: ${this.viewMode}`);
+ }
+ return builder;
+ }
+
+ private clearDiffContent() {
+ assertIsDefined(this.diffElement, 'diff table');
+ this.diffElement.innerHTML = '';
+ }
+
+ /**
+ * Called when the processor starts converting the diff information from the
+ * server into chunks.
+ */
+ clearGroups() {
+ if (!this.builder) return;
+ this.groups = [];
+ this.builder.clearGroups();
+ }
+
+ /**
+ * Called when the processor is done converting a chunk of the diff.
+ */
+ addGroup(group: GrDiffGroup) {
+ if (!this.builder) return;
+ this.builder.addGroups([group]);
+ this.groups.push(group);
+ }
+
+ // visible for testing
+ createIntralineLayer(): DiffLayer {
+ return {
+ // Take a DIV.contentText element and a line object with intraline
+ // differences to highlight and apply them to the element as
+ // annotations.
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ const HL_CLASS = 'gr-diff intraline';
+ for (const highlight of line.highlights) {
+ // The start and end indices could be the same if a highlight is
+ // meant to start at the end of a line and continue onto the
+ // next one. Ignore it.
+ if (highlight.startIndex === highlight.endIndex) {
+ continue;
+ }
+
+ // If endIndex isn't present, continue to the end of the line.
+ const endIndex =
+ highlight.endIndex === undefined
+ ? GrAnnotation.getStringLength(line.text)
+ : highlight.endIndex;
+
+ GrAnnotation.annotateElement(
+ contentEl,
+ highlight.startIndex,
+ endIndex - highlight.startIndex,
+ HL_CLASS
+ );
+ }
+ },
+ };
+ }
+
+ // visible for testing
+ createTabIndicatorLayer(): DiffLayer {
+ const show = () => this.showTabs;
+ return {
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ // If visible tabs are disabled, do nothing.
+ if (!show()) {
+ return;
+ }
+
+ // Find and annotate the locations of tabs.
+ annotateSymbols(contentEl, line, '\t', 'tab-indicator');
+ },
+ };
+ }
+
+ private createSpecialCharacterIndicatorLayer(): DiffLayer {
+ return {
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ // Find and annotate the locations of soft hyphen (\u00AD)
+ annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
+ // Find and annotate Stateful Unicode directional controls
+ annotateSymbols(
+ contentEl,
+ line,
+ /[\u202A-\u202E\u2066-\u2069]/,
+ 'special-char-warning'
+ );
+ },
+ };
+ }
+
+ // visible for testing
+ createTrailingWhitespaceLayer(): DiffLayer {
+ const show = () => this.showTrailingWhitespace;
+
+ return {
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ if (!show()) {
+ return;
+ }
+
+ const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+ if (match) {
+ // Normalize string positions in case there is unicode before or
+ // within the match.
+ const index = GrAnnotation.getStringLength(
+ line.text.substr(0, match.index)
+ );
+ const length = GrAnnotation.getStringLength(match[0]);
+ GrAnnotation.annotateElement(
+ contentEl,
+ index,
+ length,
+ 'gr-diff trailing-whitespace'
+ );
+ }
+ },
+ };
+ }
+
+ setBlame(blame: BlameInfo[] | null) {
+ if (!this.builder) return;
+ this.builder.setBlame(blame ?? []);
+ }
+
+ updateRenderPrefs(renderPrefs: RenderPreferences) {
+ this.builder?.updateRenderPrefs(renderPrefs);
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-element_test.ts
new file mode 100644
index 0000000..f6f0cb3
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -0,0 +1,628 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {
+ createConfig,
+ createEmptyDiff,
+} from '../../../test/test-data-generators';
+import './gr-diff-builder-element';
+import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {
+ DiffContent,
+ DiffLayer,
+ DiffPreferencesInfo,
+ DiffViewMode,
+ GrDiffLineType,
+ Side,
+} from '../../../api/diff';
+import {stubRestApi} from '../../../test/test-utils';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiffBuilderElement} from './gr-diff-builder-element';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffRow} from './gr-diff-row';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {querySelectorAll} from '../../../utils/dom-util';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
+
+suite('gr-diff-builder tests', () => {
+ let element: GrDiffBuilderElement;
+ let builder: GrDiffBuilder;
+ let diffTable: HTMLTableElement;
+
+ const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+ builder = new GrDiffBuilder(
+ createEmptyDiff(),
+ {...createDefaultDiffPrefs(), ...prefs},
+ diffTable
+ );
+ };
+
+ const line = (text: string) => {
+ const line = new GrDiffLine(GrDiffLineType.BOTH);
+ line.text = text;
+ return line;
+ };
+
+ setup(async () => {
+ diffTable = await fixture(html`<table id="diffTable"></table>`);
+ element = new GrDiffBuilderElement();
+ element.diffElement = diffTable;
+ stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+ stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+ stubBaseUrl('/r');
+ setBuilderPrefs({});
+ });
+
+ [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+ test(`line_length used for regular files under ${mode}`, () => {
+ element.path = '/a.txt';
+ element.viewMode = mode;
+ element.diff = createEmptyDiff();
+ element.prefs = {
+ ...createDefaultDiffPrefs(),
+ tab_size: 4,
+ line_length: 50,
+ };
+ builder = element.getDiffBuilder();
+ assert.equal(builder.prefs.line_length, 50);
+ });
+
+ test(`line_length ignored for commit msg under ${mode}`, () => {
+ element.path = '/COMMIT_MSG';
+ element.viewMode = mode;
+ element.diff = createEmptyDiff();
+ element.prefs = {
+ ...createDefaultDiffPrefs(),
+ tab_size: 4,
+ line_length: 50,
+ };
+ builder = element.getDiffBuilder();
+ assert.equal(builder.prefs.line_length, 72);
+ });
+ });
+
+ test('_handlePreferenceError throws with invalid preference', () => {
+ element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+ assert.throws(() => element.getDiffBuilder());
+ });
+
+ test('_handlePreferenceError triggers alert and javascript error', () => {
+ const errorStub = sinon.stub();
+ diffTable.addEventListener('show-alert', errorStub);
+ assert.throws(() => element.handlePreferenceError('tab size'));
+ assert.equal(
+ errorStub.lastCall.args[0].detail.message,
+ "The value of the 'tab size' user preference is invalid. " +
+ 'Fix in diff preferences'
+ );
+ });
+
+ suite('intraline differences', () => {
+ let el: HTMLElement;
+ let str: string;
+ let annotateElementSpy: sinon.SinonSpy;
+ let layer: DiffLayer;
+ const lineNumberEl = document.createElement('td');
+
+ function slice(str: string, start: number, end?: number) {
+ return Array.from(str).slice(start, end).join('');
+ }
+
+ setup(async () => {
+ el = await fixture(html`
+ <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+ `);
+ str = el.textContent ?? '';
+ annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+ layer = element.createIntralineLayer();
+ });
+
+ test('annotate no highlights', () => {
+ layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+ // The content is unchanged.
+ assert.isFalse(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 1);
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(str, el.childNodes[0].textContent);
+ });
+
+ test('annotate with highlights', () => {
+ const l = line(str);
+ l.highlights = [
+ {contentIndex: 0, startIndex: 6, endIndex: 12},
+ {contentIndex: 0, startIndex: 18, endIndex: 22},
+ ];
+ const str0 = slice(str, 0, 6);
+ const str1 = slice(str, 6, 12);
+ const str2 = slice(str, 12, 18);
+ const str3 = slice(str, 18, 22);
+ const str4 = slice(str, 22);
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isTrue(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 5);
+
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(el.childNodes[0].textContent, str0);
+
+ assert.notInstanceOf(el.childNodes[1], Text);
+ assert.equal(el.childNodes[1].textContent, str1);
+
+ assert.instanceOf(el.childNodes[2], Text);
+ assert.equal(el.childNodes[2].textContent, str2);
+
+ assert.notInstanceOf(el.childNodes[3], Text);
+ assert.equal(el.childNodes[3].textContent, str3);
+
+ assert.instanceOf(el.childNodes[4], Text);
+ assert.equal(el.childNodes[4].textContent, str4);
+ });
+
+ test('annotate without endIndex', () => {
+ const l = line(str);
+ l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+ const str0 = slice(str, 0, 28);
+ const str1 = slice(str, 28);
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isTrue(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 2);
+
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(el.childNodes[0].textContent, str0);
+
+ assert.notInstanceOf(el.childNodes[1], Text);
+ assert.equal(el.childNodes[1].textContent, str1);
+ });
+
+ test('annotate ignores empty highlights', () => {
+ const l = line(str);
+ l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isFalse(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 1);
+ });
+
+ test('annotate handles unicode', () => {
+ // Put some unicode into the string:
+ str = str.replace(/\s/g, '💢');
+ el.textContent = str;
+ const l = line(str);
+ l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+ const str0 = slice(str, 0, 6);
+ const str1 = slice(str, 6, 12);
+ const str2 = slice(str, 12);
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isTrue(annotateElementSpy.called);
+ assert.equal(el.childNodes.length, 3);
+
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(el.childNodes[0].textContent, str0);
+
+ assert.notInstanceOf(el.childNodes[1], Text);
+ assert.equal(el.childNodes[1].textContent, str1);
+
+ assert.instanceOf(el.childNodes[2], Text);
+ assert.equal(el.childNodes[2].textContent, str2);
+ });
+
+ test('annotate handles unicode w/o endIndex', () => {
+ // Put some unicode into the string:
+ str = str.replace(/\s/g, '💢');
+ el.textContent = str;
+
+ const l = line(str);
+ l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+ const str0 = slice(str, 0, 6);
+ const str1 = slice(str, 6);
+ const numHighlightedChars = GrAnnotation.getStringLength(str1);
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
+ assert.equal(el.childNodes.length, 2);
+
+ assert.instanceOf(el.childNodes[0], Text);
+ assert.equal(el.childNodes[0].textContent, str0);
+
+ assert.notInstanceOf(el.childNodes[1], Text);
+ assert.equal(el.childNodes[1].textContent, str1);
+ });
+ });
+
+ suite('tab indicators', () => {
+ let layer: DiffLayer;
+ const lineNumberEl = document.createElement('td');
+
+ setup(() => {
+ element.showTabs = true;
+ layer = element.createTabIndicatorLayer();
+ });
+
+ test('does nothing with empty line', () => {
+ const l = line('');
+ const el = document.createElement('div');
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('does nothing with no tabs', () => {
+ const str = 'lorem ipsum no tabs';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('annotates tab at beginning', () => {
+ const str = '\tlorem upsum';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.equal(annotateElementStub.callCount, 1);
+ const args = annotateElementStub.getCalls()[0].args;
+ assert.equal(args[0], el);
+ assert.equal(args[1], 0, 'offset of tab indicator');
+ assert.equal(args[2], 1, 'length of tab indicator');
+ assert.include(args[3], 'tab-indicator');
+ });
+
+ test('does not annotate when disabled', () => {
+ element.showTabs = false;
+
+ const str = '\tlorem upsum';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('annotates multiple in beginning', () => {
+ const str = '\t\tlorem upsum';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.equal(annotateElementStub.callCount, 2);
+
+ let args = annotateElementStub.getCalls()[0].args;
+ assert.equal(args[0], el);
+ assert.equal(args[1], 0, 'offset of tab indicator');
+ assert.equal(args[2], 1, 'length of tab indicator');
+ assert.include(args[3], 'tab-indicator');
+
+ args = annotateElementStub.getCalls()[1].args;
+ assert.equal(args[0], el);
+ assert.equal(args[1], 1, 'offset of tab indicator');
+ assert.equal(args[2], 1, 'length of tab indicator');
+ assert.include(args[3], 'tab-indicator');
+ });
+
+ test('annotates intermediate tabs', () => {
+ const str = 'lorem\tupsum';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+ assert.equal(annotateElementStub.callCount, 1);
+ const args = annotateElementStub.getCalls()[0].args;
+ assert.equal(args[0], el);
+ assert.equal(args[1], 5, 'offset of tab indicator');
+ assert.equal(args[2], 1, 'length of tab indicator');
+ assert.include(args[3], 'tab-indicator');
+ });
+ });
+
+ suite('layers', () => {
+ let initialLayersCount = 0;
+ let withLayerCount = 0;
+ setup(() => {
+ const layers: DiffLayer[] = [];
+ element.layers = layers;
+ element.showTrailingWhitespace = true;
+ element.setupAnnotationLayers();
+ initialLayersCount = element.layersInternal.length;
+ });
+
+ test('no layers', () => {
+ element.setupAnnotationLayers();
+ assert.equal(element.layersInternal.length, initialLayersCount);
+ });
+
+ suite('with layers', () => {
+ const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+ setup(() => {
+ element.layers = layers;
+ element.showTrailingWhitespace = true;
+ element.setupAnnotationLayers();
+ withLayerCount = element.layersInternal.length;
+ });
+ test('with layers', () => {
+ element.setupAnnotationLayers();
+ assert.equal(element.layersInternal.length, withLayerCount);
+ assert.equal(initialLayersCount + layers.length, withLayerCount);
+ });
+ });
+ });
+
+ suite('trailing whitespace', () => {
+ let layer: DiffLayer;
+ const lineNumberEl = document.createElement('td');
+
+ setup(() => {
+ element.showTrailingWhitespace = true;
+ layer = element.createTrailingWhitespaceLayer();
+ });
+
+ test('does nothing with empty line', () => {
+ const l = line('');
+ const el = document.createElement('div');
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('does nothing with no trailing whitespace', () => {
+ const str = 'lorem ipsum blah blah';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isFalse(annotateElementStub.called);
+ });
+
+ test('annotates trailing spaces', () => {
+ const str = 'lorem ipsum ';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isTrue(annotateElementStub.called);
+ assert.equal(annotateElementStub.lastCall.args[1], 11);
+ assert.equal(annotateElementStub.lastCall.args[2], 3);
+ });
+
+ test('annotates trailing tabs', () => {
+ const str = 'lorem ipsum\t\t\t';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isTrue(annotateElementStub.called);
+ assert.equal(annotateElementStub.lastCall.args[1], 11);
+ assert.equal(annotateElementStub.lastCall.args[2], 3);
+ });
+
+ test('annotates mixed trailing whitespace', () => {
+ const str = 'lorem ipsum\t \t';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isTrue(annotateElementStub.called);
+ assert.equal(annotateElementStub.lastCall.args[1], 11);
+ assert.equal(annotateElementStub.lastCall.args[2], 3);
+ });
+
+ test('unicode preceding trailing whitespace', () => {
+ const str = '💢\t';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isTrue(annotateElementStub.called);
+ assert.equal(annotateElementStub.lastCall.args[1], 1);
+ assert.equal(annotateElementStub.lastCall.args[2], 1);
+ });
+
+ test('does not annotate when disabled', () => {
+ element.showTrailingWhitespace = false;
+ const str = 'lorem upsum\t \t ';
+ const l = line(str);
+ const el = document.createElement('div');
+ el.textContent = str;
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ layer.annotate(el, lineNumberEl, l, Side.LEFT);
+ assert.isFalse(annotateElementStub.called);
+ });
+ });
+
+ suite('rendering text, images and binary files', () => {
+ let keyLocations: KeyLocations;
+ let content: DiffContent[] = [];
+
+ setup(() => {
+ element.viewMode = 'SIDE_BY_SIDE';
+ keyLocations = {left: {}, right: {}};
+ element.prefs = {
+ ...DEFAULT_PREFS,
+ context: -1,
+ syntax_highlighting: true,
+ };
+ content = [
+ {
+ a: ['all work and no play make andybons a dull boy'],
+ b: ['elgoog elgoog elgoog'],
+ },
+ {
+ ab: [
+ 'Non eram nescius, Brute, cum, quae summis ingeniis ',
+ 'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+ ],
+ },
+ ];
+ });
+
+ test('text', async () => {
+ element.diff = {...createEmptyDiff(), content};
+ element.render(keyLocations);
+ await waitForEventOnce(diffTable, 'render-content');
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
+ });
+
+ test('image', async () => {
+ element.diff = {...createEmptyDiff(), content, binary: true};
+ element.isImageDiff = true;
+ element.render(keyLocations);
+ await waitForEventOnce(diffTable, 'render-content');
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
+ });
+
+ test('binary', async () => {
+ element.diff = {...createEmptyDiff(), content, binary: true};
+ element.render(keyLocations);
+ await waitForEventOnce(diffTable, 'render-content');
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
+ });
+ });
+
+ suite('context hiding and expanding', () => {
+ let dispatchStub: sinon.SinonStub;
+
+ setup(async () => {
+ dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+ element.diff = {
+ ...createEmptyDiff(),
+ content: [
+ {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+ {a: ['before'], b: ['after']},
+ {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+ ],
+ };
+ element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+ const keyLocations: KeyLocations = {left: {}, right: {}};
+ element.prefs = {
+ ...DEFAULT_PREFS,
+ context: 1,
+ };
+ element.render(keyLocations);
+ // Make sure all listeners are installed.
+ await element.untilGroupsRendered();
+ });
+
+ test('hides lines behind two context controls', () => {
+ const contextControls = diffTable.querySelectorAll('gr-context-controls');
+ assert.equal(contextControls.length, 2);
+
+ const diffRows = diffTable.querySelectorAll('.diff-row');
+ // The first two are LOST and FILE line
+ assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+ assert.include(diffRows[2].textContent, 'unchanged 10');
+ assert.include(diffRows[3].textContent, 'before');
+ assert.include(diffRows[3].textContent, 'after');
+ assert.include(diffRows[4].textContent, 'unchanged 11');
+ });
+
+ test('clicking +x common lines expands those lines', async () => {
+ const contextControls = diffTable.querySelectorAll('gr-context-controls');
+ const topExpandCommonButton =
+ contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+ '.showContext'
+ )[0];
+ assert.isOk(topExpandCommonButton);
+ assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+ let diffRows = diffTable.querySelectorAll('.diff-row');
+ // 5 lines:
+ // FILE, LOST, the changed line plus one line of context in each direction
+ assert.equal(diffRows.length, 5);
+
+ topExpandCommonButton!.click();
+
+ await waitUntil(() => {
+ diffRows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
+ return diffRows.length === 14;
+ });
+ // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
+ assert.equal(diffRows.length, 14);
+ assert.include(diffRows[2].textContent, 'unchanged 1');
+ assert.include(diffRows[3].textContent, 'unchanged 2');
+ assert.include(diffRows[4].textContent, 'unchanged 3');
+ assert.include(diffRows[5].textContent, 'unchanged 4');
+ assert.include(diffRows[6].textContent, 'unchanged 5');
+ assert.include(diffRows[7].textContent, 'unchanged 6');
+ assert.include(diffRows[8].textContent, 'unchanged 7');
+ assert.include(diffRows[9].textContent, 'unchanged 8');
+ assert.include(diffRows[10].textContent, 'unchanged 9');
+ assert.include(diffRows[11].textContent, 'unchanged 10');
+ assert.include(diffRows[12].textContent, 'before');
+ assert.include(diffRows[12].textContent, 'after');
+ assert.include(diffRows[13].textContent, 'unchanged 11');
+ });
+
+ test('unhideLine shows the line with context', async () => {
+ dispatchStub.reset();
+ element.unhideLine(4, Side.LEFT);
+
+ await waitUntil(() => {
+ const rows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
+ return rows.length === 2 + 5 + 1 + 1 + 1;
+ });
+
+ const diffRows = diffTable.querySelectorAll('.diff-row');
+ // The first two are LOST and FILE line
+ // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+ // Because context expanders do not hide <3 lines, lines 1-2 will also
+ // be shown.
+ // Lines 6-9 continue to be hidden
+ assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+ assert.include(diffRows[2].textContent, 'unchanged 1');
+ assert.include(diffRows[3].textContent, 'unchanged 2');
+ assert.include(diffRows[4].textContent, 'unchanged 3');
+ assert.include(diffRows[5].textContent, 'unchanged 4');
+ assert.include(diffRows[6].textContent, 'unchanged 5');
+ assert.include(diffRows[7].textContent, 'unchanged 10');
+ assert.include(diffRows[8].textContent, 'before');
+ assert.include(diffRows[8].textContent, 'after');
+ assert.include(diffRows[9].textContent, 'unchanged 11');
+
+ await element.untilGroupsRendered();
+ const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+ assert.include(firedEventTypes, 'render-content');
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-image.ts
new file mode 100644
index 0000000..869d600
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder-image.ts
@@ -0,0 +1,283 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {FILE, RenderPreferences, Side} from '../../../api/diff';
+import '../../diff/gr-diff-image-viewer/gr-image-viewer';
+import {html, LitElement, nothing} from 'lit';
+import {property, query, state} from 'lit/decorators.js';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {isNewDiff, createElementDiff} from '../../diff/gr-diff/gr-diff-utils';
+
+// MIME types for images we allow showing. Do not include SVG, it can contain
+// arbitrary JavaScript.
+const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
+
+export class GrDiffBuilderImage extends GrDiffBuilder {
+ constructor(
+ diff: DiffInfo,
+ prefs: DiffPreferencesInfo,
+ outputEl: HTMLElement,
+ private readonly baseImage: ImageInfo | null,
+ private readonly revisionImage: ImageInfo | null,
+ renderPrefs?: RenderPreferences,
+ private readonly useNewImageDiffUi: boolean = false
+ ) {
+ super(diff, prefs, outputEl, [], renderPrefs);
+ }
+
+ override buildSectionElement(group: GrDiffGroup): HTMLElement {
+ const section = createElementDiff('tbody');
+ // Do not create a diff row for LOST.
+ if (group.lines[0].beforeNumber !== FILE) return section;
+ return super.buildSectionElement(group);
+ }
+
+ public renderImageDiff() {
+ const imageDiff = this.useNewImageDiffUi
+ ? this.createImageDiffNew()
+ : this.createImageDiffOld();
+ this.outputEl.appendChild(imageDiff);
+ }
+
+ private createImageDiffNew() {
+ // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
+ const imageDiff = document.createElement(
+ 'gr-diff-image-new'
+ ) as GrDiffImageNew;
+ imageDiff.automaticBlink = this.autoBlink();
+ imageDiff.baseImage = this.baseImage ?? undefined;
+ imageDiff.revisionImage = this.revisionImage ?? undefined;
+ return imageDiff;
+ }
+
+ private createImageDiffOld() {
+ // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
+ const imageDiff = document.createElement(
+ 'gr-diff-image-old'
+ ) as GrDiffImageOld;
+ imageDiff.baseImage = this.baseImage ?? undefined;
+ imageDiff.revisionImage = this.revisionImage ?? undefined;
+ return imageDiff;
+ }
+
+ private autoBlink(): boolean {
+ return !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
+ }
+
+ override updateRenderPrefs(renderPrefs: RenderPreferences) {
+ this.renderPrefs = renderPrefs;
+
+ // We have to update `imageDiff.automaticBlink` manually, because `this` is
+ // not a LitElement.
+ const imageDiff = this.outputEl.querySelector(
+ 'gr-diff-image-new'
+ ) as GrDiffImageNew;
+ if (imageDiff) imageDiff.automaticBlink = this.autoBlink();
+ }
+}
+
+class GrDiffImageNew extends LitElement {
+ @property() baseImage?: ImageInfo;
+
+ @property() revisionImage?: ImageInfo;
+
+ @property() automaticBlink = false;
+
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ override render() {
+ return html`
+ <tbody class="gr-diff image-diff">
+ <tr class="gr-diff">
+ <td class="gr-diff" colspan="4">
+ <gr-image-viewer
+ class="gr-diff"
+ .baseUrl=${imageSrc(this.baseImage)}
+ .revisionUrl=${imageSrc(this.revisionImage)}
+ .automaticBlink=${this.automaticBlink}
+ >
+ </gr-image-viewer>
+ </td>
+ </tr>
+ </tbody>
+ `;
+ }
+}
+
+class GrDiffImageOld extends LitElement {
+ @property() baseImage?: ImageInfo;
+
+ @property() revisionImage?: ImageInfo;
+
+ @query('img.left') baseImageEl?: HTMLImageElement;
+
+ @query('img.right') revisionImageEl?: HTMLImageElement;
+
+ @state() baseError?: string;
+
+ @state() revisionError?: string;
+
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ override render() {
+ return html`
+ <tbody class="gr-diff image-diff">
+ ${this.renderImagePairRow()} ${this.renderImageLabelRow()}
+ </tbody>
+ ${this.renderEndpoint()}
+ `;
+ }
+
+ private renderEndpoint() {
+ return html`
+ <tbody class="gr-diff endpoint">
+ <tr class="gr-diff">
+ <td class="gr-diff" colspan="4">
+ <gr-endpoint-decorator class="gr-diff" name="image-diff">
+ ${this.renderEndpointParam('baseImage', this.baseImage)}
+ ${this.renderEndpointParam('revisionImage', this.revisionImage)}
+ </gr-endpoint-decorator>
+ </td>
+ </tr>
+ </tbody>
+ `;
+ }
+
+ private renderEndpointParam(name: string, value: unknown) {
+ if (!value) return nothing;
+ return html`
+ <gr-endpoint-param class="gr-diff" name=${name} .value=${value}>
+ </gr-endpoint-param>
+ `;
+ }
+
+ private renderImagePairRow() {
+ return html`
+ <tr class="gr-diff">
+ <td class="gr-diff left lineNum blank"></td>
+ <td class="gr-diff left">${this.renderImage(Side.LEFT)}</td>
+ <td class="gr-diff right lineNum blank"></td>
+ <td class="gr-diff right">${this.renderImage(Side.RIGHT)}</td>
+ </tr>
+ `;
+ }
+
+ private renderImage(side: Side) {
+ const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+ if (!image) return nothing;
+ const error = side === Side.LEFT ? this.baseError : this.revisionError;
+ if (error) return error;
+ const src = imageSrc(image);
+ if (!src) return nothing;
+
+ return html`
+ <img
+ class="gr-diff ${side}"
+ src=${src}
+ @load=${this.handleLoad}
+ @error=${(e: Event) => this.handleError(e, side)}
+ >
+ </img>
+ `;
+ }
+
+ private handleLoad() {
+ this.requestUpdate();
+ }
+
+ private handleError(e: Event, side: Side) {
+ const msg = `[Image failed to load] ${e.type}`;
+ if (side === Side.LEFT) this.baseError = msg;
+ if (side === Side.RIGHT) this.revisionError = msg;
+ }
+
+ private renderImageLabelRow() {
+ return html`
+ <tr class="gr-diff">
+ <td class="gr-diff left lineNum blank"></td>
+ <td class="gr-diff left">
+ <label class="gr-diff">
+ ${this.renderName(this.baseImage?._name ?? '')}
+ <span class="gr-diff label">${this.imageLabel(Side.LEFT)}</span>
+ </label>
+ </td>
+ <td class="gr-diff right lineNum blank"></td>
+ <td class="gr-diff right">
+ <label class="gr-diff">
+ ${this.renderName(this.revisionImage?._name ?? '')}
+ <span class="gr-diff label"> ${this.imageLabel(Side.RIGHT)} </span>
+ </label>
+ </td>
+ </tr>
+ `;
+ }
+
+ private renderName(name?: string) {
+ const addNamesInLabel =
+ this.baseImage &&
+ this.revisionImage &&
+ this.baseImage._name !== this.revisionImage._name;
+ if (!addNamesInLabel) return nothing;
+ return html`
+ <span class="gr-diff name">${name}</span><br class="gr-diff" />
+ `;
+ }
+
+ private imageLabel(side: Side) {
+ const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+ const imageEl =
+ side === Side.LEFT ? this.baseImageEl : this.revisionImageEl;
+ if (image) {
+ const type = image.type ?? image._expectedType;
+ if (imageEl?.naturalWidth && imageEl.naturalHeight) {
+ return `${imageEl?.naturalWidth}×${imageEl.naturalHeight} ${type}`;
+ } else {
+ return type;
+ }
+ }
+ return 'No image';
+ }
+}
+
+function imageSrc(image?: ImageInfo): string {
+ return image && IMAGE_MIME_PATTERN.test(image.type)
+ ? `data:${image.type};base64,${image.body}`
+ : '';
+}
+
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+ customElements.define('gr-diff-image-new', GrDiffImageNew);
+ customElements.define('gr-diff-image-old', GrDiffImageOld);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff-image-new': LitElement;
+ 'gr-diff-image-old': LitElement;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder.ts
new file mode 100644
index 0000000..43c7775
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-builder.ts
@@ -0,0 +1,352 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import './gr-diff-section';
+import '../gr-context-controls/gr-context-controls';
+import {
+ ContentLoadNeededEventDetail,
+ DiffContextExpandedExternalDetail,
+ DiffViewMode,
+ LineNumber,
+ RenderPreferences,
+} from '../../../api/diff';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {BlameInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {DiffLayer, isDefined} from '../../../types/types';
+import {GrDiffRow} from './gr-diff-row';
+import {GrDiffSection} from './gr-diff-section';
+import {html, render} from 'lit';
+import {diffClasses} from '../../diff/gr-diff/gr-diff-utils';
+import {when} from 'lit/directives/when.js';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
+
+export interface DiffContextExpandedEventDetail
+ extends DiffContextExpandedExternalDetail {
+ /** The context control group that should be replaced by `groups`. */
+ contextGroup: GrDiffGroup;
+ groups: GrDiffGroup[];
+}
+
+declare global {
+ interface HTMLElementEventMap {
+ 'diff-context-expanded-internal-new': CustomEvent<DiffContextExpandedEventDetail>;
+ 'diff-context-expanded': CustomEvent<DiffContextExpandedExternalDetail>;
+ 'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
+ }
+}
+
+export function isImageDiffBuilder<T extends GrDiffBuilder>(
+ x: T | GrDiffBuilderImage | undefined
+): x is GrDiffBuilderImage {
+ return !!x && !!(x as GrDiffBuilderImage).renderImageDiff;
+}
+
+export function isBinaryDiffBuilder<T extends GrDiffBuilder>(
+ x: T | GrDiffBuilderBinary | undefined
+): x is GrDiffBuilderBinary {
+ return !!x && !!(x as GrDiffBuilderBinary).renderBinaryDiff;
+}
+
+/**
+ * The builder takes GrDiffGroups, and builds the corresponding DOM elements,
+ * called sections. Only the builder should add or remove sections from the
+ * DOM. Callers can use the ...group() methods to modify groups and thus cause
+ * rendering changes.
+ */
+export class GrDiffBuilder {
+ private readonly diff: DiffInfo;
+
+ readonly prefs: DiffPreferencesInfo;
+
+ renderPrefs?: RenderPreferences;
+
+ readonly outputEl: HTMLElement;
+
+ private groups: GrDiffGroup[];
+
+ private readonly layerUpdateListener: (
+ start: LineNumber,
+ end: LineNumber,
+ side: Side
+ ) => void;
+
+ constructor(
+ diff: DiffInfo,
+ prefs: DiffPreferencesInfo,
+ outputEl: HTMLElement,
+ readonly layers: DiffLayer[] = [],
+ renderPrefs?: RenderPreferences
+ ) {
+ this.diff = diff;
+ this.prefs = prefs;
+ this.renderPrefs = renderPrefs;
+ this.outputEl = outputEl;
+ this.groups = [];
+
+ if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+ throw Error('Invalid tab size from preferences.');
+ }
+
+ if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+ throw Error('Invalid line length from preferences.');
+ }
+
+ this.layerUpdateListener = (
+ start: LineNumber,
+ end: LineNumber,
+ side: Side
+ ) => this.renderContentByRange(start, end, side);
+ this.init();
+ }
+
+ getContentTdByLine(
+ lineNumber: LineNumber,
+ side?: Side
+ ): HTMLTableCellElement | undefined {
+ if (!side) return undefined;
+ const row = this.findRow(lineNumber, side);
+ return row?.getContentCell(side);
+ }
+
+ getLineElByNumber(
+ lineNumber: LineNumber,
+ side?: Side
+ ): HTMLTableCellElement | undefined {
+ if (!side) return undefined;
+ const row = this.findRow(lineNumber, side);
+ return row?.getLineNumberCell(side);
+ }
+
+ private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
+ if (!side || !lineNumber) return undefined;
+ const group = this.findGroup(side, lineNumber);
+ if (!group) return undefined;
+ const section = this.findSection(group);
+ if (!section) return undefined;
+ return section.findRow(side, lineNumber);
+ }
+
+ private getDiffRows() {
+ const sections = [
+ ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
+ ];
+ return sections.map(s => s.getDiffRows()).flat();
+ }
+
+ getLineNumberRows(): HTMLTableRowElement[] {
+ const rows = this.getDiffRows();
+ return rows.map(r => r.getTableRow()).filter(isDefined);
+ }
+
+ getLineNumEls(side: Side): HTMLTableCellElement[] {
+ const rows = this.getDiffRows();
+ return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+ }
+
+ /** This is used when layers initiate an update. */
+ renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
+ const groups = this.getGroupsByLineRange(start, end, side);
+ for (const group of groups) {
+ const section = this.findSection(group);
+ for (const row of section?.getDiffRows() ?? []) {
+ row.requestUpdate();
+ }
+ }
+ }
+
+ private findSection(group: GrDiffGroup): GrDiffSection | undefined {
+ const leftClass = `left-${group.startLine(Side.LEFT)}`;
+ const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+ return (
+ this.outputEl.querySelector<GrDiffSection>(
+ `gr-diff-section.${leftClass}.${rightClass}`
+ ) ?? undefined
+ );
+ }
+
+ buildSectionElement(group: GrDiffGroup): HTMLElement {
+ const leftCl = `left-${group.startLine(Side.LEFT)}`;
+ const rightCl = `right-${group.startLine(Side.RIGHT)}`;
+ const section = html`
+ <gr-diff-section
+ class="${leftCl} ${rightCl}"
+ .group=${group}
+ .diff=${this.diff}
+ .layers=${this.layers}
+ .diffPrefs=${this.prefs}
+ .renderPrefs=${this.renderPrefs}
+ ></gr-diff-section>
+ `;
+ // When using Lit's `render()` method it wants to be in full control of the
+ // element that it renders into, so we let it render into a temp element.
+ // Rendering into the diff table directly would interfere with
+ // `clearDiffContent()`for example.
+ // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+ // method into Lit's `render()` cycle.
+ const tempEl = document.createElement('div');
+ render(section, tempEl);
+ const sectionEl = tempEl.firstElementChild as GrDiffSection;
+ return sectionEl;
+ }
+
+ addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+ const colgroup = html`
+ <colgroup>
+ <col class=${diffClasses('blame')}></col>
+ ${when(
+ this.renderPrefs?.view_mode === DiffViewMode.UNIFIED,
+ () => html` ${this.renderUnifiedColumns(lineNumberWidth)} `,
+ () => html`
+ ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
+ ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
+ `
+ )}
+ </colgroup>
+ `;
+ // When using Lit's `render()` method it wants to be in full control of the
+ // element that it renders into, so we let it render into a temp element.
+ // Rendering into the diff table directly would interfere with
+ // `clearDiffContent()`for example.
+ // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+ // method into Lit's `render()` cycle.
+ const tempEl = document.createElement('div');
+ render(colgroup, tempEl);
+ const colgroupEl = tempEl.firstElementChild as HTMLElement;
+ outputEl.appendChild(colgroupEl);
+ }
+
+ private renderUnifiedColumns(lineNumberWidth: number) {
+ return html`
+ <col class=${diffClasses()} width=${lineNumberWidth}></col>
+ <col class=${diffClasses()} width=${lineNumberWidth}></col>
+ <col class=${diffClasses()}></col>
+ `;
+ }
+
+ private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
+ return html`
+ <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
+ <col class=${diffClasses(side, 'sign')}></col>
+ <col class=${diffClasses(side)}></col>
+ `;
+ }
+
+ /**
+ * This is meant to be called when the gr-diff component re-connects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with cleanup(), which is called
+ * when gr-diff disconnects.
+ */
+ init() {
+ this.cleanup();
+ for (const layer of this.layers) {
+ if (layer.addListener) {
+ layer.addListener(this.layerUpdateListener);
+ }
+ }
+ }
+
+ /**
+ * This is meant to be called when the gr-diff component disconnects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with init(), which is called when
+ * gr-diff re-connects.
+ */
+ cleanup() {
+ for (const layer of this.layers) {
+ if (layer.removeListener) {
+ layer.removeListener(this.layerUpdateListener);
+ }
+ }
+ }
+
+ addGroups(groups: readonly GrDiffGroup[]) {
+ for (const group of groups) {
+ this.groups.push(group);
+ this.emitGroup(group);
+ }
+ }
+
+ clearGroups() {
+ for (const deletedGroup of this.groups) {
+ deletedGroup.element?.remove();
+ }
+ this.groups = [];
+ }
+
+ replaceGroup(contextControl: GrDiffGroup, groups: readonly GrDiffGroup[]) {
+ const i = this.groups.indexOf(contextControl);
+ if (i === -1) throw new Error('cannot find context control group');
+
+ const contextControlSection = this.groups[i].element;
+ if (!contextControlSection) throw new Error('diff group element not set');
+
+ this.groups.splice(i, 1, ...groups);
+ for (const group of groups) {
+ this.emitGroup(group, contextControlSection);
+ }
+ if (contextControlSection) contextControlSection.remove();
+ }
+
+ findGroup(side: Side, line: LineNumber) {
+ return this.groups.find(group => group.containsLine(side, line));
+ }
+
+ private emitGroup(group: GrDiffGroup, beforeSection?: HTMLElement) {
+ const element = this.buildSectionElement(group);
+ this.outputEl.insertBefore(element, beforeSection ?? null);
+ group.element = element;
+ }
+
+ // visible for testing
+ getGroupsByLineRange(
+ startLine: LineNumber,
+ endLine: LineNumber,
+ side: Side
+ ): GrDiffGroup[] {
+ const startIndex = this.groups.findIndex(group =>
+ group.containsLine(side, startLine)
+ );
+ if (startIndex === -1) return [];
+ let endIndex = this.groups.findIndex(group =>
+ group.containsLine(side, endLine)
+ );
+ // Not all groups may have been processed yet (i.e. this.groups is still
+ // incomplete). In that case let's just return *all* groups until the end
+ // of the array.
+ if (endIndex === -1) endIndex = this.groups.length - 1;
+ // The filter preserves the legacy behavior to only return non-context
+ // groups
+ return this.groups
+ .slice(startIndex, endIndex + 1)
+ .filter(group => group.lines.length > 0);
+ }
+
+ /**
+ * Set the blame information for the diff. For any already-rendered line,
+ * re-render its blame cell content.
+ */
+ setBlame(blame: BlameInfo[]) {
+ for (const blameInfo of blame) {
+ for (const range of blameInfo.ranges) {
+ for (let line = range.start; line <= range.end; line++) {
+ const row = this.findRow(line, Side.LEFT);
+ if (row) row.blameInfo = blameInfo;
+ }
+ }
+ }
+ }
+
+ /**
+ * Only special builders need to implement this. The default is to
+ * just ignore it.
+ */
+ updateRenderPrefs(_: RenderPreferences) {}
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-row.ts
new file mode 100644
index 0000000..785f5a6
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-row.ts
@@ -0,0 +1,486 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement, nothing, TemplateResult} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createRef, Ref, ref} from 'lit/directives/ref.js';
+import {
+ DiffResponsiveMode,
+ Side,
+ LineNumber,
+ DiffLayer,
+ GrDiffLineType,
+ LOST,
+ FILE,
+} from '../../../api/diff';
+import {BlameInfo} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import './gr-diff-text';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {
+ diffClasses,
+ isNewDiff,
+ isResponsive,
+} from '../../diff/gr-diff/gr-diff-utils';
+
+export class GrDiffRow extends LitElement {
+ contentLeftRef: Ref<LitElement> = createRef();
+
+ contentRightRef: Ref<LitElement> = createRef();
+
+ contentCellLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+ contentCellRightRef: Ref<HTMLTableCellElement> = createRef();
+
+ lineNumberLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+ lineNumberRightRef: Ref<HTMLTableCellElement> = createRef();
+
+ blameCellRef: Ref<HTMLTableCellElement> = createRef();
+
+ tableRowRef: Ref<HTMLTableRowElement> = createRef();
+
+ @property({type: Object})
+ left?: GrDiffLine;
+
+ @property({type: Object})
+ right?: GrDiffLine;
+
+ @property({type: Object})
+ blameInfo?: BlameInfo;
+
+ @property({type: Object})
+ responsiveMode?: DiffResponsiveMode;
+
+ /**
+ * true: side-by-side diff
+ * false: unified diff
+ */
+ @property({type: Boolean})
+ unifiedDiff = false;
+
+ @property({type: Number})
+ tabSize = 2;
+
+ @property({type: Number})
+ lineLength = 80;
+
+ @property({type: Boolean})
+ hideFileCommentButton = false;
+
+ @property({type: Object})
+ layers: DiffLayer[] = [];
+
+ /**
+ * Semantic DOM diff testing does not work with just table fragments, so when
+ * running such tests the render() method has to wrap the DOM in a proper
+ * <table> element.
+ */
+ @state()
+ addTableWrapperForTesting = false;
+
+ /**
+ * Keeps track of whether diff layers have already been applied to the diff
+ * row. That happens after the DOM has been created in the `updated()`
+ * lifecycle callback.
+ *
+ * Once layers are applied, the diff row requires two rendering passes for an
+ * update: 1. Remove all <gr-diff-text> elements and their layer manipulated
+ * DOMs. 2. Add fresh <gr-diff-text> elements and let layers re-apply in
+ * `updated()`.
+ */
+ private layersApplied = false;
+
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ override updated() {
+ if (this.layersApplied) {
+ // <gr-diff-text> elements have been removed during rendering. Let's start
+ // another rendering cycle with freshly created <gr-diff-text> elements.
+ this.updateComplete.then(() => {
+ this.layersApplied = false;
+ this.requestUpdate();
+ });
+ } else {
+ this.updateLayers(Side.LEFT);
+ this.updateLayers(Side.RIGHT);
+ }
+ }
+
+ /**
+ * The diff layers API is designed to let layers manipulate the DOM. So we
+ * have to apply them after the rendering cycle is done (`updated()`). But
+ * when re-rendering a row that already has layers applied, then we have to
+ * first wipe away <gr-diff-text>. This is achieved by
+ * `this.layersApplied = true`.
+ */
+ private async updateLayers(side: Side) {
+ const line = this.line(side);
+ const contentEl = this.contentRef(side).value;
+ const lineNumberEl = this.lineNumberRef(side).value;
+ if (!line || !contentEl || !lineNumberEl) return;
+
+ // We have to wait for the <gr-diff-text> child component to finish
+ // rendering before we can apply layers, which will re-write the HTML.
+ await contentEl?.updateComplete;
+ for (const layer of this.layers) {
+ if (typeof layer.annotate === 'function') {
+ layer.annotate(contentEl, lineNumberEl, line, side);
+ }
+ }
+ // At this point we consider layers applied. So as soon as <gr-diff-row>
+ // enters a new rendering cycle <gr-diff-text> elements will be removed.
+ this.layersApplied = true;
+ }
+
+ override render() {
+ if (!this.left || !this.right) return;
+ const classes = this.unifiedDiff ? ['unified'] : ['side-by-side'];
+ const unifiedType = this.unifiedType();
+ if (this.unifiedDiff && unifiedType) classes.push(unifiedType);
+ const row = html`
+ <tr
+ ${ref(this.tableRowRef)}
+ class=${diffClasses('diff-row', ...classes)}
+ left-type=${ifDefined(this.getType(Side.LEFT))}
+ right-type=${ifDefined(this.getType(Side.RIGHT))}
+ tabindex="-1"
+ aria-labelledby=${this.ariaLabelIds()}
+ >
+ ${this.renderBlameCell()} ${this.renderLineNumberCell(Side.LEFT)}
+ ${this.renderSignCell(Side.LEFT)} ${this.renderContentCell(Side.LEFT)}
+ ${this.renderLineNumberCell(Side.RIGHT)}
+ ${this.renderSignCell(Side.RIGHT)} ${this.renderContentCell(Side.RIGHT)}
+ </tr>
+ ${this.renderPostLineSlot(Side.LEFT)}
+ ${this.renderPostLineSlot(Side.RIGHT)}
+ `;
+ if (this.addTableWrapperForTesting) {
+ return html`<table>
+ ${row}
+ </table>`;
+ }
+ return row;
+ }
+
+ private ariaLabelIds() {
+ const ids: string[] = [];
+ ids.push(this.lineNumberId(Side.LEFT));
+ if (!this.unifiedDiff) ids.push(this.contentId(Side.LEFT));
+ ids.push(this.lineNumberId(Side.RIGHT));
+ if (!this.unifiedDiff) ids.push(this.contentId(Side.RIGHT));
+ if (this.unifiedDiff) ids.push(this.contentId(this.unifiedSide()));
+ return ids.filter(id => !!id).join(' ');
+ }
+
+ private lineNumberId(side: Side): string {
+ const lineNumber = this.lineNumber(side);
+ if (!lineNumber) return '';
+ return `${side}-button-${lineNumber}`;
+ }
+
+ private unifiedSide() {
+ const isLeft = this.line(Side.RIGHT)?.type === GrDiffLineType.BLANK;
+ return isLeft ? Side.LEFT : Side.RIGHT;
+ }
+
+ private contentId(side: Side): string {
+ const lineNumber = this.lineNumber(side);
+ if (!lineNumber) return '';
+ return `${side}-content-${lineNumber}`;
+ }
+
+ getTableRow(): HTMLTableRowElement | undefined {
+ return this.tableRowRef.value;
+ }
+
+ getLineNumberCell(side: Side): HTMLTableCellElement | undefined {
+ return this.lineNumberRef(side).value;
+ }
+
+ getContentCell(side: Side) {
+ return this.contentCellRef(side)?.value;
+ }
+
+ getBlameCell() {
+ return this.blameCellRef.value;
+ }
+
+ private renderBlameCell() {
+ // td.blame has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`
+ <td
+ ${ref(this.blameCellRef)}
+ class=${diffClasses('blame')}
+ data-line-number=${this.left?.beforeNumber ?? 0}
+ >${this.renderBlameElement()}</td>
+ `;
+ }
+
+ private renderBlameElement() {
+ const lineNum = this.left?.beforeNumber;
+ const commit = this.blameInfo;
+ if (!lineNum || !commit) return;
+
+ const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+ const extras: string[] = [];
+ if (isStartOfRange) extras.push('startOfRange');
+ const date = new Date(commit.time * 1000).toLocaleDateString();
+ const shortName = commit.author.split(' ')[0];
+ const url = `${getBaseUrl()}/q/${commit.id}`;
+
+ // td.blame has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`<span class=${diffClasses(...extras)}
+ ><a href=${url} class=${diffClasses('blameDate')}>${date}</a
+ ><span class=${diffClasses('blameAuthor')}> ${shortName}</span
+ ><gr-hovercard class=${diffClasses()}>
+ <span class=${diffClasses('blameHoverCard')}>
+ Commit ${commit.id}<br />
+ Author: ${commit.author}<br />
+ Date: ${date}<br />
+ <br />
+ ${commit.commit_msg}
+ </span>
+ </gr-hovercard
+ ></span>`;
+ }
+
+ private renderLineNumberCell(side: Side): TemplateResult {
+ const line = this.line(side);
+ const lineNumber = this.lineNumber(side);
+ const isBlank = line?.type === GrDiffLineType.BLANK;
+ if (!line || !lineNumber || isBlank || this.layersApplied) {
+ const blankClass = isBlank && !this.unifiedDiff ? 'blankLineNum' : '';
+ return html`<td
+ ${ref(this.lineNumberRef(side))}
+ class=${diffClasses(side, blankClass)}
+ ></td>`;
+ }
+
+ return html`<td
+ ${ref(this.lineNumberRef(side))}
+ class=${diffClasses(side, 'lineNum')}
+ data-value=${lineNumber}
+ >
+ ${this.renderLineNumberButton(line, lineNumber, side)}
+ </td>`;
+ }
+
+ private renderLineNumberButton(
+ line: GrDiffLine,
+ lineNumber: LineNumber,
+ side: Side
+ ) {
+ if (this.hideFileCommentButton && lineNumber === FILE) return;
+ if (lineNumber === LOST) return;
+ // .lineNumButton has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`
+ <button
+ id=${this.lineNumberId(side)}
+ class=${diffClasses('lineNumButton', side)}
+ tabindex="-1"
+ data-value=${lineNumber}
+ aria-label=${ifDefined(
+ this.computeLineNumberAriaLabel(line, lineNumber)
+ )}
+ @mouseenter=${() =>
+ fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
+ @mouseleave=${() =>
+ fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
+ >${lineNumber === FILE ? 'File' : lineNumber.toString()}</button>
+ `;
+ }
+
+ private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
+ if (lineNumber === FILE) return 'Add file comment';
+
+ // Add aria-labels for valid line numbers.
+ // For unified diff, this method will be called with number set to 0 for
+ // the empty line number column for added/removed lines. This should not
+ // be announced to the screenreader.
+ if (lineNumber === LOST || lineNumber <= 0) return undefined;
+
+ switch (line.type) {
+ case GrDiffLineType.REMOVE:
+ return `${lineNumber} removed`;
+ case GrDiffLineType.ADD:
+ return `${lineNumber} added`;
+ case GrDiffLineType.BOTH:
+ case GrDiffLineType.BLANK:
+ return `${lineNumber} unmodified`;
+ }
+ }
+
+ private renderContentCell(side: Side) {
+ let line = this.line(side);
+ if (this.unifiedDiff) {
+ if (side === Side.LEFT) return nothing;
+ if (line?.type === GrDiffLineType.BLANK) {
+ side = Side.LEFT;
+ line = this.line(Side.LEFT);
+ }
+ }
+ const lineNumber = this.lineNumber(side);
+ assertIsDefined(line, 'line');
+ const extras: string[] = [line.type, side];
+ if (line.type !== GrDiffLineType.BLANK) extras.push('content');
+ if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+ if (line.beforeNumber === FILE) extras.push('file');
+ if (line.beforeNumber === LOST) extras.push('lost');
+
+ // .content has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`
+ <td
+ ${ref(this.contentCellRef(side))}
+ class=${diffClasses(...extras)}
+ @mouseenter=${() => {
+ if (lineNumber)
+ fire(this, 'line-mouse-enter', {lineNum: lineNumber, side});
+ }}
+ @mouseleave=${() => {
+ if (lineNumber)
+ fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
+ }}
+ >${this.renderText(side)}${this.renderThreadGroup(side)}</td>
+ `;
+ }
+
+ private renderSignCell(side: Side) {
+ if (this.unifiedDiff) return nothing;
+ const line = this.line(side);
+ assertIsDefined(line, 'line');
+ const isBlank = line.type === GrDiffLineType.BLANK;
+ const isAdd = line.type === GrDiffLineType.ADD && side === Side.RIGHT;
+ const isRemove = line.type === GrDiffLineType.REMOVE && side === Side.LEFT;
+ const extras: string[] = ['sign', side];
+ if (isBlank) extras.push('blank');
+ if (isAdd) extras.push('add');
+ if (isRemove) extras.push('remove');
+ if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+
+ const sign = isAdd ? '+' : isRemove ? '-' : '';
+ return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
+ }
+
+ private renderThreadGroup(side: Side) {
+ const lineNumber = this.lineNumber(side);
+ if (!lineNumber) return nothing;
+ return html`<div class="thread-group" data-side=${side}>
+ <slot name="${side}-${lineNumber}"></slot>
+ ${this.renderSecondSlot()}
+ </div>`;
+ }
+
+ private renderSecondSlot() {
+ if (!this.unifiedDiff) return nothing;
+ if (this.line(Side.LEFT)?.type !== GrDiffLineType.BOTH) return nothing;
+ return html`<slot
+ name="${Side.LEFT}-${this.lineNumber(Side.LEFT)}"
+ ></slot>`;
+ }
+
+ private contentRef(side: Side) {
+ return side === Side.LEFT ? this.contentLeftRef : this.contentRightRef;
+ }
+
+ private contentCellRef(side: Side) {
+ return side === Side.LEFT
+ ? this.contentCellLeftRef
+ : this.contentCellRightRef;
+ }
+
+ private lineNumberRef(side: Side) {
+ return side === Side.LEFT
+ ? this.lineNumberLeftRef
+ : this.lineNumberRightRef;
+ }
+
+ private lineNumber(side: Side) {
+ return this.line(side)?.lineNumber(side);
+ }
+
+ private line(side: Side) {
+ return side === Side.LEFT ? this.left : this.right;
+ }
+
+ private getType(side?: Side): string | undefined {
+ if (this.unifiedDiff) return undefined;
+ if (side === Side.LEFT) return this.left?.type;
+ if (side === Side.RIGHT) return this.right?.type;
+ return undefined;
+ }
+
+ private unifiedType() {
+ return this.left?.type === GrDiffLineType.BLANK
+ ? this.right?.type
+ : this.left?.type;
+ }
+
+ /**
+ * Returns a 'div' element containing the supplied |text| as its innerText,
+ * with '\t' characters expanded to a width determined by |tabSize|, and the
+ * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+ * desired.
+ */
+ private renderText(side: Side) {
+ const line = this.line(side);
+ const lineNumber = this.lineNumber(side);
+ if (lineNumber === FILE || lineNumber === LOST) return;
+
+ // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
+ // another rendering cycle will be initiated in `updated()`.
+ // prettier-ignore
+ const textElement = line?.text && !this.layersApplied
+ ? html`<gr-diff-text
+ ${ref(this.contentRef(side))}
+ .text=${line?.text}
+ .tabSize=${this.tabSize}
+ .lineLimit=${this.lineLength}
+ .isResponsive=${isResponsive(this.responsiveMode)}
+ ></gr-diff-text>` : '';
+ // .content has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`<div
+ class=${diffClasses('contentText')}
+ data-side=${ifDefined(side)}
+ id=${this.contentId(side)}
+ >${textElement}</div>`;
+ }
+
+ private renderPostLineSlot(side: Side) {
+ const lineNumber = this.lineNumber(side);
+ return lineNumber && Number.isInteger(lineNumber)
+ ? html`<slot name="post-${side}-line-${lineNumber}"></slot>`
+ : nothing;
+ }
+}
+
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+ customElements.define('gr-diff-row', GrDiffRow);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff-row': LitElement;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-row_test.ts
new file mode 100644
index 0000000..42d30aa
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-row_test.ts
@@ -0,0 +1,271 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-row';
+import {GrDiffRow} from './gr-diff-row';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-row test', () => {
+ let element: GrDiffRow;
+
+ setup(async () => {
+ element = await fixture<GrDiffRow>(html`<gr-diff-row></gr-diff-row>`);
+ element.addTableWrapperForTesting = true;
+ await element.updateComplete;
+ });
+
+ test('both', async () => {
+ const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = line;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ </tbody>
+ </table>
+ `
+ );
+ });
+
+ test('both unified', async () => {
+ const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = line;
+ element.unifiedDiff = true;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ aria-labelledby="left-button-1 right-button-1 right-content-1"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ </tbody>
+ </table>
+ `
+ );
+ });
+
+ test('add', async () => {
+ const line = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+ line.text = 'lorem ipsum';
+ element.left = new GrDiffLine(GrDiffLineType.BLANK);
+ element.right = line;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ aria-labelledby="right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 added"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ <slot name="post-right-line-1"></slot>
+ </tr>
+ </tbody>
+ </table>
+ `
+ );
+ });
+
+ test('remove', async () => {
+ const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = new GrDiffLine(GrDiffLineType.BLANK);
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ aria-labelledby="left-button-1 left-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 removed"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ <slot name="post-left-line-1"></slot>
+ </tbody>
+ </table>
+ `
+ );
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-section.ts
new file mode 100644
index 0000000..d80abd8
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-section.ts
@@ -0,0 +1,256 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {
+ DiffInfo,
+ DiffLayer,
+ DiffViewMode,
+ RenderPreferences,
+ Side,
+ LineNumber,
+ DiffPreferencesInfo,
+} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {
+ isNewDiff,
+ diffClasses,
+ getResponsiveMode,
+} from '../../diff/gr-diff/gr-diff-utils';
+import {GrDiffRow} from './gr-diff-row';
+import '../gr-context-controls/gr-context-controls-section';
+import '../gr-context-controls/gr-context-controls';
+import '../../diff/gr-range-header/gr-range-header';
+import './gr-diff-row';
+import {when} from 'lit/directives/when.js';
+import {fire} from '../../../utils/event-util';
+import {countLines} from '../../../utils/diff-util';
+
+export class GrDiffSection extends LitElement {
+ @property({type: Object})
+ group?: GrDiffGroup;
+
+ @property({type: Object})
+ diff?: DiffInfo;
+
+ @property({type: Object})
+ renderPrefs?: RenderPreferences;
+
+ @property({type: Object})
+ diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ layers: DiffLayer[] = [];
+
+ /**
+ * Semantic DOM diff testing does not work with just table fragments, so when
+ * running such tests the render() method has to wrap the DOM in a proper
+ * <table> element.
+ */
+ @state()
+ addTableWrapperForTesting = false;
+
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ override render() {
+ if (!this.group) return;
+ const extras: string[] = [];
+ extras.push('section');
+ extras.push(this.group.type);
+ if (this.group.isTotal()) extras.push('total');
+ if (this.group.dueToRebase) extras.push('dueToRebase');
+ if (this.group.moveDetails) extras.push('dueToMove');
+ if (this.group.moveDetails?.changed) extras.push('changed');
+ if (this.group.ignoredWhitespaceOnly) extras.push('ignoredWhitespaceOnly');
+
+ const pairs = this.getLinePairs();
+ const responsiveMode = getResponsiveMode(this.diffPrefs, this.renderPrefs);
+ const hideFileCommentButton =
+ this.diffPrefs?.show_file_comment_button === false ||
+ this.renderPrefs?.show_file_comment_button === false;
+ const body = html`
+ <tbody class=${diffClasses(...extras)}>
+ ${this.renderContextControls()} ${this.renderMoveControls()}
+ ${pairs.map(pair => {
+ const leftCl = `left-${pair.left.lineNumber(Side.LEFT)}`;
+ const rightCl = `right-${pair.right.lineNumber(Side.RIGHT)}`;
+ return html`
+ <gr-diff-row
+ class="${leftCl} ${rightCl}"
+ .left=${pair.left}
+ .right=${pair.right}
+ .layers=${this.layers}
+ .lineLength=${this.diffPrefs?.line_length ?? 80}
+ .tabSize=${this.diffPrefs?.tab_size ?? 2}
+ .unifiedDiff=${this.isUnifiedDiff()}
+ .responsiveMode=${responsiveMode}
+ .hideFileCommentButton=${hideFileCommentButton}
+ >
+ </gr-diff-row>
+ `;
+ })}
+ </tbody>
+ `;
+ if (this.addTableWrapperForTesting) {
+ return html`<table>
+ ${body}
+ </table>`;
+ }
+ return body;
+ }
+
+ private isUnifiedDiff() {
+ return this.renderPrefs?.view_mode === DiffViewMode.UNIFIED;
+ }
+
+ getLinePairs() {
+ if (!this.group) return [];
+ const isControl = this.group.type === GrDiffGroupType.CONTEXT_CONTROL;
+ if (isControl) return [];
+ return this.isUnifiedDiff()
+ ? this.group.getUnifiedPairs()
+ : this.group.getSideBySidePairs();
+ }
+
+ getDiffRows(): GrDiffRow[] {
+ return [...this.querySelectorAll<GrDiffRow>('gr-diff-row')];
+ }
+
+ private renderContextControls() {
+ if (this.group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+
+ const leftStart = this.group.lineRange.left.start_line;
+ const leftEnd = this.group.lineRange.left.end_line;
+ const firstGroupIsSkipped = !!this.group.contextGroups[0].skip;
+ const lastGroupIsSkipped =
+ !!this.group.contextGroups[this.group.contextGroups.length - 1].skip;
+ const lineCountLeft = countLines(this.diff, Side.LEFT);
+ const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
+ const showAbove =
+ (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+ const showBelow = leftEnd < lineCountLeft && !lastGroupIsSkipped;
+
+ return html`
+ <gr-context-controls-section
+ .showAbove=${showAbove}
+ .showBelow=${showBelow}
+ .group=${this.group}
+ .diff=${this.diff}
+ .renderPrefs=${this.renderPrefs}
+ >
+ </gr-context-controls-section>
+ `;
+ }
+
+ findRow(side: Side, lineNumber: LineNumber): GrDiffRow | undefined {
+ return (
+ this.querySelector<GrDiffRow>(`gr-diff-row.${side}-${lineNumber}`) ??
+ undefined
+ );
+ }
+
+ private renderMoveControls() {
+ if (!this.group?.moveDetails) return;
+ const movedIn = this.group.adds.length > 0;
+ const plainCell = html`<td class=${diffClasses()}></td>`;
+ const signCell = html`<td class=${diffClasses('sign')}></td>`;
+ const lineNumberCell = html`
+ <td class=${diffClasses('moveControlsLineNumCol')}></td>
+ `;
+ const moveCell = html`
+ <td class=${diffClasses('moveHeader')}>
+ <gr-range-header class=${diffClasses()} icon="move_item">
+ ${this.renderMoveDescription(movedIn)}
+ </gr-range-header>
+ </td>
+ `;
+ return html`
+ <tr
+ class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
+ >
+ ${when(
+ this.isUnifiedDiff(),
+ () => html`${lineNumberCell} ${lineNumberCell} ${moveCell}`,
+ () => html`${lineNumberCell} ${signCell}
+ ${movedIn ? plainCell : moveCell} ${lineNumberCell} ${signCell}
+ ${movedIn ? moveCell : plainCell}`
+ )}
+ </tr>
+ `;
+ }
+
+ private renderMoveDescription(movedIn: boolean) {
+ if (this.group?.moveDetails?.range) {
+ const {changed, range} = this.group.moveDetails;
+ const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+ const andChangedLabel = changed ? 'and changed ' : '';
+ const direction = movedIn ? 'from' : 'to';
+ const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+ return html`
+ <div class=${diffClasses()}>
+ <span class=${diffClasses()}>${textLabel}</span>
+ ${this.renderMovedLineAnchor(range.start, otherSide)}
+ <span class=${diffClasses()}> - </span>
+ ${this.renderMovedLineAnchor(range.end, otherSide)}
+ </div>
+ `;
+ }
+
+ return html`
+ <div class=${diffClasses()}>
+ <span class=${diffClasses()}
+ >${movedIn ? 'Moved in' : 'Moved out'}</span
+ >
+ </div>
+ `;
+ }
+
+ private renderMovedLineAnchor(line: number, side: Side) {
+ const listener = (e: MouseEvent) => {
+ e.preventDefault();
+ this.handleMovedLineAnchorClick(e.target, side, line);
+ };
+ // `href` is not actually used but important for Screen Readers
+ return html`
+ <a class=${diffClasses()} href=${`#${line}`} @click=${listener}
+ >${line}</a
+ >
+ `;
+ }
+
+ private handleMovedLineAnchorClick(
+ anchor: EventTarget | null,
+ side: Side,
+ line: number
+ ) {
+ if (!anchor) return;
+ fire(anchor, 'moved-link-clicked', {
+ lineNum: line,
+ side,
+ });
+ }
+}
+
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+ customElements.define('gr-diff-section', GrDiffSection);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff-section': LitElement;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-section_test.ts
new file mode 100644
index 0000000..381f9b2
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-section_test.ts
@@ -0,0 +1,315 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-section';
+import {GrDiffSection} from './gr-diff-section';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
+import {waitQueryAndAssert} from '../../../test/test-utils';
+
+suite('gr-diff-section test', () => {
+ let element: GrDiffSection;
+
+ setup(async () => {
+ element = await fixture<GrDiffSection>(
+ html`<gr-diff-section></gr-diff-section>`
+ );
+ element.addTableWrapperForTesting = true;
+ await element.updateComplete;
+ });
+
+ suite('move controls', async () => {
+ setup(async () => {
+ const lines = [new GrDiffLine(GrDiffLineType.BOTH, 1, 1)];
+ lines[0].text = 'asdf';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines,
+ moveDetails: {changed: false, range: {start: 1, end: 2}},
+ });
+ element.group = group;
+ await element.updateComplete;
+ });
+
+ test('side-by-side', async () => {
+ const row = await waitQueryAndAssert(element, 'tr.moveControls');
+ // Semantic dom diff has a problem with just comparing table rows or
+ // cells directly. So as a workaround put the row into an empty test
+ // table.
+ const testTable = document.createElement('table');
+ testTable.appendChild(row);
+ assert.dom.equal(
+ testTable,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr class="gr-diff moveControls movedOut">
+ <td class="gr-diff moveControlsLineNumCol"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff moveHeader">
+ <gr-range-header class="gr-diff" icon="move_item">
+ <div class="gr-diff">
+ <span class="gr-diff"> Moved to lines </span>
+ <a class="gr-diff" href="#1"> 1 </a>
+ <span class="gr-diff"> - </span>
+ <a class="gr-diff" href="#2"> 2 </a>
+ </div>
+ </gr-range-header>
+ </td>
+ <td class="gr-diff moveControlsLineNumCol"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ </tbody>
+ </table>
+ `,
+ {}
+ );
+ });
+
+ test('unified', async () => {
+ element.renderPrefs = {
+ ...element.renderPrefs,
+ view_mode: DiffViewMode.UNIFIED,
+ };
+ const row = await waitQueryAndAssert(element, 'tr.moveControls');
+ // Semantic dom diff has a problem with just comparing table rows or
+ // cells directly. So as a workaround put the row into an empty test
+ // table.
+ const testTable = document.createElement('table');
+ testTable.appendChild(row);
+ assert.dom.equal(
+ testTable,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr class="gr-diff moveControls movedOut">
+ <td class="gr-diff moveControlsLineNumCol"></td>
+ <td class="gr-diff moveControlsLineNumCol"></td>
+ <td class="gr-diff moveHeader">
+ <gr-range-header class="gr-diff" icon="move_item">
+ <div class="gr-diff">
+ <span class="gr-diff"> Moved to lines </span>
+ <a class="gr-diff" href="#1"> 1 </a>
+ <span class="gr-diff"> - </span>
+ <a class="gr-diff" href="#2"> 2 </a>
+ </div>
+ </gr-range-header>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ `,
+ {}
+ );
+ });
+ });
+
+ test('3 normal unchanged rows', async () => {
+ const lines = [
+ new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+ new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+ new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+ ];
+ lines[0].text = 'asdf';
+ lines[1].text = 'qwer';
+ lines[2].text = 'zxcv';
+ const group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+ element.group = group;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ <table>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ `
+ );
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-text.ts
new file mode 100644
index 0000000..c447ee4
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-text.ts
@@ -0,0 +1,157 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, TemplateResult} from 'lit';
+import {property} from 'lit/decorators.js';
+import {styleMap} from 'lit/directives/style-map.js';
+import {isNewDiff, diffClasses} from '../../diff/gr-diff/gr-diff-utils';
+
+const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+const TAB = '\t';
+
+/**
+ * Renders one line of code on one side of the diff. It takes care of:
+ * - Tabs, see `tabSize` property.
+ * - Line Breaks, see `lineLimit` property.
+ * - Surrogate Character Pairs.
+ *
+ * Note that other modifications to the code in a gr-diff is done via diff
+ * layers, which manipulate the DOM directly. So `gr-diff-text` is thrown
+ * away and re-rendered every time something changes by its parent
+ * `gr-diff-row`. So don't bother to optimize this component for re-rendering
+ * performance. And be aware that building longer lived local state is not
+ * useful here.
+ */
+export class GrDiffText extends LitElement {
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ @property({type: String})
+ text = '';
+
+ @property({type: Boolean})
+ isResponsive = false;
+
+ @property({type: Number})
+ tabSize = 2;
+
+ @property({type: Number})
+ lineLimit = 80;
+
+ /** Temporary state while rendering. */
+ private textOffset = 0;
+
+ /** Temporary state while rendering. */
+ private columnPos = 0;
+
+ /** Temporary state while rendering. */
+ private pieces: (string | TemplateResult)[] = [];
+
+ /** Split up the string into tabs, surrogate pairs and regular segments. */
+ override render() {
+ this.textOffset = 0;
+ this.columnPos = 0;
+ this.pieces = [];
+ const splitByTab = this.text.split('\t');
+ for (let i = 0; i < splitByTab.length; i++) {
+ const splitBySurrogate = splitByTab[i].split(SURROGATE_PAIR);
+ for (let j = 0; j < splitBySurrogate.length; j++) {
+ this.renderSegment(splitBySurrogate[j]);
+ if (j < splitBySurrogate.length - 1) {
+ this.renderSurrogatePair();
+ }
+ }
+ if (i < splitByTab.length - 1) {
+ this.renderTab();
+ }
+ }
+ if (this.textOffset !== this.text.length) throw new Error('unfinished');
+ return this.pieces;
+ }
+
+ /** Render regular characters, but insert line breaks appropriately. */
+ private renderSegment(segment: string) {
+ let segmentOffset = 0;
+ while (segmentOffset < segment.length) {
+ const newOffset = Math.min(
+ segment.length,
+ segmentOffset + this.lineLimit - this.columnPos
+ );
+ this.renderString(segment.substring(segmentOffset, newOffset));
+ segmentOffset = newOffset;
+ if (segmentOffset < segment.length && this.columnPos === this.lineLimit) {
+ this.renderLineBreak();
+ }
+ }
+ }
+
+ /** Render regular characters. */
+ private renderString(s: string) {
+ if (s.length === 0) return;
+ this.pieces.push(s);
+ this.textOffset += s.length;
+ this.columnPos += s.length;
+ if (this.columnPos > this.lineLimit) throw new Error('over line limit');
+ }
+
+ /** Render a tab character. */
+ private renderTab() {
+ let tabSize = this.tabSize - (this.columnPos % this.tabSize);
+ if (this.columnPos + tabSize > this.lineLimit) {
+ this.renderLineBreak();
+ tabSize = this.tabSize;
+ }
+ const piece = html`<span
+ class=${diffClasses('tab')}
+ style=${styleMap({'tab-size': `${tabSize}`})}
+ >${TAB}</span
+ >`;
+ this.pieces.push(piece);
+ this.textOffset += 1;
+ this.columnPos += tabSize;
+ }
+
+ /** Render a surrogate pair: string length is 2, but is just 1 char. */
+ private renderSurrogatePair() {
+ if (this.columnPos === this.lineLimit) {
+ this.renderLineBreak();
+ }
+ this.pieces.push(this.text.substring(this.textOffset, this.textOffset + 2));
+ this.textOffset += 2;
+ this.columnPos += 1;
+ }
+
+ /** Render a line break, don't advance text offset, reset col position. */
+ private renderLineBreak() {
+ if (this.isResponsive) {
+ this.pieces.push(html`<wbr class=${diffClasses()}></wbr>`);
+ } else {
+ this.pieces.push(html`<span class=${diffClasses('br')}></span>`);
+ }
+ // this.textOffset += 0;
+ this.columnPos = 0;
+ }
+}
+
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+ customElements.define('gr-diff-text', GrDiffText);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff-text': LitElement;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-text_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-text_test.ts
new file mode 100644
index 0000000..344240b
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-builder/gr-diff-text_test.ts
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+
+import './gr-diff-text';
+import {GrDiffText} from './gr-diff-text';
+import {fixture, html, assert} from '@open-wc/testing';
+
+const LINE_BREAK = '<span class="gr-diff br"></span>';
+
+const LINE_BREAK_WBR = '<wbr class="gr-diff"></wbr>';
+
+const TAB = '<span class="" style=""></span>';
+
+const TAB_IGNORE = ['class', 'style'];
+
+suite('gr-diff-text test', () => {
+ let element: GrDiffText;
+
+ setup(async () => {
+ element = await fixture<GrDiffText>(
+ html`<gr-diff-text tabsize="4" linelimit="10"></gr-diff-text>`
+ );
+ });
+
+ const check = async (
+ text: string,
+ html: string,
+ ignoreAttributes: string[] = []
+ ) => {
+ element.text = text;
+ await element.updateComplete;
+ assert.lightDom.equal(element, html, {ignoreAttributes});
+ };
+
+ suite('lit rendering', () => {
+ test('renderText newlines 1', async () => {
+ await check('abcdef', 'abcdef');
+ await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK}aaaaaaaaaa`);
+ });
+
+ test('renderText newlines 1 responsive', async () => {
+ element.isResponsive = true;
+ await check('abcdef', 'abcdef');
+ await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK_WBR}aaaaaaaaaa`);
+ });
+
+ test('renderText newlines 2', async () => {
+ await check(
+ '<span class="thumbsup">👍</span>',
+ '<span clas' +
+ LINE_BREAK +
+ 's="thumbsu' +
+ LINE_BREAK +
+ 'p">👍</span' +
+ LINE_BREAK +
+ '>'
+ );
+ });
+
+ test('renderText newlines 3', async () => {
+ await check(
+ '01234\t56789',
+ '01234' + TAB + '56' + LINE_BREAK + '789',
+ TAB_IGNORE
+ );
+ });
+
+ test('renderText newlines 4', async () => {
+ element.lineLimit = 20;
+ await element.updateComplete;
+ await check(
+ '👍'.repeat(58),
+ '👍'.repeat(20) +
+ LINE_BREAK +
+ '👍'.repeat(20) +
+ LINE_BREAK +
+ '👍'.repeat(18)
+ );
+ });
+
+ test('tab wrapper style', async () => {
+ element.lineLimit = 100;
+ element.tabSize = 4;
+ await check(
+ '\t',
+ /* HTML */ '<span class="gr-diff tab" style="tab-size:4;"></span>'
+ );
+ await check(
+ 'abc\t',
+ /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:1;"></span>'
+ );
+
+ element.tabSize = 8;
+ await check(
+ '\t',
+ /* HTML */ '<span class="gr-diff tab" style="tab-size:8;"></span>'
+ );
+ await check(
+ 'abc\t',
+ /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:5;"></span>'
+ );
+ });
+
+ test('tab wrapper insertion', async () => {
+ await check('abc\tdef', 'abc' + TAB + 'def', TAB_IGNORE);
+ });
+
+ test('escaping HTML', async () => {
+ element.lineLimit = 100;
+ await element.updateComplete;
+ await check(
+ '<script>alert("XSS");<' + '/script>',
+ '<script>alert("XSS");</script>'
+ );
+ await check('& < > " \' / `', '& < > " \' / `');
+ });
+
+ test('text length with tabs and unicode', async () => {
+ async function expectTextLength(
+ text: string,
+ tabSize: number,
+ expected: number
+ ) {
+ element.text = text;
+ element.tabSize = tabSize;
+ element.lineLimit = expected;
+ await element.updateComplete;
+ const result = element.innerHTML;
+
+ // Must not contain a line break.
+ assert.isNotOk(element.querySelector('span.br'));
+
+ // Increasing the line limit by 1 should not change anything.
+ element.lineLimit = expected + 1;
+ await element.updateComplete;
+ const resultPlusOne = element.innerHTML;
+ assert.equal(resultPlusOne, result);
+
+ // Increasing the line limit to infinity should not change anything.
+ element.lineLimit = Infinity;
+ await element.updateComplete;
+ const resultInf = element.innerHTML;
+ assert.equal(resultInf, result);
+
+ // Decreasing the line limit by 1 should introduce a line break.
+ element.lineLimit = expected + 1;
+ await element.updateComplete;
+ assert.isNotOk(element.querySelector('span.br'));
+ }
+ expectTextLength('12345', 4, 5);
+ expectTextLength('\t\t12', 4, 10);
+ expectTextLength('abc💢123', 4, 7);
+ expectTextLength('abc\t', 8, 8);
+ expectTextLength('abc\t\t', 10, 20);
+ expectTextLength('', 10, 0);
+ // 17 Thai combining chars.
+ expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+ expectTextLength('abc\tde', 10, 12);
+ expectTextLength('abc\tde\t', 10, 20);
+ expectTextLength('\t\t\t\t\t', 20, 100);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-cursor/gr-diff-cursor.ts
new file mode 100644
index 0000000..6a32afb
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-cursor/gr-diff-cursor.ts
@@ -0,0 +1,593 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Subscription} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
+import {
+ DiffViewMode,
+ GrDiffCursor as GrDiffCursorApi,
+ GrDiffLineType,
+ LineNumber,
+ LineSelectedEventDetail,
+} from '../../../api/diff';
+import {ScrollMode, Side} from '../../../constants/constants';
+import {toggleClass} from '../../../utils/dom-util';
+import {
+ GrCursorManager,
+ isTargetable,
+} from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
+import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {fire} from '../../../utils/event-util';
+
+type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
+
+const LEFT_SIDE_CLASS = 'target-side-left';
+const RIGHT_SIDE_CLASS = 'target-side-right';
+
+interface Address {
+ leftSide: boolean;
+ number: number;
+}
+
+/**
+ * From <tr> diff row go up to <tbody> diff chunk.
+ *
+ * In Lit based diff there is a <gr-diff-row> element in between the two.
+ */
+export function fromRowToChunk(
+ rowEl: HTMLElement
+): HTMLTableSectionElement | undefined {
+ const parent = rowEl.parentElement;
+ if (!parent) return undefined;
+ if (parent.tagName === 'TBODY') {
+ return parent as HTMLTableSectionElement;
+ }
+
+ const grandParent = parent.parentElement;
+ if (!grandParent) return undefined;
+ if (grandParent.tagName === 'TBODY') {
+ return grandParent as HTMLTableSectionElement;
+ }
+
+ return undefined;
+}
+
+/** A subset of the GrDiff API that the cursor is using. */
+export interface GrDiffCursorable extends HTMLElement {
+ isRangeSelected(): boolean;
+ createRangeComment(): void;
+ getCursorStops(): Stop[];
+ path?: string;
+}
+
+export class GrDiffCursor implements GrDiffCursorApi {
+ private preventAutoScrollOnManualScroll = false;
+
+ set side(side: Side) {
+ if (this.sideInternal === side) {
+ return;
+ }
+ if (this.sideInternal && this.diffRow) {
+ this.fireCursorMoved(
+ 'line-cursor-moved-out',
+ this.diffRow,
+ this.sideInternal
+ );
+ }
+ this.sideInternal = side;
+ this.updateSideClass();
+ if (this.diffRow) {
+ this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+ }
+ }
+
+ get side(): Side {
+ return this.sideInternal;
+ }
+
+ private sideInternal = Side.RIGHT;
+
+ set diffRow(diffRow: HTMLElement | undefined) {
+ if (this.diffRowInternal) {
+ this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+ this.fireCursorMoved(
+ 'line-cursor-moved-out',
+ this.diffRowInternal,
+ this.side
+ );
+ }
+ this.diffRowInternal = diffRow;
+
+ this.updateSideClass();
+ if (this.diffRow) {
+ this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+ }
+ }
+
+ get diffRow(): HTMLElement | undefined {
+ return this.diffRowInternal;
+ }
+
+ private diffRowInternal?: HTMLElement;
+
+ private diffs: GrDiffCursorable[] = [];
+
+ /**
+ * If set, the cursor will attempt to move to the line number (instead of
+ * the first chunk) the next time the diff renders. It is set back to null
+ * when used. It should be only used if you want the line to be focused
+ * after initialization of the component and page should scroll
+ * to that position. This parameter should be set at most for one gr-diff
+ * element in the page.
+ */
+ initialLineNumber: number | null = null;
+
+ // visible for testing
+ cursorManager = new GrCursorManager();
+
+ private targetSubscription?: Subscription;
+
+ constructor() {
+ this.cursorManager.cursorTargetClass = 'target-row';
+ this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
+ this.cursorManager.focusOnMove = true;
+
+ window.addEventListener('scroll', this._boundHandleWindowScroll);
+ this.targetSubscription = this.cursorManager.target$.subscribe(target => {
+ this.diffRow = target || undefined;
+ });
+ }
+
+ dispose() {
+ this.cursorManager.unsetCursor();
+ if (this.targetSubscription) this.targetSubscription.unsubscribe();
+ window.removeEventListener('scroll', this._boundHandleWindowScroll);
+ }
+
+ // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+ isAtStart() {
+ return this.cursorManager.isAtStart();
+ }
+
+ // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+ isAtEnd() {
+ return this.cursorManager.isAtEnd();
+ }
+
+ moveLeft() {
+ this.side = Side.LEFT;
+ if (this._isTargetBlank()) {
+ this.moveUp();
+ }
+ }
+
+ moveRight() {
+ this.side = Side.RIGHT;
+ if (this._isTargetBlank()) {
+ this.moveUp();
+ }
+ }
+
+ moveDown() {
+ if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ return this.cursorManager.next({
+ filter: (row: Element) => this._rowHasSide(row),
+ });
+ } else {
+ return this.cursorManager.next();
+ }
+ }
+
+ moveUp() {
+ if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ return this.cursorManager.previous({
+ filter: (row: Element) => this._rowHasSide(row),
+ });
+ } else {
+ return this.cursorManager.previous();
+ }
+ }
+
+ moveToVisibleArea() {
+ if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ this.cursorManager.moveToVisibleArea((row: Element) =>
+ this._rowHasSide(row)
+ );
+ } else {
+ this.cursorManager.moveToVisibleArea();
+ }
+ }
+
+ moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
+ const result = this.cursorManager.next({
+ filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+ getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
+ clipToTop,
+ });
+ this._fixSide();
+ return result;
+ }
+
+ moveToPreviousChunk(): CursorMoveResult {
+ const result = this.cursorManager.previous({
+ filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+ });
+ this._fixSide();
+ return result;
+ }
+
+ moveToNextCommentThread(): CursorMoveResult {
+ if (this.isAtEnd()) {
+ return CursorMoveResult.CLIPPED;
+ }
+ const result = this.cursorManager.next({
+ filter: (row: HTMLElement) => this._rowHasThread(row),
+ });
+ this._fixSide();
+ return result;
+ }
+
+ moveToPreviousCommentThread(): CursorMoveResult {
+ const result = this.cursorManager.previous({
+ filter: (row: HTMLElement) => this._rowHasThread(row),
+ });
+ this._fixSide();
+ return result;
+ }
+
+ moveToLineNumber(
+ number: LineNumber,
+ side: Side,
+ path?: string,
+ intentionalMove?: boolean
+ ) {
+ const row = this._findRowByNumberAndFile(number, side, path);
+ if (row) {
+ this.side = side;
+ this.cursorManager.setCursor(row, undefined, intentionalMove);
+ }
+ }
+
+ /**
+ * Get the line number element targeted by the cursor row and side.
+ */
+ getTargetLineElement(): HTMLElement | null {
+ let lineElSelector = '.lineNum';
+
+ if (!this.diffRow) {
+ return null;
+ }
+
+ if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
+ }
+
+ return this.diffRow.querySelector(lineElSelector);
+ }
+
+ getTargetDiffElement(): GrDiff | null {
+ if (!this.diffRow) return null;
+
+ const hostOwner = this.diffRow.getRootNode() as ShadowRoot;
+ if (hostOwner?.host?.tagName === 'GR-DIFF') {
+ return hostOwner.host as GrDiff;
+ }
+ return null;
+ }
+
+ moveToFirstChunk() {
+ this.cursorManager.moveToStart();
+ if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+ this.moveToNextChunk(true);
+ } else {
+ this._fixSide();
+ }
+ }
+
+ moveToLastChunk() {
+ this.cursorManager.moveToEnd();
+ if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+ this.moveToPreviousChunk();
+ } else {
+ this._fixSide();
+ }
+ }
+
+ /**
+ * Move the cursor either to initialLineNumber or the first chunk and
+ * reset scroll behavior.
+ *
+ * This may grab the focus from the app.
+ *
+ * If you do not want to move the cursor or grab focus, and just want to
+ * reset the scroll behavior, use reInitAndUpdateStops() instead.
+ */
+ reInitCursor() {
+ this._updateStops();
+ if (!this.diffRow) {
+ // does not scroll during init unless requested
+ this.cursorManager.scrollMode = this.initialLineNumber
+ ? ScrollMode.KEEP_VISIBLE
+ : ScrollMode.NEVER;
+ if (this.initialLineNumber) {
+ this.moveToLineNumber(this.initialLineNumber, this.side);
+ this.initialLineNumber = null;
+ } else {
+ this.moveToFirstChunk();
+ }
+ }
+ this.resetScrollMode();
+ }
+
+ resetScrollMode() {
+ this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
+ }
+
+ private _boundHandleWindowScroll = () => {
+ if (this.preventAutoScrollOnManualScroll) {
+ this.cursorManager.scrollMode = ScrollMode.NEVER;
+ this.cursorManager.focusOnMove = false;
+ this.preventAutoScrollOnManualScroll = false;
+ }
+ };
+
+ reInitAndUpdateStops() {
+ this.resetScrollMode();
+ this._updateStops();
+ }
+
+ private boundHandleDiffLoadingChanged = () => {
+ this._updateStops();
+ };
+
+ private _boundHandleDiffRenderStart = () => {
+ this.preventAutoScrollOnManualScroll = true;
+ };
+
+ private _boundHandleDiffRenderContent = () => {
+ this._updateStops();
+ // When done rendering, turn focus on move and automatic scrolling back on
+ this.cursorManager.focusOnMove = true;
+ this.preventAutoScrollOnManualScroll = false;
+ };
+
+ private _boundHandleDiffLineSelected = (
+ e: CustomEvent<LineSelectedEventDetail>
+ ) => {
+ this.moveToLineNumber(e.detail.number, e.detail.side, e.detail.path);
+ };
+
+ createCommentInPlace() {
+ const diffWithRangeSelected = this.diffs.find(diff =>
+ diff.isRangeSelected()
+ );
+ if (diffWithRangeSelected) {
+ diffWithRangeSelected.createRangeComment();
+ } else {
+ const line = this.getTargetLineElement();
+ const diff = this.getTargetDiffElement();
+ if (diff && line) {
+ diff.addDraftAtLine(line);
+ }
+ }
+ }
+
+ /**
+ * Get an object describing the location of the cursor. Such as
+ * {leftSide: false, number: 123} for line 123 of the revision, or
+ * {leftSide: true, number: 321} for line 321 of the base patch.
+ * Returns null if an address is not available.
+ */
+ getAddress(): Address | null {
+ if (!this.diffRow) {
+ return null;
+ }
+ // Get the line-number cell targeted by the cursor. If the mode is unified
+ // then prefer the revision cell if available.
+ return this.getAddressFor(this.diffRow, this.side);
+ }
+
+ private getAddressFor(diffRow: HTMLElement, side: Side): Address | null {
+ let cell;
+ if (this._getViewMode() === DiffViewMode.UNIFIED) {
+ cell = diffRow.querySelector('.lineNum.right');
+ if (!cell) {
+ cell = diffRow.querySelector('.lineNum.left');
+ }
+ } else {
+ cell = diffRow.querySelector('.lineNum.' + side);
+ }
+ if (!cell) {
+ return null;
+ }
+
+ const number = cell.getAttribute('data-value');
+ if (!number || number === 'FILE') {
+ return null;
+ }
+
+ return {
+ leftSide: cell.matches('.left'),
+ number: Number(number),
+ };
+ }
+
+ _getViewMode() {
+ if (!this.diffRow) {
+ return null;
+ }
+
+ if (this.diffRow.classList.contains('side-by-side')) {
+ return DiffViewMode.SIDE_BY_SIDE;
+ } else {
+ return DiffViewMode.UNIFIED;
+ }
+ }
+
+ _rowHasSide(row: Element) {
+ const selector =
+ (this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
+ return !!row.querySelector(selector);
+ }
+
+ _isFirstRowOfChunk(row: HTMLElement) {
+ const chunk = fromRowToChunk(row);
+ if (!chunk) return false;
+
+ const isInDeltaChunk = chunk.classList.contains('delta');
+ if (!isInDeltaChunk) return false;
+
+ const firstRow = chunk.querySelector('tr:not(.moveControls)');
+ return firstRow === row;
+ }
+
+ _rowHasThread(row: HTMLElement): boolean {
+ const slots = [
+ ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
+ ];
+ return slots.some(slot => slot.assignedElements().length > 0);
+ }
+
+ /**
+ * If we jumped to a row where there is no content on the current side then
+ * switch to the alternate side.
+ */
+ _fixSide() {
+ if (
+ this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+ this._isTargetBlank()
+ ) {
+ this.side = this.side === Side.LEFT ? Side.RIGHT : Side.LEFT;
+ }
+ }
+
+ _isTargetBlank() {
+ if (!this.diffRow) {
+ return false;
+ }
+
+ const actions = this._getActionsForRow();
+ return (
+ (this.side === Side.LEFT && !actions.left) ||
+ (this.side === Side.RIGHT && !actions.right)
+ );
+ }
+
+ private fireCursorMoved(
+ event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
+ row: HTMLElement,
+ side: Side
+ ) {
+ const address = this.getAddressFor(row, side);
+ if (address) {
+ const {leftSide, number} = address;
+ fire(row, event, {
+ lineNum: number,
+ side: leftSide ? Side.LEFT : Side.RIGHT,
+ });
+ }
+ }
+
+ private updateSideClass() {
+ if (!this.diffRow) {
+ return;
+ }
+ toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT);
+ toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
+ }
+
+ _isActionType(type: GrDiffRowType) {
+ return (
+ type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
+ );
+ }
+
+ _getActionsForRow() {
+ const actions = {left: false, right: false};
+ if (this.diffRow) {
+ actions.left = this._isActionType(
+ this.diffRow.getAttribute('left-type') as GrDiffRowType
+ );
+ actions.right = this._isActionType(
+ this.diffRow.getAttribute('right-type') as GrDiffRowType
+ );
+ }
+ return actions;
+ }
+
+ _updateStops() {
+ this.cursorManager.stops = this.diffs.reduce(
+ (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
+ []
+ );
+ }
+
+ replaceDiffs(diffs: GrDiffCursorable[]) {
+ for (const diff of this.diffs) {
+ this.removeEventListeners(diff);
+ }
+ this.diffs = [];
+ for (const diff of diffs) {
+ this.addEventListeners(diff);
+ }
+ this.diffs.push(...diffs);
+ this._updateStops();
+ }
+
+ unregisterDiff(diff: GrDiffCursorable) {
+ // This can happen during destruction - just don't unregister then.
+ if (!this.diffs) return;
+ const i = this.diffs.indexOf(diff);
+ if (i !== -1) {
+ this.diffs.splice(i, 1);
+ }
+ }
+
+ private removeEventListeners(diff: GrDiffCursorable) {
+ diff.removeEventListener(
+ 'loading-changed',
+ this.boundHandleDiffLoadingChanged
+ );
+ diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
+ diff.removeEventListener(
+ 'render-content',
+ this._boundHandleDiffRenderContent
+ );
+ diff.removeEventListener(
+ 'line-selected',
+ this._boundHandleDiffLineSelected
+ );
+ }
+
+ private addEventListeners(diff: GrDiffCursorable) {
+ diff.addEventListener(
+ 'loading-changed',
+ this.boundHandleDiffLoadingChanged
+ );
+ diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
+ diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
+ diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
+ }
+
+ _findRowByNumberAndFile(
+ targetNumber: LineNumber,
+ side: Side,
+ path?: string
+ ): HTMLElement | undefined {
+ let stops: Array<HTMLElement | AbortStop>;
+ if (path) {
+ const diff = this.diffs.filter(diff => diff.path === path)[0];
+ stops = diff.getCursorStops();
+ } else {
+ stops = this.cursorManager.stops;
+ }
+ // Sadly needed for type narrowing to understand that the result is always
+ // targetable.
+ const targetableStops: HTMLElement[] = stops.filter(isTargetable);
+ const selector = `.lineNum.${side}[data-value="${targetNumber}"]`;
+ return targetableStops.find(stop => stop.querySelector(selector));
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-cursor/gr-diff-cursor_test.ts
new file mode 100644
index 0000000..61f8551
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -0,0 +1,694 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff';
+import './gr-diff-cursor';
+import {fixture, html, assert} from '@open-wc/testing';
+import {
+ mockPromise,
+ queryAll,
+ queryAndAssert,
+ waitUntil,
+} from '../../../test/test-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffCursor} from './gr-diff-cursor';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {DiffInfo, DiffViewMode, Side} from '../../../api/diff';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {assertIsDefined} from '../../../utils/common-util';
+
+suite('gr-diff-cursor tests', () => {
+ let cursor: GrDiffCursor;
+ let diffElement: GrDiff;
+ let diff: DiffInfo;
+
+ setup(async () => {
+ diffElement = await fixture(html`<gr-diff></gr-diff>`);
+ cursor = new GrDiffCursor();
+
+ // Register the diff with the cursor.
+ cursor.replaceDiffs([diffElement]);
+
+ diffElement.loggedIn = false;
+ diffElement.path = 'some/path.ts';
+ const promise = mockPromise();
+ const setupDone = () => {
+ cursor._updateStops();
+ cursor.moveToFirstChunk();
+ diffElement.removeEventListener('render', setupDone);
+ promise.resolve();
+ };
+ diffElement.addEventListener('render', setupDone);
+
+ diff = createDiff();
+ diffElement.prefs = createDefaultDiffPrefs();
+ diffElement.diff = diff;
+ await promise;
+ });
+
+ test('diff cursor functionality (side-by-side)', () => {
+ assert.isOk(cursor.diffRow);
+
+ const deltaRows = queryAll<HTMLTableRowElement>(
+ diffElement,
+ '.section.delta tr.diff-row'
+ );
+ assert.equal(cursor.diffRow, deltaRows[0]);
+
+ cursor.moveDown();
+
+ assert.notEqual(cursor.diffRow, deltaRows[0]);
+ assert.equal(cursor.diffRow, deltaRows[1]);
+
+ cursor.moveUp();
+
+ assert.notEqual(cursor.diffRow, deltaRows[1]);
+ assert.equal(cursor.diffRow, deltaRows[0]);
+ });
+
+ test('moveToFirstChunk', async () => {
+ const diff: DiffInfo = {
+ meta_a: {
+ name: 'lorem-ipsum.txt',
+ content_type: 'text/plain',
+ lines: 3,
+ },
+ meta_b: {
+ name: 'lorem-ipsum.txt',
+ content_type: 'text/plain',
+ lines: 3,
+ },
+ intraline_status: 'OK',
+ change_type: 'MODIFIED',
+ diff_header: [
+ 'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+ 'index b2adcf4..554ae49 100644',
+ '--- a/lorem-ipsum.txt',
+ '+++ b/lorem-ipsum.txt',
+ ],
+ content: [
+ {b: ['new line 1']},
+ {ab: ['unchanged line']},
+ {a: ['old line 2']},
+ {ab: ['more unchanged lines']},
+ ],
+ };
+
+ diffElement.diff = diff;
+ // The file comment button, if present, is a cursor stop. Ensure
+ // moveToFirstChunk() works correctly even if the button is not shown.
+ diffElement.prefs!.show_file_comment_button = false;
+ await waitForEventOnce(diffElement, 'render');
+
+ cursor._updateStops();
+
+ const chunks = [
+ ...queryAll(diffElement, '.section.delta'),
+ ] as HTMLElement[];
+ assert.equal(chunks.length, 2);
+
+ const rows = [
+ ...queryAll(diffElement, '.section.delta tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(rows.length, 2);
+
+ // Verify it works on fresh diff.
+ cursor.moveToFirstChunk();
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[0]);
+ assert.equal(cursor.side, Side.RIGHT);
+
+ // Verify it works from other cursor positions.
+ cursor.moveToNextChunk();
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[1]);
+ assert.equal(cursor.side, Side.LEFT);
+
+ cursor.moveToFirstChunk();
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[0]);
+ assert.equal(cursor.side, Side.RIGHT);
+ });
+
+ test('moveToLastChunk', async () => {
+ const diff: DiffInfo = {
+ meta_a: {
+ name: 'lorem-ipsum.txt',
+ content_type: 'text/plain',
+ lines: 3,
+ },
+ meta_b: {
+ name: 'lorem-ipsum.txt',
+ content_type: 'text/plain',
+ lines: 3,
+ },
+ intraline_status: 'OK',
+ change_type: 'MODIFIED',
+ diff_header: [
+ 'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+ 'index b2adcf4..554ae49 100644',
+ '--- a/lorem-ipsum.txt',
+ '+++ b/lorem-ipsum.txt',
+ ],
+ content: [
+ {ab: ['unchanged line']},
+ {a: ['old line 2']},
+ {ab: ['more unchanged lines']},
+ {b: ['new line 3']},
+ ],
+ };
+
+ diffElement.diff = diff;
+ await waitForEventOnce(diffElement, 'render');
+ cursor._updateStops();
+
+ const chunks = [
+ ...queryAll(diffElement, '.section.delta'),
+ ] as HTMLElement[];
+ assert.equal(chunks.length, 2);
+
+ const rows = [
+ ...queryAll(diffElement, '.section.delta tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(rows.length, 2);
+
+ // Verify it works on fresh diff.
+ cursor.moveToLastChunk();
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[1]);
+ assert.equal(cursor.side, Side.RIGHT);
+
+ // Verify it works from other cursor positions.
+ cursor.moveToPreviousChunk();
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[0]);
+ assert.equal(cursor.side, Side.LEFT);
+
+ cursor.moveToLastChunk();
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[1]);
+ assert.equal(cursor.side, Side.RIGHT);
+ });
+
+ test('cursor scroll behavior', () => {
+ assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+
+ diffElement.dispatchEvent(new Event('render-start'));
+ assert.isTrue(cursor.cursorManager.focusOnMove);
+
+ window.dispatchEvent(new Event('scroll'));
+ assert.equal(cursor.cursorManager.scrollMode, 'never');
+ assert.isFalse(cursor.cursorManager.focusOnMove);
+
+ diffElement.dispatchEvent(new Event('render-content'));
+ assert.isTrue(cursor.cursorManager.focusOnMove);
+
+ cursor.reInitCursor();
+ assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+ });
+
+ test('moves to selected line', () => {
+ const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+
+ diffElement.dispatchEvent(
+ new CustomEvent('line-selected', {
+ detail: {number: '123', side: Side.RIGHT, path: 'some/file'},
+ })
+ );
+
+ assert.isTrue(moveToNumStub.called);
+ assert.equal(moveToNumStub.lastCall.args[0], 123);
+ assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+ assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+ });
+
+ suite('unified diff', () => {
+ setup(async () => {
+ diffElement.viewMode = DiffViewMode.UNIFIED;
+ await waitForEventOnce(diffElement, 'render');
+ cursor.reInitCursor();
+ });
+
+ test('diff cursor functionality (unified)', () => {
+ assert.isOk(cursor.diffRow);
+
+ const rows = [
+ ...queryAll(diffElement, '.section.delta tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(cursor.diffRow, rows[0]);
+
+ cursor.moveDown();
+
+ assert.notEqual(cursor.diffRow, rows[0]);
+ assert.equal(cursor.diffRow, rows[1]);
+
+ cursor.moveUp();
+
+ assert.notEqual(cursor.diffRow, rows[1]);
+ assert.equal(cursor.diffRow, rows[0]);
+ });
+ });
+
+ test('cursor side functionality', () => {
+ // The side only applies to side-by-side mode, which should be the default
+ // mode.
+ assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+ const rows = [
+ ...queryAll(diffElement, '.section tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(rows.length, 50);
+ const deltaRows = [
+ ...queryAll(diffElement, '.section.delta tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(deltaRows.length, 14);
+ const indexFirstDelta = rows.indexOf(deltaRows[0]);
+ const rowBeforeFirstDelta = rows[indexFirstDelta - 1];
+
+ // Because the first delta in this diff is on the right, it should be set
+ // to the right side.
+ assert.equal(cursor.side, Side.RIGHT);
+ assert.equal(cursor.diffRow, deltaRows[0]);
+ const firstIndex = cursor.cursorManager.index;
+
+ // Move the side to the left. Because this delta only has a right side, we
+ // should be moved up to the previous line where there is content on the
+ // right. The previous row is part of the previous section.
+ cursor.moveLeft();
+
+ assert.equal(cursor.side, Side.LEFT);
+ assert.notEqual(cursor.diffRow, rows[0]);
+ assert.equal(cursor.diffRow, rowBeforeFirstDelta);
+ assert.equal(cursor.cursorManager.index, firstIndex - 1);
+
+ // If we move down, we should skip everything in the first delta because
+ // we are on the left side and the first delta has no content on the left.
+ cursor.moveDown();
+
+ assert.equal(cursor.side, Side.LEFT);
+ assert.notEqual(cursor.diffRow, rowBeforeFirstDelta);
+ assert.notEqual(cursor.diffRow, rows[0]);
+ assert.isTrue(cursor.cursorManager.index > firstIndex);
+ });
+
+ test('chunk skip functionality', () => {
+ const deltaChunks = [...queryAll(diffElement, 'tbody.section.delta')];
+
+ // We should be initialized to the first chunk. Since this chunk only has
+ // content on the right side, our side should be right.
+ assert.equal(cursor.diffRow, deltaChunks[0].querySelector('tr'));
+ assert.equal(cursor.side, Side.RIGHT);
+
+ // Move to the next chunk.
+ cursor.moveToNextChunk();
+
+ // Since this chunk only has content on the left side. we should have been
+ // automatically moved over.
+ assert.equal(cursor.diffRow, deltaChunks[1].querySelector('tr'));
+ assert.equal(cursor.side, Side.LEFT);
+ });
+
+ suite('moved chunks without line range)', () => {
+ setup(async () => {
+ const promise = mockPromise();
+ const renderHandler = function () {
+ diffElement.removeEventListener('render', renderHandler);
+ cursor.reInitCursor();
+ promise.resolve();
+ };
+ diffElement.addEventListener('render', renderHandler);
+ diffElement.diff = {
+ ...diff,
+ content: [
+ {
+ ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+ },
+ {
+ b: [
+ 'Nullam neque, ligula ac, id blandit.',
+ 'Sagittis tincidunt torquent, tempor nunc amet.',
+ 'At rhoncus id.',
+ ],
+ move_details: {changed: false},
+ },
+ {
+ ab: ['Sem nascetur, erat ut, non in.'],
+ },
+ {
+ a: [
+ 'Nullam neque, ligula ac, id blandit.',
+ 'Sagittis tincidunt torquent, tempor nunc amet.',
+ 'At rhoncus id.',
+ ],
+ move_details: {changed: false},
+ },
+ {
+ ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+ },
+ ],
+ };
+ await promise;
+ });
+
+ test('renders moveControls with simple descriptions', () => {
+ const [movedIn, movedOut] = [
+ ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
+ ];
+ assert.include(movedIn.innerText, 'Moved in');
+ assert.include(movedOut.innerText, 'Moved out');
+ });
+ });
+
+ suite('moved chunks (moveDetails)', () => {
+ setup(async () => {
+ const promise = mockPromise();
+ const renderHandler = function () {
+ diffElement.removeEventListener('render', renderHandler);
+ cursor.reInitCursor();
+ promise.resolve();
+ };
+ diffElement.addEventListener('render', renderHandler);
+ diffElement.diff = {
+ ...diff,
+ content: [
+ {
+ ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+ },
+ {
+ b: [
+ 'Nullam neque, ligula ac, id blandit.',
+ 'Sagittis tincidunt torquent, tempor nunc amet.',
+ 'At rhoncus id.',
+ ],
+ move_details: {changed: false, range: {start: 4, end: 6}},
+ },
+ {
+ ab: ['Sem nascetur, erat ut, non in.'],
+ },
+ {
+ a: [
+ 'Nullam neque, ligula ac, id blandit.',
+ 'Sagittis tincidunt torquent, tempor nunc amet.',
+ 'At rhoncus id.',
+ ],
+ move_details: {changed: false, range: {start: 2, end: 4}},
+ },
+ {
+ ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+ },
+ ],
+ };
+ await promise;
+ });
+
+ test('renders moveControls with simple descriptions', () => {
+ const [movedIn, movedOut] = [
+ ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
+ ];
+ assert.include(movedIn.innerText, 'Moved from lines 4 - 6');
+ assert.include(movedOut.innerText, 'Moved to lines 2 - 4');
+ });
+
+ test('startLineAnchor of movedIn chunk fires events', async () => {
+ const [movedIn] = [...queryAll(diffElement, '.dueToMove .moveControls')];
+ const [startLineAnchor] = movedIn.querySelectorAll('a');
+
+ const promise = mockPromise();
+ const onMovedLinkClicked = (e: CustomEvent) => {
+ assert.deepEqual(e.detail, {lineNum: 4, side: Side.LEFT});
+ promise.resolve();
+ };
+ assert.equal(startLineAnchor.textContent, '4');
+ startLineAnchor.addEventListener(
+ 'moved-link-clicked',
+ onMovedLinkClicked
+ );
+ startLineAnchor.click();
+ await promise;
+ });
+
+ test('endLineAnchor of movedOut fires events', async () => {
+ const [, movedOut] = [
+ ...queryAll(diffElement, '.dueToMove .moveControls'),
+ ];
+ const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+ const promise = mockPromise();
+ const onMovedLinkClicked = (e: CustomEvent) => {
+ assert.deepEqual(e.detail, {lineNum: 4, side: Side.RIGHT});
+ promise.resolve();
+ };
+ assert.equal(endLineAnchor.textContent, '4');
+ endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+ endLineAnchor.click();
+ await promise;
+ });
+ });
+
+ test('initialLineNumber not provided', async () => {
+ let scrollBehaviorDuringMove;
+ const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+ const moveToChunkStub = sinon
+ .stub(cursor, 'moveToFirstChunk')
+ .callsFake(() => {
+ scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+ });
+ diffElement.diff = createDiff();
+ await diffElement.updateComplete;
+ await waitForEventOnce(diffElement, 'render');
+ cursor.reInitCursor();
+ assert.isFalse(moveToNumStub.called);
+ assert.isTrue(moveToChunkStub.called);
+ assert.equal(scrollBehaviorDuringMove, 'never');
+ assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+ });
+
+ test('initialLineNumber provided', async () => {
+ let scrollBehaviorDuringMove;
+ const moveToNumStub = sinon
+ .stub(cursor, 'moveToLineNumber')
+ .callsFake(() => {
+ scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+ });
+ const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+ cursor.initialLineNumber = 10;
+ cursor.side = Side.RIGHT;
+
+ diffElement.diff = createDiff();
+ await diffElement.updateComplete;
+ await waitForEventOnce(diffElement, 'render');
+ cursor.reInitCursor();
+ assert.isFalse(moveToChunkStub.called);
+ assert.isTrue(moveToNumStub.called);
+ assert.equal(moveToNumStub.lastCall.args[0], 10);
+ assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+ assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+ assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+ });
+
+ test('getTargetDiffElement', () => {
+ cursor.initialLineNumber = 1;
+ assert.isTrue(!!cursor.diffRow);
+ assert.equal(cursor.getTargetDiffElement(), diffElement);
+ });
+
+ suite('createCommentInPlace', () => {
+ setup(() => {
+ diffElement.loggedIn = true;
+ });
+
+ test('adds new draft for selected line on the left', async () => {
+ cursor.moveToLineNumber(2, Side.LEFT);
+ const promise = mockPromise();
+ diffElement.addEventListener('create-comment', e => {
+ const {lineNum, range, side} = e.detail;
+ assert.equal(lineNum, 2);
+ assert.equal(range, undefined);
+ assert.equal(side, Side.LEFT);
+ promise.resolve();
+ });
+ cursor.createCommentInPlace();
+ await promise;
+ });
+
+ test('adds draft for selected line on the right', async () => {
+ cursor.moveToLineNumber(4, Side.RIGHT);
+ const promise = mockPromise();
+ diffElement.addEventListener('create-comment', e => {
+ const {lineNum, range, side} = e.detail;
+ assert.equal(lineNum, 4);
+ assert.equal(range, undefined);
+ assert.equal(side, Side.RIGHT);
+ promise.resolve();
+ });
+ cursor.createCommentInPlace();
+ await promise;
+ });
+
+ test('creates comment for range if selected', async () => {
+ const someRange = {
+ start_line: 2,
+ start_character: 3,
+ end_line: 6,
+ end_character: 1,
+ };
+ diffElement.highlights.selectedRange = {
+ side: Side.RIGHT,
+ range: someRange,
+ };
+ const promise = mockPromise();
+ diffElement.addEventListener('create-comment', e => {
+ const {lineNum, range, side} = e.detail;
+ assert.equal(lineNum, 6);
+ assert.equal(range, someRange);
+ assert.equal(side, Side.RIGHT);
+ promise.resolve();
+ });
+ cursor.createCommentInPlace();
+ await promise;
+ });
+
+ test('ignores call if nothing is selected', () => {
+ const createRangeCommentStub = sinon.stub(
+ diffElement,
+ 'createRangeComment'
+ );
+ const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+ cursor.diffRow = undefined;
+ cursor.createCommentInPlace();
+ assert.isFalse(createRangeCommentStub.called);
+ assert.isFalse(addDraftAtLineStub.called);
+ });
+ });
+
+ test('getAddress', () => {
+ // It should initialize to the first chunk: line 5 of the revision.
+ assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+ // Revision line 4 is up.
+ cursor.moveUp();
+ assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+
+ // Base line 4 is left.
+ cursor.moveLeft();
+ assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+
+ // Moving to the next chunk takes it back to the start.
+ cursor.moveToNextChunk();
+ assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+ // The following chunk is a removal starting on line 10 of the base.
+ cursor.moveToNextChunk();
+ assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+
+ // Should be null if there is no selection.
+ cursor.cursorManager.unsetCursor();
+ assert.isNotOk(cursor.getAddress());
+ });
+
+ test('_findRowByNumberAndFile', () => {
+ // Get the first ab row after the first chunk.
+ const rows = [...queryAll<HTMLTableRowElement>(diffElement, 'tr')];
+ const row = rows[9];
+ assert.ok(row);
+
+ // It should be line 8 on the right, but line 5 on the left.
+ assert.equal(cursor._findRowByNumberAndFile(8, Side.RIGHT), row);
+ assert.equal(cursor._findRowByNumberAndFile(5, Side.LEFT), row);
+ });
+
+ test('expand context updates stops', async () => {
+ const spy = sinon.spy(cursor, '_updateStops');
+ const controls = queryAndAssert(diffElement, 'gr-context-controls');
+ const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
+ showContext.click();
+ await waitForEventOnce(diffElement, 'render');
+ await waitUntil(() => spy.called);
+ assert.isTrue(spy.called);
+ });
+
+ test('updates stops when loading changes', () => {
+ const spy = sinon.spy(cursor, '_updateStops');
+ diffElement.dispatchEvent(new Event('loading-changed'));
+ assert.isTrue(spy.called);
+ });
+
+ suite('multi diff', () => {
+ let diffElements: GrDiff[];
+
+ setup(async () => {
+ diffElements = [
+ await fixture(html`<gr-diff></gr-diff>`),
+ await fixture(html`<gr-diff></gr-diff>`),
+ await fixture(html`<gr-diff></gr-diff>`),
+ ];
+ cursor = new GrDiffCursor();
+
+ // Register the diff with the cursor.
+ cursor.replaceDiffs(diffElements);
+
+ for (const el of diffElements) {
+ el.prefs = createDefaultDiffPrefs();
+ }
+ });
+
+ function getTargetDiffIndex() {
+ // Mocha has a bug where when `assert.equals` fails, it will try to
+ // JSON.stringify the operands, which fails when they are cyclic structures
+ // like GrDiffElement. The failure is difficult to attribute to a specific
+ // assertion because of the async nature assertion errors are handled and
+ // can cause the test simply timing out, causing a lot of debugging headache.
+ // Working with indices circumvents the problem.
+ const target = cursor.getTargetDiffElement();
+ assertIsDefined(target);
+ return diffElements.indexOf(target);
+ }
+
+ test('do not skip loading diffs', async () => {
+ diffElements[0].diff = createDiff();
+ diffElements[2].diff = createDiff();
+ await waitForEventOnce(diffElements[0], 'render');
+ await waitForEventOnce(diffElements[2], 'render');
+
+ const lastLine = diffElements[0].diff.meta_b?.lines;
+ assertIsDefined(lastLine);
+
+ // Goto second last line of the first diff
+ cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
+ assert.equal(
+ cursor.getTargetLineElement()!.textContent?.trim(),
+ `${lastLine - 1}`
+ );
+
+ // Can move down until we reach the loading file
+ cursor.moveDown();
+ assert.equal(getTargetDiffIndex(), 0);
+ assert.equal(
+ cursor.getTargetLineElement()!.textContent?.trim(),
+ lastLine.toString()
+ );
+
+ // Cannot move down while still loading the diff we would switch to
+ cursor.moveDown();
+ assert.equal(getTargetDiffIndex(), 0);
+ assert.equal(
+ cursor.getTargetLineElement()!.textContent?.trim(),
+ lastLine.toString()
+ );
+
+ // Diff 1 finishing to load
+ diffElements[1].diff = createDiff();
+ await waitForEventOnce(diffElements[1], 'render');
+
+ // Now we can go down
+ cursor.moveDown(); // LOST
+ cursor.moveDown(); // FILE
+ assert.equal(getTargetDiffIndex(), 1);
+ assert.equal(cursor.getTargetLineElement()!.textContent?.trim(), 'File');
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-annotation.ts
new file mode 100644
index 0000000..38bd707
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-annotation.ts
@@ -0,0 +1,284 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
+
+// TODO(wyatta): refactor this to be <MARK> rather than <HL>.
+const ANNOTATION_TAG = 'HL';
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export const GrAnnotation = {
+ /**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ *
+ */
+ getLength(node: Node) {
+ if (node instanceof Comment) return 0;
+ return this.getStringLength(node.textContent || '');
+ },
+
+ /**
+ * Returns the number of Unicode code points in the given string
+ *
+ * This is not necessarily the same as the number of visible symbols.
+ * See https://mathiasbynens.be/notes/javascript-unicode for more details.
+ */
+ getStringLength(str: string) {
+ return [...str].length;
+ },
+
+ /**
+ * Annotates the [offset, offset+length) text segment in the parent with the
+ * element definition provided as arguments.
+ *
+ * @param parent the node whose contents will be annotated.
+ * If parent is Text then parent.parentNode must not be null
+ * @param offset the 0-based offset from which the annotation will
+ * start.
+ * @param length of the annotated text.
+ * @param elementSpec the spec to create the
+ * annotating element.
+ */
+ annotateWithElement(
+ parent: Node,
+ offset: number,
+ length: number,
+ elSpec: ElementSpec
+ ) {
+ const tagName = elSpec.tagName;
+ const attributes = elSpec.attributes || {};
+ let childNodes: Node[];
+
+ if (parent instanceof Element) {
+ childNodes = Array.from(parent.childNodes);
+ } else if (parent instanceof Text) {
+ childNodes = [parent];
+ parent = parent.parentNode!;
+ } else {
+ return;
+ }
+
+ const nestedNodes: Node[] = [];
+ for (let node of childNodes) {
+ const initialNodeLength = this.getLength(node);
+ // If the current node is completely before the offset.
+ if (offset > 0 && initialNodeLength <= offset) {
+ offset -= initialNodeLength;
+ continue;
+ }
+
+ if (offset > 0) {
+ node = this.splitNode(node, offset);
+ offset = 0;
+ }
+ if (this.getLength(node) > length) {
+ this.splitNode(node, length);
+ }
+ nestedNodes.push(node);
+
+ length -= this.getLength(node);
+ if (!length) break;
+ }
+
+ const wrapper = document.createElement(tagName);
+ const sanitizer = getSanitizeDOMValue();
+ for (let [name, value] of Object.entries(attributes)) {
+ if (!value) continue;
+ if (sanitizer) {
+ value = sanitizer(value, name, 'attribute', wrapper) as string;
+ }
+ wrapper.setAttribute(name, value);
+ }
+ for (const inner of nestedNodes) {
+ parent.replaceChild(wrapper, inner);
+ wrapper.appendChild(inner);
+ }
+ },
+
+ /**
+ * Surrounds the element's text at specified range in an ANNOTATION_TAG
+ * element. If the element has child elements, the range is split and
+ * applied as deeply as possible.
+ */
+ annotateElement(
+ parent: HTMLElement,
+ offset: number,
+ length: number,
+ cssClass: string
+ ) {
+ const nodes: Array<HTMLElement | Text> = [].slice.apply(parent.childNodes);
+ let nodeLength;
+ let subLength;
+
+ for (const node of nodes) {
+ nodeLength = this.getLength(node);
+
+ // If the current node is completely before the offset.
+ if (nodeLength <= offset) {
+ offset -= nodeLength;
+ continue;
+ }
+
+ // Sublength is the annotation length for the current node.
+ subLength = Math.min(length, nodeLength - offset);
+
+ if (node instanceof Text) {
+ this._annotateText(node, offset, subLength, cssClass);
+ } else if (node instanceof Element) {
+ this.annotateElement(node, offset, subLength, cssClass);
+ }
+
+ // If there is still more to annotate, then shift the indices, otherwise
+ // work is done, so break the loop.
+ if (subLength < length) {
+ length -= subLength;
+ offset = 0;
+ } else {
+ break;
+ }
+ }
+ },
+
+ /**
+ * Wraps node in annotation tag with cssClass, replacing the node in DOM.
+ */
+ wrapInHighlight(node: Element | Text, cssClass: string) {
+ let hl;
+ if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
+ hl = node;
+ hl.classList.add(cssClass);
+ } else {
+ hl = document.createElement(ANNOTATION_TAG);
+ hl.className = cssClass;
+ if (node.parentElement) node.parentElement.replaceChild(hl, node);
+ hl.appendChild(node);
+ }
+ return hl;
+ },
+
+ /**
+ * Splits Text Node and wraps it in hl with cssClass.
+ * Wraps trailing part after split, tailing one if firstPart is true.
+ */
+ splitAndWrapInHighlight(
+ node: Text,
+ offset: number,
+ cssClass: string,
+ firstPart?: boolean
+ ) {
+ if (
+ (this.getLength(node) === offset && firstPart) ||
+ (offset === 0 && !firstPart)
+ ) {
+ return this.wrapInHighlight(node, cssClass);
+ }
+ if (firstPart) {
+ this.splitNode(node, offset);
+ // Node points to first part of the Text, second one is sibling.
+ } else {
+ // if node is Text then splitNode will return a Text
+ node = this.splitNode(node, offset) as Text;
+ }
+ return this.wrapInHighlight(node, cssClass);
+ },
+
+ /**
+ * Splits Node at offset.
+ * If Node is Element, it's cloned and the node at offset is split too.
+ */
+ splitNode(element: Node, offset: number) {
+ if (element instanceof Text) {
+ return this.splitTextNode(element, offset);
+ }
+ const tail = element.cloneNode(false);
+
+ if (element.parentElement)
+ element.parentElement.insertBefore(tail, element.nextSibling);
+ // Skip nodes before offset.
+ let node = element.firstChild;
+ while (
+ node &&
+ (this.getLength(node) <= offset || this.getLength(node) === 0)
+ ) {
+ offset -= this.getLength(node);
+ node = node.nextSibling;
+ }
+ if (node && this.getLength(node) > offset) {
+ tail.appendChild(this.splitNode(node, offset));
+ }
+ while (node && node.nextSibling) {
+ tail.appendChild(node.nextSibling);
+ }
+ return tail;
+ },
+
+ /**
+ * Node.prototype.splitText Unicode-valid alternative.
+ *
+ * DOM Api for splitText() is broken for Unicode:
+ * https://mathiasbynens.be/notes/javascript-unicode
+ *
+ * @return Trailing Text Node.
+ */
+ splitTextNode(node: Text, offset: number) {
+ if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
+ const head = Array.from(node.textContent);
+ const tail = head.splice(offset);
+ const parent = node.parentNode;
+
+ // Split the content of the original node.
+ node.textContent = head.join('');
+
+ const tailNode = document.createTextNode(tail.join(''));
+ if (parent) {
+ parent.insertBefore(tailNode, node.nextSibling);
+ }
+ return tailNode;
+ } else {
+ return node.splitText(offset);
+ }
+ },
+
+ _annotateText(node: Text, offset: number, length: number, cssClass: string) {
+ const nodeLength = this.getLength(node);
+
+ // There are four cases:
+ // 1) Entire node is highlighted.
+ // 2) Highlight is at the start.
+ // 3) Highlight is at the end.
+ // 4) Highlight is in the middle.
+
+ if (offset === 0 && nodeLength === length) {
+ // Case 1.
+ this.wrapInHighlight(node, cssClass);
+ } else if (offset === 0) {
+ // Case 2.
+ this.splitAndWrapInHighlight(node, length, cssClass, true);
+ } else if (offset + length === nodeLength) {
+ // Case 3
+ this.splitAndWrapInHighlight(node, offset, cssClass, false);
+ } else {
+ // Case 4
+ this.splitAndWrapInHighlight(
+ this.splitTextNode(node, offset),
+ length,
+ cssClass,
+ true
+ );
+ }
+ },
+};
+
+/**
+ * Data used to construct an element.
+ *
+ */
+export interface ElementSpec {
+ tagName: string;
+ attributes?: {[attributeName: string]: string | undefined};
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-annotation_test.ts
new file mode 100644
index 0000000..3e1ce66
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-annotation_test.ts
@@ -0,0 +1,307 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {GrAnnotation} from './gr-annotation';
+import {
+ getSanitizeDOMValue,
+ setSanitizeDOMValue,
+} from '@polymer/polymer/lib/utils/settings';
+import {assert, fixture, html} from '@open-wc/testing';
+
+suite('annotation', () => {
+ let str: string;
+ let parent: HTMLDivElement;
+ let textNode: Text;
+
+ setup(async () => {
+ parent = await fixture(
+ html`
+ <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+ `
+ );
+ textNode = parent.childNodes[0] as Text;
+ str = textNode.textContent!;
+ });
+
+ test('_annotateText length:0 offset:0', () => {
+ GrAnnotation._annotateText(textNode, 0, 0, 'foobar');
+
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ '<hl class="foobar"></hl>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula'
+ );
+ });
+
+ test('_annotateText length:0 offset:1', () => {
+ GrAnnotation._annotateText(textNode, 1, 0, 'foobar');
+
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'L<hl class="foobar"></hl>orem ipsum dolor sit amet, suspendisse inceptos vehicula'
+ );
+ });
+
+ test('_annotateText length:0 offset:str.length', () => {
+ GrAnnotation._annotateText(textNode, str.length, 0, 'foobar');
+
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula<hl class="foobar"></hl>'
+ );
+ });
+
+ test('_annotateText Case 1', () => {
+ GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ '<hl class="foobar">Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</hl>'
+ );
+ });
+
+ test('_annotateText Case 2', () => {
+ GrAnnotation._annotateText(textNode, 0, 12, 'foobar');
+
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ '<hl class="foobar">Lorem ipsum </hl>dolor sit amet, suspendisse inceptos vehicula'
+ );
+ });
+
+ test('_annotateText Case 3', () => {
+ GrAnnotation._annotateText(textNode, 12, str.length - 12, 'foobar');
+
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'Lorem ipsum <hl class="foobar">dolor sit amet, suspendisse inceptos vehicula</hl>'
+ );
+ });
+
+ test('_annotateText Case 4', () => {
+ const index = str.indexOf('dolor');
+ const length = 'dolor '.length;
+
+ GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'Lorem ipsum <hl class="foobar">dolor </hl>sit amet, suspendisse inceptos vehicula'
+ );
+ });
+
+ test('_annotateElement design doc example', () => {
+ const layers = ['amet, ', 'inceptos ', 'amet, ', 'et, suspendisse ince'];
+
+ // Apply the layers successively.
+ layers.forEach((layer, i) => {
+ GrAnnotation.annotateElement(
+ parent,
+ str.indexOf(layer),
+ layer.length,
+ `layer-${i + 1}`
+ );
+ });
+
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'Lorem ipsum dolor sit <hl class="layer-1"><hl class="layer-3">am<hl class="layer-4">et, </hl></hl></hl><hl class="layer-4">suspendisse </hl><hl class="layer-2"><hl class="layer-4">ince</hl>ptos </hl>vehicula'
+ );
+ });
+
+ test('splitTextNode', () => {
+ const helloString = 'hello';
+ const asciiString = 'ASCII';
+ const unicodeString = 'Unic💢de';
+
+ let node;
+ let tail;
+
+ // Non-unicode path:
+ node = document.createTextNode(helloString + asciiString);
+ tail = GrAnnotation.splitTextNode(node, helloString.length);
+ assert(node.textContent, helloString);
+ assert(tail.textContent, asciiString);
+
+ // Unicdoe path:
+ node = document.createTextNode(helloString + unicodeString);
+ tail = GrAnnotation.splitTextNode(node, helloString.length);
+ assert(node.textContent, helloString);
+ assert(tail.textContent, unicodeString);
+ });
+
+ suite('annotateWithElement', () => {
+ const fullText = '01234567890123456789';
+ let mockSanitize: sinon.SinonSpy;
+ let originalSanitizeDOMValue: (
+ p0: any,
+ p1: string,
+ p2: string,
+ p3: Node | null
+ ) => any;
+
+ setup(() => {
+ setSanitizeDOMValue(p0 => p0);
+ originalSanitizeDOMValue = getSanitizeDOMValue()!;
+ assert.isDefined(originalSanitizeDOMValue);
+ mockSanitize = sinon.spy(originalSanitizeDOMValue);
+ setSanitizeDOMValue(mockSanitize);
+ });
+
+ teardown(() => {
+ setSanitizeDOMValue(originalSanitizeDOMValue);
+ });
+
+ test('annotates when fully contained', () => {
+ const length = 10;
+ const container = document.createElement('div');
+ container.textContent = fullText;
+ GrAnnotation.annotateWithElement(container, 1, length, {
+ tagName: 'test-wrapper',
+ });
+
+ assert.equal(
+ container.innerHTML,
+ '0<test-wrapper>1234567890</test-wrapper>123456789'
+ );
+ });
+
+ test('annotates when spanning multiple nodes', () => {
+ const length = 10;
+ const container = document.createElement('div');
+ container.textContent = fullText;
+ GrAnnotation.annotateElement(container, 5, length, 'testclass');
+ GrAnnotation.annotateWithElement(container, 1, length, {
+ tagName: 'test-wrapper',
+ });
+
+ assert.equal(
+ container.innerHTML,
+ '0' +
+ '<test-wrapper>' +
+ '1234' +
+ '<hl class="testclass">567890</hl>' +
+ '</test-wrapper>' +
+ '<hl class="testclass">1234</hl>' +
+ '56789'
+ );
+ });
+
+ test('annotates text node', () => {
+ const length = 10;
+ const container = document.createElement('div');
+ container.textContent = fullText;
+ GrAnnotation.annotateWithElement(container.childNodes[0], 1, length, {
+ tagName: 'test-wrapper',
+ });
+
+ assert.equal(
+ container.innerHTML,
+ '0<test-wrapper>1234567890</test-wrapper>123456789'
+ );
+ });
+
+ test('handles zero-length nodes', () => {
+ const container = document.createElement('div');
+ container.appendChild(document.createTextNode('0123456789'));
+ container.appendChild(document.createElement('span'));
+ container.appendChild(document.createTextNode('0123456789'));
+ GrAnnotation.annotateWithElement(container, 1, 10, {
+ tagName: 'test-wrapper',
+ });
+
+ assert.equal(
+ container.innerHTML,
+ '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789'
+ );
+ });
+
+ test('handles comment nodes', () => {
+ const container = document.createElement('div');
+ container.appendChild(document.createComment('comment1'));
+ container.appendChild(document.createTextNode('0123456789'));
+ container.appendChild(document.createComment('comment2'));
+ container.appendChild(document.createElement('span'));
+ container.appendChild(document.createTextNode('0123456789'));
+ GrAnnotation.annotateWithElement(container, 1, 10, {
+ tagName: 'test-wrapper',
+ });
+
+ assert.equal(
+ container.innerHTML,
+ '<!--comment1-->' +
+ '0<test-wrapper>123456789' +
+ '<!--comment2-->' +
+ '<span></span>0</test-wrapper>123456789'
+ );
+ });
+
+ test('sets sanitized attributes', () => {
+ const container = document.createElement('div');
+ container.textContent = fullText;
+ const attributes = {
+ href: 'foo',
+ 'data-foo': 'bar',
+ class: 'hello world',
+ };
+ GrAnnotation.annotateWithElement(container, 1, length, {
+ tagName: 'test-wrapper',
+ attributes,
+ });
+ assert(
+ mockSanitize.calledWith(
+ 'foo',
+ 'href',
+ 'attribute',
+ sinon.match.instanceOf(Element)
+ )
+ );
+ assert(
+ mockSanitize.calledWith(
+ 'bar',
+ 'data-foo',
+ 'attribute',
+ sinon.match.instanceOf(Element)
+ )
+ );
+ assert(
+ mockSanitize.calledWith(
+ 'hello world',
+ 'class',
+ 'attribute',
+ sinon.match.instanceOf(Element)
+ )
+ );
+ const el = container.querySelector('test-wrapper')!;
+ assert.equal(el.getAttribute('href'), 'foo');
+ assert.equal(el.getAttribute('data-foo'), 'bar');
+ assert.equal(el.getAttribute('class'), 'hello world');
+ });
+ });
+
+ suite('getStringLength', () => {
+ test('ASCII characters are counted correctly', () => {
+ assert.equal(GrAnnotation.getStringLength('ASCII'), 5);
+ });
+
+ test('Unicode surrogate pairs count as one symbol', () => {
+ assert.equal(GrAnnotation.getStringLength('Unic💢de'), 7);
+ assert.equal(GrAnnotation.getStringLength('💢💢'), 2);
+ });
+
+ test('Grapheme clusters count as multiple symbols', () => {
+ assert.equal(GrAnnotation.getStringLength('man\u0303ana'), 7); // mañana
+ assert.equal(GrAnnotation.getStringLength('q\u0307\u0323'), 3); // q̣̇
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-diff-highlight.ts
new file mode 100644
index 0000000..dad8df6
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-diff-highlight.ts
@@ -0,0 +1,531 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import '../../diff/gr-selection-action-box/gr-selection-action-box';
+import {GrAnnotation} from './gr-annotation';
+import {normalize} from './gr-range-normalizer';
+import {strToClassName} from '../../../utils/dom-util';
+import {Side} from '../../../constants/constants';
+import {CommentRange} from '../../../types/common';
+import {GrSelectionActionBox} from '../../diff/gr-selection-action-box/gr-selection-action-box';
+import {
+ getLineElByChild,
+ getLineNumberByChild,
+ getSideByLineEl,
+ GrDiffThreadElement,
+} from '../../diff/gr-diff/gr-diff-utils';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {
+ assert,
+ assertIsDefined,
+ queryAndAssert,
+} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {FILE, LOST} from '../../../api/diff';
+
+interface SidedRange {
+ side: Side;
+ range: CommentRange;
+}
+
+interface NormalizedPosition {
+ node: Node | null;
+ side: Side;
+ line: number;
+ column: number;
+}
+
+interface NormalizedRange {
+ start: NormalizedPosition | null;
+ end: NormalizedPosition | null;
+}
+
+/**
+ * The methods that we actually want to call on the builder. We don't want a
+ * fully blown dependency on GrDiffBuilderElement.
+ */
+export interface DiffBuilderInterface {
+ getContentTdByLineEl(lineEl?: Element): Element | undefined;
+}
+
+/**
+ * Handles showing, positioning and interacting with <gr-selection-action-box>.
+ *
+ * Toggles a css class for highlighting comment ranges when the mouse leaves or
+ * enters a comment thread element.
+ */
+export class GrDiffHighlight {
+ selectedRange?: SidedRange;
+
+ private diffBuilder?: DiffBuilderInterface;
+
+ private diffTable?: HTMLElement;
+
+ private selectionChangeTask?: DelayedTask;
+
+ init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+ this.cleanup();
+
+ this.diffTable = diffTable;
+ this.diffBuilder = diffBuilder;
+
+ diffTable.addEventListener(
+ 'comment-thread-mouseleave',
+ this.handleCommentThreadMouseleave
+ );
+ diffTable.addEventListener(
+ 'comment-thread-mouseenter',
+ this.handleCommentThreadMouseenter
+ );
+ diffTable.addEventListener(
+ 'create-comment-requested',
+ this.handleRangeCommentRequest
+ );
+ }
+
+ cleanup() {
+ this.selectionChangeTask?.cancel();
+ if (this.diffTable) {
+ this.diffTable.removeEventListener(
+ 'comment-thread-mouseleave',
+ this.handleCommentThreadMouseleave
+ );
+ this.diffTable.removeEventListener(
+ 'comment-thread-mouseenter',
+ this.handleCommentThreadMouseenter
+ );
+ this.diffTable.removeEventListener(
+ 'create-comment-requested',
+ this.handleRangeCommentRequest
+ );
+ }
+ }
+
+ /**
+ * Determines side/line/range for a DOM selection and shows a tooltip.
+ *
+ * With native shadow DOM, gr-diff-highlight cannot access a selection that
+ * references the DOM elements making up the diff because they are in the
+ * shadow DOM the gr-diff element. For this reason, we listen to the
+ * selectionchange event and retrieve the selection in gr-diff, and then
+ * call this method to process the Selection.
+ *
+ * @param selection A DOM Selection living in the shadow DOM of
+ * the diff element.
+ * @param isMouseUp If true, this is called due to a mouseup
+ * event, in which case we might want to immediately create a comment,
+ * because isMouseUp === true combined with an existing selection must
+ * mean that this is the end of a double-click.
+ */
+ handleSelectionChange(
+ selection: Selection | Range | null,
+ isMouseUp: boolean
+ ) {
+ if (selection === null) return;
+ // Debounce is not just nice for waiting until the selection has settled,
+ // it is also vital for being able to click on the action box before it is
+ // removed.
+ // If you wait longer than 50 ms, then you don't properly catch a very
+ // quick 'c' press after the selection change. If you wait less than 10
+ // ms, then you will have about 50 handleSelection() calls when doing a
+ // simple drag for select.
+ this.selectionChangeTask = debounce(
+ this.selectionChangeTask,
+ () => this.handleSelection(selection, isMouseUp),
+ 10
+ );
+ }
+
+ private getThreadEl(e: Event): GrDiffThreadElement | null {
+ for (const pathEl of e.composedPath()) {
+ if (
+ pathEl instanceof HTMLElement &&
+ pathEl.classList.contains('comment-thread')
+ ) {
+ return pathEl as GrDiffThreadElement;
+ }
+ }
+ return null;
+ }
+
+ private toggleRangeElHighlight(
+ threadEl: GrDiffThreadElement | null,
+ highlightRange = false
+ ) {
+ const rootId = threadEl?.rootId;
+ if (!rootId) return;
+ if (!this.diffTable) return;
+ if (highlightRange) {
+ const selector = `.range.${strToClassName(rootId)}`;
+ const rangeNodes = this.diffTable.querySelectorAll(selector);
+ rangeNodes.forEach(rangeNode => {
+ rangeNode.classList.add('rangeHoverHighlight');
+ });
+ const hintNode = this.diffTable.querySelector(
+ `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+ );
+ hintNode?.shadowRoot
+ ?.querySelectorAll('.rangeHighlight')
+ .forEach(highlightNode =>
+ highlightNode.classList.add('rangeHoverHighlight')
+ );
+ } else {
+ const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`;
+ const rangeNodes = this.diffTable.querySelectorAll(selector);
+ rangeNodes.forEach(rangeNode => {
+ rangeNode.classList.remove('rangeHoverHighlight');
+ });
+ const hintNode = this.diffTable.querySelector(
+ `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+ );
+ hintNode?.shadowRoot
+ ?.querySelectorAll('.rangeHoverHighlight')
+ .forEach(highlightNode =>
+ highlightNode.classList.remove('rangeHoverHighlight')
+ );
+ }
+ }
+
+ private handleCommentThreadMouseenter = (e: Event) => {
+ const threadEl = this.getThreadEl(e);
+ this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+ };
+
+ private handleCommentThreadMouseleave = (e: Event) => {
+ const threadEl = this.getThreadEl(e);
+ this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+ };
+
+ /**
+ * Get current normalized selection.
+ * Merges multiple ranges, accounts for triple click, accounts for
+ * syntax highligh, convert native DOM Range objects to Gerrit concepts
+ * (line, side, etc).
+ */
+ private getNormalizedRange(selection: Selection | Range) {
+ /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+ we can get is a single Range */
+ if (selection instanceof Range) {
+ return this.normalizeRange(selection);
+ }
+ const rangeCount = selection.rangeCount;
+ if (rangeCount === 0) {
+ return null;
+ } else if (rangeCount === 1) {
+ return this.normalizeRange(selection.getRangeAt(0));
+ } else {
+ const startRange = this.normalizeRange(selection.getRangeAt(0));
+ const endRange = this.normalizeRange(
+ selection.getRangeAt(rangeCount - 1)
+ );
+ return {
+ start: startRange.start,
+ end: endRange.end,
+ };
+ }
+ }
+
+ /**
+ * Normalize a specific DOM Range.
+ *
+ * @return fixed normalized range
+ */
+ private normalizeRange(domRange: Range): NormalizedRange {
+ const range = normalize(domRange);
+ return this.fixTripleClickSelection(
+ {
+ start: this.normalizeSelectionSide(
+ range.startContainer,
+ range.startOffset
+ ),
+ end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
+ },
+ domRange
+ );
+ }
+
+ /**
+ * Adjust triple click selection for the whole line.
+ * A triple click always results in:
+ * - start.column == end.column == 0
+ * - end.line == start.line + 1
+ *
+ * @param range Normalized range, ie column/line numbers
+ * @param domRange DOM Range object
+ * @return fixed normalized range
+ */
+ private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+ if (!range.start) {
+ // Selection outside of current diff.
+ return range;
+ }
+ const start = range.start;
+ const end = range.end;
+ // Happens when triple click in side-by-side mode with other side empty.
+ const endsAtOtherEmptySide =
+ !end &&
+ domRange.endOffset === 0 &&
+ domRange.endContainer instanceof HTMLElement &&
+ domRange.endContainer.nodeName === 'TD' &&
+ (domRange.endContainer.classList.contains('left') ||
+ domRange.endContainer.classList.contains('right'));
+ const endsAtBeginningOfNextLine =
+ end &&
+ start.column === 0 &&
+ end.column === 0 &&
+ end.line === start.line + 1;
+ const content = domRange.cloneContents().querySelector('.contentText');
+ const lineLength = (content && this.getLength(content)) || 0;
+ if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
+ // Move the selection to the end of the previous line.
+ range.end = {
+ node: start.node,
+ column: lineLength,
+ side: start.side,
+ line: start.line,
+ };
+ }
+ return range;
+ }
+
+ /**
+ * Convert DOM Range selection to concrete numbers (line, column, side).
+ * Moves range end if it's not inside td.content.
+ * Returns null if selection end is not valid (outside of diff).
+ *
+ * @param node td.content child
+ * @param offset offset within node
+ */
+ private normalizeSelectionSide(
+ node: Node | null,
+ offset: number
+ ): NormalizedPosition | null {
+ let column;
+ if (!this.diffTable) return null;
+ if (!this.diffBuilder) return null;
+ if (!node || !this.diffTable.contains(node)) return null;
+ const lineEl = getLineElByChild(node);
+ if (!lineEl) return null;
+ const side = getSideByLineEl(lineEl);
+ if (!side) return null;
+ const line = getLineNumberByChild(lineEl);
+ if (!line || line === FILE || line === LOST) return null;
+ assert(typeof line === 'number', 'line must be a number');
+ const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
+ if (!contentTd) return null;
+ const contentText = contentTd.querySelector('.contentText');
+ if (!contentTd.contains(node)) {
+ node = contentText;
+ column = 0;
+ } else {
+ const thread = contentTd.querySelector('.comment-thread');
+ if (thread?.contains(node)) {
+ column = this.getLength(contentText);
+ node = contentText;
+ } else {
+ column = this.convertOffsetToColumn(node, offset);
+ }
+ }
+
+ return {
+ node,
+ side,
+ line,
+ column,
+ };
+ }
+
+ /**
+ * The only line in which add a comment tooltip is cut off is the first
+ * line. Even if there is a collapsed section, The first visible line is
+ * in the position where the second line would have been, if not for the
+ * collapsed section, so don't need to worry about this case for
+ * positioning the tooltip.
+ */
+ // visible for testing
+ positionActionBox(
+ actionBox: GrSelectionActionBox,
+ startLine: number,
+ range: Text | Element | Range
+ ) {
+ if (startLine > 1) {
+ actionBox.positionBelow = false;
+ actionBox.placeAbove(range);
+ return;
+ }
+ actionBox.positionBelow = true;
+ actionBox.placeBelow(range);
+ }
+
+ private isRangeValid(range: NormalizedRange | null) {
+ if (!range || !range.start || !range.start.node || !range.end) {
+ return false;
+ }
+ const start = range.start;
+ const end = range.end;
+ return !(
+ start.side !== end.side ||
+ end.line < start.line ||
+ (start.line === end.line && start.column === end.column)
+ );
+ }
+
+ // visible for testing
+ handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+ /* On Safari, the selection events may return a null range that should
+ be ignored */
+ if (!selection) return;
+ if (!this.diffTable) return;
+
+ const normalizedRange = this.getNormalizedRange(selection);
+ if (!this.isRangeValid(normalizedRange)) {
+ this.removeActionBox();
+ return;
+ }
+ /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+ we can get is a single Range */
+ const domRange =
+ selection instanceof Range ? selection : selection.getRangeAt(0);
+ const start = normalizedRange!.start!;
+ const end = normalizedRange!.end!;
+
+ // TODO (viktard): Drop empty first and last lines from selection.
+
+ // If the selection is from the end of one line to the start of the next
+ // line, then this must have been a double-click, or you have started
+ // dragging. Showing the action box is bad in the former case and not very
+ // useful in the latter, so never do that.
+ // If this was a mouse-up event, we create a comment immediately if
+ // the selection is from the end of a line to the start of the next line.
+ // In a perfect world we would only do this for double-click, but it is
+ // extremely rare that a user would drag from the end of one line to the
+ // start of the next and release the mouse, so we don't bother.
+ // TODO(brohlfs): This does not work, if the double-click is before a new
+ // diff chunk (start will be equal to end), and neither before an "expand
+ // the diff context" block (end line will match the first line of the new
+ // section and thus be greater than start line + 1).
+ if (start.line === end.line - 1 && end.column === 0) {
+ // Rather than trying to find the line contents (for comparing
+ // start.column with the content length), we just check if the selection
+ // is empty to see that it's at the end of a line.
+ const content = domRange.cloneContents().querySelector('.contentText');
+ if (isMouseUp && this.getLength(content) === 0) {
+ this.fireCreateRangeComment(start.side, {
+ start_line: start.line,
+ start_character: 0,
+ end_line: start.line,
+ end_character: start.column,
+ });
+ }
+ return;
+ }
+
+ let actionBox = this.diffTable.querySelector('gr-selection-action-box');
+ if (!actionBox) {
+ actionBox = document.createElement('gr-selection-action-box');
+ this.diffTable.appendChild(actionBox);
+ }
+ this.selectedRange = {
+ range: {
+ start_line: start.line,
+ start_character: start.column,
+ end_line: end.line,
+ end_character: end.column,
+ },
+ side: start.side,
+ };
+ if (start.line === end.line) {
+ this.positionActionBox(actionBox, start.line, domRange);
+ } else if (start.node instanceof Text) {
+ if (start.column) {
+ this.positionActionBox(
+ actionBox,
+ start.line,
+ start.node.splitText(start.column)
+ );
+ }
+ start.node.parentElement!.normalize(); // Undo splitText from above.
+ } else if (
+ start.node instanceof HTMLElement &&
+ start.node.classList.contains('content') &&
+ (start.node.firstChild instanceof Element ||
+ start.node.firstChild instanceof Text)
+ ) {
+ this.positionActionBox(actionBox, start.line, start.node.firstChild);
+ } else if (start.node instanceof Element || start.node instanceof Text) {
+ this.positionActionBox(actionBox, start.line, start.node);
+ } else {
+ console.warn('Failed to position comment action box.');
+ this.removeActionBox();
+ }
+ }
+
+ private fireCreateRangeComment(side: Side, range: CommentRange) {
+ if (this.diffTable) {
+ fire(this.diffTable, 'create-range-comment', {side, range});
+ }
+ this.removeActionBox();
+ }
+
+ private handleRangeCommentRequest = (e: Event) => {
+ e.stopPropagation();
+ assertIsDefined(this.selectedRange, 'selectedRange');
+ const {side, range} = this.selectedRange;
+ this.fireCreateRangeComment(side, range);
+ };
+
+ // visible for testing
+ removeActionBox() {
+ this.selectedRange = undefined;
+ const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+ if (actionBox) actionBox.remove();
+ }
+
+ private convertOffsetToColumn(el: Node, offset: number) {
+ if (el instanceof Element && el.classList.contains('content')) {
+ return offset;
+ }
+ while (
+ el.previousSibling ||
+ !el.parentElement?.classList.contains('content')
+ ) {
+ if (el.previousSibling) {
+ el = el.previousSibling;
+ offset += this.getLength(el);
+ } else {
+ el = el.parentElement!;
+ }
+ }
+ return offset;
+ }
+
+ /**
+ * Get length of a node. If the node is a content node, then only give the
+ * length of its .contentText child.
+ *
+ * @param node this is sometimes passed as null.
+ */
+ // visible for testing
+ getLength(node: Node | null): number {
+ if (node === null) return 0;
+ if (node instanceof Element && node.classList.contains('content')) {
+ return this.getLength(queryAndAssert(node, '.contentText'));
+ } else {
+ return GrAnnotation.getLength(node);
+ }
+ }
+}
+
+export interface CreateRangeCommentEventDetail {
+ side: Side;
+ range: CommentRange;
+}
+
+declare global {
+ interface HTMLElementEventMap {
+ 'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-diff-highlight_test.ts
new file mode 100644
index 0000000..e491e63
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -0,0 +1,717 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-highlight';
+import {getTextOffset} from './gr-range-normalizer';
+import {fixture, fixtureCleanup, html, assert} from '@open-wc/testing';
+import {
+ GrDiffHighlight,
+ DiffBuilderInterface,
+ CreateRangeCommentEventDetail,
+} from './gr-diff-highlight';
+import {Side} from '../../../api/diff';
+import {SinonStubbedMember} from 'sinon';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrDiffThreadElement} from '../../diff/gr-diff/gr-diff-utils';
+import {
+ stubElement,
+ waitQueryAndAssert,
+ waitUntil,
+} from '../../../test/test-utils';
+import {GrSelectionActionBox} from '../../diff/gr-selection-action-box/gr-selection-action-box';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len, lit/prefer-static-styles */
+/* prettier-ignore */
+const diffTable = html`
+ <table id="diffTable">
+ <tbody class="section both">
+ <tr class="diff-row side-by-side" left-type="both" right-type="both">
+ <td class="left lineNum" data-value="1"></td>
+ <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+ <td class="right lineNum" data-value="1"></td>
+ <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section delta">
+ <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+ <td class="left lineNum" data-value="2"></td>
+ <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+ <td class="content remove"><div class="contentText">na💢ti <hl class="foo range generated_id314">te, inquit</hl>, sumus<hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>udiam, <hl>quid</hl> sit,<span class="tab-indicator" style="tab-size:8;"> </span>quod<hl>Epicurum</hl></div></td>
+ <td class="right lineNum" data-value="2"></td>
+ <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+ <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>otiosum,<span class="tab-indicator" style="tab-size:8;"> </span> audiam,sit, quod</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section both">
+ <tr class="diff-row side-by-side" left-type="both" right-type="both">
+ <td class="left lineNum" data-value="138"></td>
+ <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+ <td class="right lineNum" data-value="119"></td>
+ <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section delta">
+ <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+ <td class="left lineNum" data-value="140"></td>
+ <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+ <td class="content remove"><div class="contentText"><!-- a comment node -->na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+ [Yet another random diff thread content here]
+ </div></td>
+ <td class="right lineNum" data-value="120"></td>
+ <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+ <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum, <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam, sit, quod</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section both">
+ <tr class="diff-row side-by-side" left-type="both" right-type="both">
+ <td class="left lineNum" data-value="141"></td>
+ <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>complectitur<span class="tab-indicator" style="tab-size:8;"></span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+ <td class="right lineNum" data-value="130"></td>
+ <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quodintellegam;</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section contextControl">
+ <tr
+ class="diff-row side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="left contextLineNum"></td>
+ <td>
+ <gr-button>+10↑</gr-button>
+ -
+ <gr-button>Show 21 common lines</gr-button>
+ -
+ <gr-button>+10↓</gr-button>
+ </td>
+ <td class="right contextLineNum"></td>
+ <td>
+ <gr-button>+10↑</gr-button>
+ -
+ <gr-button>Show 21 common lines</gr-button>
+ -
+ <gr-button>+10↓</gr-button>
+ </td>
+ </tr>
+ </tbody>
+
+ <tbody class="section delta total">
+ <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+ <td class="left"></td>
+ <td class="blank"></td>
+ <td class="right lineNum" data-value="146"></td>
+ <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+ </tr>
+ </tbody>
+
+ <tbody class="section both">
+ <tr class="diff-row side-by-side" left-type="both" right-type="both">
+ <td class="left lineNum" data-value="165"></td>
+ <td class="content both"><div class="contentText"></div></td>
+ <td class="right lineNum" data-value="147"></td>
+ <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+ </tr>
+ </tbody>
+ </table>
+`;
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+ suite('comment events', () => {
+ let threadEl: GrDiffThreadElement;
+ let hlRange: HTMLElement;
+ let element: GrDiffHighlight;
+ let diff: HTMLElement;
+ let builder: {
+ getContentTdByLineEl: SinonStubbedMember<
+ DiffBuilderInterface['getContentTdByLineEl']
+ >;
+ };
+
+ setup(async () => {
+ diff = await fixture<HTMLTableElement>(diffTable);
+ builder = {
+ getContentTdByLineEl: sinon.stub(),
+ };
+ element = new GrDiffHighlight();
+ element.init(diff, builder);
+ hlRange = queryAndAssert(diff, 'hl.range.generated_id314');
+
+ threadEl = document.createElement(
+ 'div'
+ ) as unknown as GrDiffThreadElement;
+ threadEl.className = 'comment-thread';
+ threadEl.rootId = 'id314';
+ diff.appendChild(threadEl);
+ });
+
+ teardown(() => {
+ element.cleanup();
+ threadEl.remove();
+ });
+
+ test('comment-thread-mouseenter toggles rangeHoverHighlight class', async () => {
+ assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+ threadEl.dispatchEvent(
+ new CustomEvent('comment-thread-mouseenter', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ await waitUntil(() => hlRange.classList.contains('rangeHoverHighlight'));
+ assert.isTrue(hlRange.classList.contains('rangeHoverHighlight'));
+ });
+
+ test('comment-thread-mouseleave toggles rangeHoverHighlight class', async () => {
+ hlRange.classList.add('rangeHoverHighlight');
+ threadEl.dispatchEvent(
+ new CustomEvent('comment-thread-mouseleave', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ await waitUntil(() => !hlRange.classList.contains('rangeHoverHighlight'));
+ assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+ });
+
+ test(`create-range-comment for range when create-comment-requested
+ is fired`, () => {
+ const removeActionBoxStub = sinon.stub(element, 'removeActionBox');
+ element.selectedRange = {
+ side: Side.LEFT,
+ range: {
+ start_line: 7,
+ start_character: 11,
+ end_line: 24,
+ end_character: 42,
+ },
+ };
+ const requestEvent = new CustomEvent('create-comment-requested');
+ let createRangeEvent: CustomEvent<CreateRangeCommentEventDetail>;
+ diff.addEventListener('create-range-comment', e => {
+ createRangeEvent = e;
+ });
+ diff.dispatchEvent(requestEvent);
+ if (!createRangeEvent!) assert.fail('event not set');
+ assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+ assert.isTrue(removeActionBoxStub.called);
+ });
+ });
+
+ suite('selection', () => {
+ let element: GrDiffHighlight;
+ let diff: HTMLElement;
+ let builder: {
+ getContentTdByLineEl: SinonStubbedMember<
+ DiffBuilderInterface['getContentTdByLineEl']
+ >;
+ };
+ let contentStubs;
+
+ setup(async () => {
+ diff = await fixture<HTMLTableElement>(diffTable);
+ builder = {
+ getContentTdByLineEl: sinon.stub(),
+ };
+ element = new GrDiffHighlight();
+ element.init(diff, builder);
+ contentStubs = [];
+ stubElement('gr-selection-action-box', 'placeAbove');
+ stubElement('gr-selection-action-box', 'placeBelow');
+ });
+
+ teardown(() => {
+ fixtureCleanup();
+ element.cleanup();
+ contentStubs = null;
+ document.getSelection()!.removeAllRanges();
+ });
+
+ const stubContent = (line: number, side: Side) => {
+ const contentTd = diff.querySelector(
+ `.${side}.lineNum[data-value="${line}"] ~ .content`
+ );
+ if (!contentTd) assert.fail('content td not found');
+ const contentText = contentTd.querySelector('.contentText');
+ const lineEl =
+ diff.querySelector(`.${side}.lineNum[data-value="${line}"]`) ??
+ undefined;
+ contentStubs.push({
+ lineEl,
+ contentTd,
+ contentText,
+ });
+ builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+ return contentText;
+ };
+
+ const emulateSelection = (
+ startNode: Node,
+ startOffset: number,
+ endNode: Node,
+ endOffset: number
+ ) => {
+ const selection = document.getSelection();
+ if (!selection) assert.fail('no selection');
+ selection.removeAllRanges();
+ const range = document.createRange();
+ range.setStart(startNode, startOffset);
+ range.setEnd(endNode, endOffset);
+ selection.addRange(range);
+ element.handleSelection(selection, false);
+ };
+
+ test('single first line', () => {
+ const content = stubContent(1, Side.RIGHT);
+ sinon.spy(element, 'positionActionBox');
+ if (!content?.firstChild) assert.fail('content first child not found');
+ emulateSelection(content.firstChild, 5, content.firstChild, 12);
+ const actionBox = diff.querySelector('gr-selection-action-box');
+ if (!actionBox) assert.fail('action box not found');
+ assert.isTrue(actionBox.positionBelow);
+ });
+
+ test('multiline starting on first line', () => {
+ const startContent = stubContent(1, Side.RIGHT);
+ const endContent = stubContent(2, Side.RIGHT);
+ sinon.spy(element, 'positionActionBox');
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.lastChild) {
+ assert.fail('last child of end content not found');
+ }
+ emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+ const actionBox = diff.querySelector('gr-selection-action-box');
+ if (!actionBox) assert.fail('action box not found');
+ assert.isTrue(actionBox.positionBelow);
+ });
+
+ test('single line', async () => {
+ const content = stubContent(138, Side.LEFT);
+ sinon.spy(element, 'positionActionBox');
+ if (!content?.firstChild) assert.fail('content first child not found');
+ emulateSelection(content.firstChild, 5, content.firstChild, 12);
+ const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+ diff,
+ 'gr-selection-action-box'
+ );
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 138,
+ start_character: 5,
+ end_line: 138,
+ end_character: 12,
+ });
+ assert.equal(side, Side.LEFT);
+ assert.notOk(actionBox.positionBelow);
+ });
+
+ test('multiline', () => {
+ const startContent = stubContent(119, Side.RIGHT);
+ const endContent = stubContent(120, Side.RIGHT);
+ sinon.spy(element, 'positionActionBox');
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.lastChild) {
+ assert.fail('last child of end content');
+ }
+ emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+ const actionBox = diff.querySelector('gr-selection-action-box');
+ if (!actionBox) assert.fail('action box not found');
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 119,
+ start_character: 10,
+ end_line: 120,
+ end_character: 36,
+ });
+ assert.equal(side, Side.RIGHT);
+ assert.notOk(actionBox.positionBelow);
+ });
+
+ test('multiple ranges aka firefox implementation', () => {
+ const startContent = stubContent(119, Side.RIGHT);
+ const endContent = stubContent(120, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.lastChild) {
+ assert.fail('last child of end content');
+ }
+
+ const startRange = document.createRange();
+ startRange.setStart(startContent.firstChild, 10);
+ startRange.setEnd(startContent.firstChild, 11);
+
+ const endRange = document.createRange();
+ endRange.setStart(endContent.lastChild, 6);
+ endRange.setEnd(endContent.lastChild, 7);
+
+ const getRangeAtStub = sinon.stub();
+ getRangeAtStub
+ .onFirstCall()
+ .returns(startRange)
+ .onSecondCall()
+ .returns(endRange);
+ const selection = {
+ rangeCount: 2,
+ getRangeAt: getRangeAtStub,
+ removeAllRanges: sinon.stub(),
+ } as unknown as Selection;
+ element.handleSelection(selection, false);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 119,
+ start_character: 10,
+ end_line: 120,
+ end_character: 36,
+ });
+ });
+
+ test('multiline grow end highlight over tabs', () => {
+ const startContent = stubContent(119, Side.RIGHT);
+ const endContent = stubContent(120, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.firstChild) {
+ assert.fail('first child of end content not found');
+ }
+ emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 119,
+ start_character: 10,
+ end_line: 120,
+ end_character: 2,
+ });
+ assert.equal(side, Side.RIGHT);
+ });
+
+ test('collapsed', () => {
+ const content = stubContent(138, Side.LEFT);
+ if (!content?.firstChild) {
+ assert.fail('first child of content not found');
+ }
+ emulateSelection(content.firstChild, 5, content.firstChild, 5);
+ const sel = document.getSelection();
+ if (!sel) assert.fail('no selection');
+ assert.isOk(sel.getRangeAt(0).startContainer);
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('starts inside hl', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) {
+ assert.fail('content not found');
+ }
+ const hl = content.querySelector('.foo');
+ if (!hl?.firstChild) {
+ assert.fail('first child of hl element not found');
+ }
+ if (!hl?.nextSibling) {
+ assert.fail('next sibling of hl element not found');
+ }
+ emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 8,
+ end_line: 140,
+ end_character: 23,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('ends inside hl', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content not found');
+ const hl = content.querySelector('.bar');
+ if (!hl) assert.fail('hl inside content not found');
+ if (!hl.previousSibling) assert.fail('previous sibling not found');
+ if (!hl.firstChild) assert.fail('first child not found');
+ emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 18,
+ end_line: 140,
+ end_character: 27,
+ });
+ });
+
+ test('multiple hl', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content not found');
+ if (!content.firstChild) assert.fail('first child not found');
+ const hl = content.querySelectorAll('hl')[4];
+ if (!hl) assert.fail('hl not found');
+ if (!hl.firstChild) assert.fail('first child of hl not found');
+ emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 2,
+ end_line: 140,
+ end_character: 61,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('starts outside of diff', () => {
+ const contentText = stubContent(140, Side.LEFT);
+ if (!contentText) assert.fail('content not found');
+ if (!contentText.firstChild) assert.fail('child not found');
+ const contentTd = contentText.parentElement;
+ if (!contentTd) assert.fail('content td not found');
+ if (!contentTd.parentElement) assert.fail('parent of td not found');
+
+ emulateSelection(contentTd.parentElement, 0, contentText.firstChild, 2);
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('ends outside of diff', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content not found');
+ if (!content.firstChild) assert.fail('child not found');
+ if (!content.nextElementSibling) assert.fail('sibling not found');
+ if (!content.nextElementSibling.firstChild) {
+ assert.fail('sibling child not found');
+ }
+ emulateSelection(
+ content.nextElementSibling.firstChild,
+ 2,
+ content.firstChild,
+ 2
+ );
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('starts and ends on different sides', () => {
+ const startContent = stubContent(140, Side.LEFT);
+ const endContent = stubContent(130, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.firstChild) {
+ assert.fail('first child of end content not found');
+ }
+ emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('starts in comment thread element', () => {
+ const startContent = stubContent(140, Side.LEFT);
+ if (!startContent?.parentElement) {
+ assert.fail('parent el of start content not found');
+ }
+ const comment =
+ startContent.parentElement.querySelector('.comment-thread');
+ if (!comment?.firstChild) {
+ assert.fail('first child of comment not found');
+ }
+ const endContent = stubContent(141, Side.LEFT);
+ if (!endContent?.firstChild) {
+ assert.fail('first child of end content not found');
+ }
+ emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 83,
+ end_line: 141,
+ end_character: 4,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('ends in comment thread element', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content?.firstChild) {
+ assert.fail('first child of content not found');
+ }
+ if (!content?.parentElement) {
+ assert.fail('parent element of content not found');
+ }
+ const comment = content.parentElement.querySelector('.comment-thread');
+ if (!comment?.firstChild) {
+ assert.fail('first child of comment element not found');
+ }
+ emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 4,
+ end_line: 140,
+ end_character: 83,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('starts in context element', () => {
+ const contextControl = diff
+ .querySelector('.contextControl')!
+ .querySelector('gr-button');
+ if (!contextControl) assert.fail('context control not found');
+ const content = stubContent(146, Side.RIGHT);
+ if (!content) assert.fail('content not found');
+ if (!content.firstChild) assert.fail('content child not found');
+ emulateSelection(contextControl, 0, content.firstChild, 7);
+ // TODO (viktard): Select nearest line.
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('ends in context element', () => {
+ const contextControl = diff
+ .querySelector('.contextControl')!
+ .querySelector('gr-button');
+ if (!contextControl) {
+ assert.fail('context control element not found');
+ }
+ const content = stubContent(141, Side.LEFT);
+ if (!content?.firstChild) {
+ assert.fail('first child of content element not found');
+ }
+ emulateSelection(content.firstChild, 2, contextControl, 1);
+ // TODO (viktard): Select nearest line.
+ assert.isFalse(!!element.selectedRange);
+ });
+
+ test('selection containing context element', () => {
+ const startContent = stubContent(130, Side.RIGHT);
+ const endContent = stubContent(146, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent?.firstChild) {
+ assert.fail('first child of end content not found');
+ }
+ emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 130,
+ start_character: 3,
+ end_line: 146,
+ end_character: 14,
+ });
+ assert.equal(side, Side.RIGHT);
+ });
+
+ test('ends at a tab', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content?.firstChild) {
+ assert.fail('first child of content element not found');
+ }
+ const span = content.querySelector('span');
+ if (!span) assert.fail('span element not found');
+ emulateSelection(content.firstChild, 1, span, 0);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 1,
+ end_line: 140,
+ end_character: 51,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('starts at a tab', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content element not found');
+ emulateSelection(
+ content.querySelectorAll('hl')[3],
+ 0,
+ content.querySelectorAll('span')[1].nextSibling!,
+ 1
+ );
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 51,
+ end_line: 140,
+ end_character: 71,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('properly accounts for syntax highlighting', () => {
+ const content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content element not found');
+ emulateSelection(
+ content.querySelectorAll('hl')[3],
+ 0,
+ content.querySelectorAll('span')[1],
+ 0
+ );
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 140,
+ start_character: 51,
+ end_line: 140,
+ end_character: 69,
+ });
+ assert.equal(side, Side.LEFT);
+ });
+
+ test('GrRangeNormalizer.getTextOffset computes text offset', () => {
+ let content = stubContent(140, Side.LEFT);
+ if (!content) assert.fail('content element not found');
+ if (!content.lastChild) assert.fail('last child of content not found');
+ let child = content.lastChild.lastChild;
+ if (!child) assert.fail('last child of last child of content not found');
+ let result = getTextOffset(content, child);
+ assert.equal(result, 75);
+ content = stubContent(146, Side.RIGHT);
+ if (!content) assert.fail('content element not found');
+ child = content.lastChild;
+ if (!child) assert.fail('child element not found');
+ result = getTextOffset(content, child);
+ assert.equal(result, 0);
+ });
+
+ test('fixTripleClickSelection', () => {
+ const startContent = stubContent(119, Side.RIGHT);
+ const endContent = stubContent(120, Side.RIGHT);
+ if (!startContent?.firstChild) {
+ assert.fail('first child of start content not found');
+ }
+ if (!endContent) assert.fail('end content not found');
+ if (!endContent.firstChild) assert.fail('first child not found');
+ emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+ if (!element.selectedRange) assert.fail('no range selected');
+ const {range, side} = element.selectedRange;
+ assert.deepEqual(range, {
+ start_line: 119,
+ start_character: 0,
+ end_line: 119,
+ end_character: element.getLength(startContent),
+ });
+ assert.equal(side, Side.RIGHT);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-range-normalizer.ts
new file mode 100644
index 0000000..b177e14
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-highlight/gr-range-normalizer.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export interface NormalizedRange {
+ endContainer: Node;
+ endOffset: number;
+ startContainer: Node;
+ startOffset: number;
+}
+
+/**
+ * Remap DOM range to whole lines of a diff if necessary. If the start or
+ * end containers are DOM elements that are singular pieces of syntax
+ * highlighting, the containers are remapped to the .contentText divs that
+ * contain the entire line of code.
+ *
+ * @param range - the standard DOM selector range.
+ * @return A modified version of the range that correctly accounts
+ * for syntax highlighting.
+ */
+export function normalize(range: Range): NormalizedRange {
+ const startContainer = getContentTextParent(range.startContainer);
+ const startOffset =
+ range.startOffset + getTextOffset(startContainer, range.startContainer);
+ const endContainer = getContentTextParent(range.endContainer);
+ const endOffset =
+ range.endOffset + getTextOffset(endContainer, range.endContainer);
+ return {
+ startContainer,
+ startOffset,
+ endContainer,
+ endOffset,
+ };
+}
+
+function getContentTextParent(target: Node): Node {
+ if (!target.parentElement) return target;
+
+ let element: Element | null;
+ if (target instanceof Element) {
+ element = target;
+ } else {
+ element = target.parentElement;
+ }
+
+ while (element && !element.classList.contains('contentText')) {
+ if (element.parentElement === null) {
+ return target;
+ }
+ element = element.parentElement;
+ }
+ return element ? element : target;
+}
+
+/**
+ * Gets the character offset of the child within the parent.
+ * Performs a synchronous in-order traversal from top to bottom of the node
+ * element, counting the length of the syntax until child is found.
+ *
+ * @param node The root DOM element to be searched through.
+ * @param child The child element being searched for.
+ */
+// TODO(TS): Only export for test.
+export function getTextOffset(node: Node | null, child: Node): number {
+ let count = 0;
+ let stack = [node];
+ while (stack.length) {
+ const n = stack.pop();
+ if (n === child) {
+ break;
+ }
+ if (n?.childNodes && n.childNodes.length !== 0) {
+ const arr = [];
+ for (const childNode of n.childNodes) {
+ arr.push(childNode);
+ }
+ arr.reverse();
+ stack = stack.concat(arr);
+ } else {
+ count += getLength(n);
+ }
+ }
+ return count;
+}
+
+/**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ *
+ * @param node A text node.
+ * @return The length of the text.
+ */
+function getLength(node?: Node | null) {
+ return node && node.textContent && node.nodeType !== Node.COMMENT_NODE
+ ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
+ : 0;
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-model/gr-diff-model.ts
new file mode 100644
index 0000000..8fbda14
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-model/gr-diff-model.ts
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {
+ DiffInfo,
+ DiffPreferencesInfo,
+ RenderPreferences,
+} from '../../../api/diff';
+import {define} from '../../../models/dependency';
+import {Model} from '../../../models/model';
+import {isDefined} from '../../../types/types';
+import {select} from '../../../utils/observable-util';
+
+export interface DiffState {
+ diff: DiffInfo;
+ path?: string;
+ renderPrefs: RenderPreferences;
+ diffPrefs: DiffPreferencesInfo;
+}
+
+export const diffModelToken = define<DiffModel>('diff-model');
+
+export class DiffModel extends Model<DiffState | undefined> {
+ readonly diff$: Observable<DiffInfo> = select(
+ this.state$.pipe(filter(isDefined)),
+ diffState => diffState.diff
+ );
+
+ readonly path$: Observable<string | undefined> = select(
+ this.state$.pipe(filter(isDefined)),
+ diffState => diffState.path
+ );
+
+ readonly renderPrefs$: Observable<RenderPreferences> = select(
+ this.state$.pipe(filter(isDefined)),
+ diffState => diffState.renderPrefs
+ );
+
+ readonly diffPrefs$: Observable<DiffPreferencesInfo> = select(
+ this.state$.pipe(filter(isDefined)),
+ diffState => diffState.diffPrefs
+ );
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-processor/gr-diff-processor.ts
new file mode 100644
index 0000000..256dc11
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-processor/gr-diff-processor.ts
@@ -0,0 +1,714 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffLine, Highlights} from '../gr-diff/gr-diff-line';
+import {
+ GrDiffGroup,
+ GrDiffGroupType,
+ hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {DiffContent} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {FILE, GrDiffLineType, LOST, LineNumber} from '../../../api/diff';
+
+const WHOLE_FILE = -1;
+
+// visible for testing
+export interface State {
+ lineNums: {
+ left: number;
+ right: number;
+ };
+ chunkIndex: number;
+}
+
+interface ChunkEnd {
+ offset: number;
+ keyLocation: boolean;
+}
+
+export interface KeyLocations {
+ left: {[key: string]: boolean};
+ right: {[key: string]: boolean};
+}
+
+/**
+ * The maximum size for an addition or removal chunk before it is broken down
+ * into a series of chunks that are this size at most.
+ *
+ * Note: The value of 120 is chosen so that it is larger than the default
+ * asyncThreshold of 64, but feel free to tune this constant to your
+ * performance needs.
+ */
+function calcMaxGroupSize(asyncThreshold?: number): number {
+ if (!asyncThreshold) return 120;
+ return asyncThreshold * 2;
+}
+
+/** Interface for listening to the output of the processor. */
+export interface GroupConsumer {
+ addGroup(group: GrDiffGroup): void;
+ clearGroups(): void;
+}
+
+/**
+ * Converts the API's `DiffContent`s to `GrDiffGroup`s for rendering.
+ *
+ * Glossary:
+ * - "chunk": A single `DiffContent` as returned by the API.
+ * - "group": A single `GrDiffGroup` as used for rendering.
+ * - "common" chunk/group: A chunk/group that should be considered unchanged
+ * for diffing purposes. This can mean its either actually unchanged, or it
+ * has only whitespace changes.
+ * - "key location": A line number and side of the diff that should not be
+ * collapsed e.g. because a comment is attached to it, or because it was
+ * provided in the URL and thus should be visible
+ * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+ * or cannot be collapsed because it contains a key location
+ *
+ * Here a a number of tasks this processor performs:
+ * - splitting large chunks to allow more granular async rendering
+ * - adding a group for the "File" pseudo line that file-level comments can
+ * be attached to
+ * - replacing common parts of the diff that are outside the user's
+ * context setting and do not have comments with a group representing the
+ * "expand context" widget. This may require splitting a chunk/group so
+ * that the part that is within the context or has comments is shown, while
+ * the rest is not.
+ */
+export class GrDiffProcessor {
+ context = 3;
+
+ consumer?: GroupConsumer;
+
+ keyLocations: KeyLocations = {left: {}, right: {}};
+
+ asyncThreshold = 64;
+
+ // visible for testing
+ isScrolling?: boolean;
+
+ /** Just for making sure that process() is only called once. */
+ private isStarted = false;
+
+ /** Indicates that processing should be stopped. */
+ private isCancelled = false;
+
+ private resetIsScrollingTask?: DelayedTask;
+
+ private readonly handleWindowScroll = () => {
+ this.isScrolling = true;
+ this.resetIsScrollingTask = debounce(
+ this.resetIsScrollingTask,
+ () => (this.isScrolling = false),
+ 50
+ );
+ };
+
+ /**
+ * Asynchronously process the diff chunks into groups. As it processes, it
+ * will splice groups into the `groups` property of the component.
+ *
+ * @return A promise that resolves with an
+ * array of GrDiffGroups when the diff is completely processed.
+ */
+ process(chunks: DiffContent[], isBinary: boolean) {
+ assert(this.isStarted === false, 'diff processor cannot be started twice');
+ this.isStarted = true;
+
+ window.addEventListener('scroll', this.handleWindowScroll);
+
+ assertIsDefined(this.consumer, 'consumer');
+ this.consumer.clearGroups();
+ this.consumer.addGroup(this.makeGroup(LOST));
+ this.consumer.addGroup(this.makeGroup(FILE));
+
+ if (isBinary) return Promise.resolve();
+
+ return new Promise<void>(resolve => {
+ const state = {
+ lineNums: {left: 0, right: 0},
+ chunkIndex: 0,
+ };
+
+ chunks = this.splitLargeChunks(chunks);
+ chunks = this.splitCommonChunksWithKeyLocations(chunks);
+
+ let currentBatch = 0;
+ const nextStep = () => {
+ if (this.isCancelled || state.chunkIndex >= chunks.length) {
+ resolve();
+ return;
+ }
+ if (this.isScrolling) {
+ window.setTimeout(nextStep, 100);
+ return;
+ }
+
+ const stateUpdate = this.processNext(state, chunks);
+ for (const group of stateUpdate.groups) {
+ this.consumer?.addGroup(group);
+ currentBatch += group.lines.length;
+ }
+ state.lineNums.left += stateUpdate.lineDelta.left;
+ state.lineNums.right += stateUpdate.lineDelta.right;
+
+ state.chunkIndex = stateUpdate.newChunkIndex;
+ if (currentBatch >= this.asyncThreshold) {
+ currentBatch = 0;
+ window.setTimeout(nextStep, 1);
+ } else {
+ nextStep.call(this);
+ }
+ };
+
+ nextStep.call(this);
+ }).finally(() => {
+ this.finish();
+ });
+ }
+
+ finish() {
+ this.consumer = undefined;
+ window.removeEventListener('scroll', this.handleWindowScroll);
+ }
+
+ cancel() {
+ this.isCancelled = true;
+ this.finish();
+ }
+
+ /**
+ * Process the next uncollapsible chunk, or the next collapsible chunks.
+ */
+ // visible for testing
+ processNext(state: State, chunks: DiffContent[]) {
+ const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
+ chunks,
+ state.chunkIndex
+ );
+ if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+ const chunk = chunks[state.chunkIndex];
+ return {
+ lineDelta: {
+ left: this.linesLeft(chunk).length,
+ right: this.linesRight(chunk).length,
+ },
+ groups: [
+ this.chunkToGroup(
+ chunk,
+ state.lineNums.left + 1,
+ state.lineNums.right + 1
+ ),
+ ],
+ newChunkIndex: state.chunkIndex + 1,
+ };
+ }
+
+ return this.processCollapsibleChunks(
+ state,
+ chunks,
+ firstUncollapsibleChunkIndex
+ );
+ }
+
+ private linesLeft(chunk: DiffContent) {
+ return chunk.ab || chunk.a || [];
+ }
+
+ private linesRight(chunk: DiffContent) {
+ return chunk.ab || chunk.b || [];
+ }
+
+ private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
+ let chunkIndex = offset;
+ while (
+ chunkIndex < chunks.length &&
+ this.isCollapsibleChunk(chunks[chunkIndex])
+ ) {
+ chunkIndex++;
+ }
+ return chunkIndex;
+ }
+
+ private isCollapsibleChunk(chunk: DiffContent) {
+ return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
+ }
+
+ /**
+ * Process a stretch of collapsible chunks.
+ *
+ * Outputs up to three groups:
+ * 1) Visible context before the hidden common code, unless it's the
+ * very beginning of the file.
+ * 2) Context hidden behind a context bar, unless empty.
+ * 3) Visible context after the hidden common code, unless it's the very
+ * end of the file.
+ */
+ private processCollapsibleChunks(
+ state: State,
+ chunks: DiffContent[],
+ firstUncollapsibleChunkIndex: number
+ ) {
+ const collapsibleChunks = chunks.slice(
+ state.chunkIndex,
+ firstUncollapsibleChunkIndex
+ );
+ const lineCount = collapsibleChunks.reduce(
+ (sum, chunk) => sum + this.commonChunkLength(chunk),
+ 0
+ );
+
+ let groups = this.chunksToGroups(
+ collapsibleChunks,
+ state.lineNums.left + 1,
+ state.lineNums.right + 1
+ );
+
+ const hasSkippedGroup = !!groups.find(g => g.skip);
+ if (this.context !== WHOLE_FILE || hasSkippedGroup) {
+ const contextNumLines = this.context > 0 ? this.context : 0;
+ const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
+ const hiddenEnd =
+ lineCount -
+ (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+ groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+ }
+
+ return {
+ lineDelta: {
+ left: lineCount,
+ right: lineCount,
+ },
+ groups,
+ newChunkIndex: firstUncollapsibleChunkIndex,
+ };
+ }
+
+ private commonChunkLength(chunk: DiffContent) {
+ if (chunk.skip) {
+ return chunk.skip;
+ }
+ console.assert(!!chunk.ab || !!chunk.common);
+
+ console.assert(
+ !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
+ 'common chunk needs same number of a and b lines: ',
+ chunk
+ );
+ return this.linesLeft(chunk).length;
+ }
+
+ private chunksToGroups(
+ chunks: DiffContent[],
+ offsetLeft: number,
+ offsetRight: number
+ ): GrDiffGroup[] {
+ return chunks.map(chunk => {
+ const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
+ const chunkLength = this.commonChunkLength(chunk);
+ offsetLeft += chunkLength;
+ offsetRight += chunkLength;
+ return group;
+ });
+ }
+
+ private chunkToGroup(
+ chunk: DiffContent,
+ offsetLeft: number,
+ offsetRight: number
+ ): GrDiffGroup {
+ const type =
+ chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
+ const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
+ const options = {
+ moveDetails: chunk.move_details,
+ dueToRebase: !!chunk.due_to_rebase,
+ ignoredWhitespaceOnly: !!chunk.common,
+ keyLocation: !!chunk.keyLocation,
+ };
+ if (chunk.skip !== undefined) {
+ return new GrDiffGroup({
+ type,
+ skip: chunk.skip,
+ offsetLeft,
+ offsetRight,
+ ...options,
+ });
+ } else {
+ return new GrDiffGroup({
+ type,
+ lines,
+ ...options,
+ });
+ }
+ }
+
+ private linesFromChunk(
+ chunk: DiffContent,
+ offsetLeft: number,
+ offsetRight: number
+ ) {
+ if (chunk.ab) {
+ return chunk.ab.map((row, i) =>
+ this.lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
+ );
+ }
+ let lines: GrDiffLine[] = [];
+ if (chunk.a) {
+ // Avoiding a.push(...b) because that causes callstack overflows for
+ // large b, which can occur when large files are added removed.
+ lines = lines.concat(
+ this.linesFromRows(
+ GrDiffLineType.REMOVE,
+ chunk.a,
+ offsetLeft,
+ chunk.edit_a
+ )
+ );
+ }
+ if (chunk.b) {
+ // Avoiding a.push(...b) because that causes callstack overflows for
+ // large b, which can occur when large files are added removed.
+ lines = lines.concat(
+ this.linesFromRows(
+ GrDiffLineType.ADD,
+ chunk.b,
+ offsetRight,
+ chunk.edit_b
+ )
+ );
+ }
+ return lines;
+ }
+
+ // visible for testing
+ linesFromRows(
+ lineType: GrDiffLineType,
+ rows: string[],
+ offset: number,
+ intralineInfos?: number[][]
+ ): GrDiffLine[] {
+ const grDiffHighlights = intralineInfos
+ ? this.convertIntralineInfos(rows, intralineInfos)
+ : undefined;
+ return rows.map((row, i) =>
+ this.lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
+ );
+ }
+
+ private lineFromRow(
+ type: GrDiffLineType,
+ offsetLeft: number,
+ offsetRight: number,
+ row: string,
+ i: number,
+ highlights?: Highlights[]
+ ): GrDiffLine {
+ const line = new GrDiffLine(type);
+ line.text = row;
+ if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
+ if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
+ if (highlights) {
+ line.hasIntralineInfo = true;
+ line.highlights = highlights.filter(hl => hl.contentIndex === i);
+ } else {
+ line.hasIntralineInfo = false;
+ }
+ return line;
+ }
+
+ private makeGroup(number: LineNumber) {
+ const line = new GrDiffLine(GrDiffLineType.BOTH);
+ line.beforeNumber = number;
+ line.afterNumber = number;
+ return new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [line]});
+ }
+
+ /**
+ * Split chunks into smaller chunks of the same kind.
+ *
+ * This is done to prevent doing too much work on the main thread in one
+ * uninterrupted rendering step, which would make the browser unresponsive.
+ *
+ * Note that in the case of unmodified chunks, we only split chunks if the
+ * context is set to file (because otherwise they are split up further down
+ * the processing into the visible and hidden context), and only split it
+ * into 2 chunks, one max sized one and the rest (for reasons that are
+ * unclear to me).
+ *
+ * @param chunks Chunks as returned from the server
+ * @return Finer grained chunks.
+ */
+ // visible for testing
+ splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
+ const newChunks = [];
+
+ for (const chunk of chunks) {
+ if (!chunk.ab) {
+ for (const subChunk of this.breakdownChunk(chunk)) {
+ newChunks.push(subChunk);
+ }
+ continue;
+ }
+
+ // If the context is set to "whole file", then break down the shared
+ // chunks so they can be rendered incrementally. Note: this is not
+ // enabled for any other context preference because manipulating the
+ // chunks in this way violates assumptions by the context grouper logic.
+ const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
+ if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
+ // Split large shared chunks in two, where the first is the maximum
+ // group size.
+ newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
+ newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
+ } else {
+ newChunks.push(chunk);
+ }
+ }
+ return newChunks;
+ }
+
+ /**
+ * In order to show key locations, such as comments, out of the bounds of
+ * the selected context, treat them as separate chunks within the model so
+ * that the content (and context surrounding it) renders correctly.
+ *
+ * @param chunks DiffContents as returned from server.
+ * @return Finer grained DiffContents.
+ */
+ // visible for testing
+ splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
+ const result = [];
+ let leftLineNum = 1;
+ let rightLineNum = 1;
+
+ for (const chunk of chunks) {
+ // If it isn't a common chunk, append it as-is and update line numbers.
+ if (!chunk.ab && !chunk.skip && !chunk.common) {
+ if (chunk.a) {
+ leftLineNum += chunk.a.length;
+ }
+ if (chunk.b) {
+ rightLineNum += chunk.b.length;
+ }
+ result.push(chunk);
+ continue;
+ }
+
+ if (chunk.common && chunk.a!.length !== chunk.b!.length) {
+ throw new Error(
+ 'DiffContent with common=true must always have equal length'
+ );
+ }
+ const numLines = this.commonChunkLength(chunk);
+ const chunkEnds = this.findChunkEndsAtKeyLocations(
+ numLines,
+ leftLineNum,
+ rightLineNum
+ );
+ leftLineNum += numLines;
+ rightLineNum += numLines;
+
+ if (chunk.skip) {
+ result.push({
+ ...chunk,
+ skip: chunk.skip,
+ keyLocation: false,
+ });
+ } else if (chunk.ab) {
+ result.push(
+ ...this.splitAtChunkEnds(chunk.ab, chunkEnds).map(
+ ({lines, keyLocation}) => {
+ return {
+ ...chunk,
+ ab: lines,
+ keyLocation,
+ };
+ }
+ )
+ );
+ } else if (chunk.common) {
+ const aChunks = this.splitAtChunkEnds(chunk.a!, chunkEnds);
+ const bChunks = this.splitAtChunkEnds(chunk.b!, chunkEnds);
+ result.push(
+ ...aChunks.map(({lines, keyLocation}, i) => {
+ return {
+ ...chunk,
+ a: lines,
+ b: bChunks[i].lines,
+ keyLocation,
+ };
+ })
+ );
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * @return Offsets of the new chunk ends, including whether it's a key
+ * location.
+ */
+ private findChunkEndsAtKeyLocations(
+ numLines: number,
+ leftOffset: number,
+ rightOffset: number
+ ): ChunkEnd[] {
+ const result = [];
+ let lastChunkEnd = 0;
+ for (let i = 0; i < numLines; i++) {
+ // If this line should not be collapsed.
+ if (
+ this.keyLocations[Side.LEFT][leftOffset + i] ||
+ this.keyLocations[Side.RIGHT][rightOffset + i]
+ ) {
+ // If any lines have been accumulated into the chunk leading up to
+ // this non-collapse line, then add them as a chunk and start a new
+ // one.
+ if (i > lastChunkEnd) {
+ result.push({offset: i, keyLocation: false});
+ lastChunkEnd = i;
+ }
+
+ // Add the non-collapse line as its own chunk.
+ result.push({offset: i + 1, keyLocation: true});
+ }
+ }
+
+ if (numLines > lastChunkEnd) {
+ result.push({offset: numLines, keyLocation: false});
+ }
+
+ return result;
+ }
+
+ private splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
+ const result = [];
+ let lastChunkEndOffset = 0;
+ for (const {offset, keyLocation} of chunkEnds) {
+ if (lastChunkEndOffset === offset) continue;
+ result.push({
+ lines: lines.slice(lastChunkEndOffset, offset),
+ keyLocation,
+ });
+ lastChunkEndOffset = offset;
+ }
+ return result;
+ }
+
+ /**
+ * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+ * for rendering.
+ */
+ // visible for testing
+ convertIntralineInfos(
+ rows: string[],
+ intralineInfos: number[][]
+ ): Highlights[] {
+ // +1 to account for the \n that is not part of the rows passed here
+ const lineLengths = rows.map(r => GrAnnotation.getStringLength(r) + 1);
+
+ let rowIndex = 0;
+ let idx = 0;
+ const normalized = [];
+ for (const [skipLength, markLength] of intralineInfos) {
+ let lineLength = lineLengths[rowIndex];
+ let j = 0;
+ while (j < skipLength) {
+ if (idx === lineLength) {
+ idx = 0;
+ lineLength = lineLengths[++rowIndex];
+ continue;
+ }
+ idx++;
+ j++;
+ }
+ let lineHighlight: Highlights = {
+ contentIndex: rowIndex,
+ startIndex: idx,
+ };
+
+ j = 0;
+ while (lineLength && j < markLength) {
+ if (idx === lineLength) {
+ idx = 0;
+ lineLength = lineLengths[++rowIndex];
+ normalized.push(lineHighlight);
+ lineHighlight = {
+ contentIndex: rowIndex,
+ startIndex: idx,
+ };
+ continue;
+ }
+ idx++;
+ j++;
+ }
+ lineHighlight.endIndex = idx;
+ normalized.push(lineHighlight);
+ }
+ return normalized;
+ }
+
+ /**
+ * If a group is an addition or a removal, break it down into smaller groups
+ * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
+ * or a delta it is returned as the single element of the result array.
+ */
+ // visible for testing
+ breakdownChunk(chunk: DiffContent): DiffContent[] {
+ let key: 'a' | 'b' | 'ab' | null = null;
+ const {a, b, ab, move_details} = chunk;
+ if (a?.length && !b?.length) {
+ key = 'a';
+ } else if (b?.length && !a?.length) {
+ key = 'b';
+ } else if (ab?.length) {
+ key = 'ab';
+ }
+
+ // Move chunks should not be divided because of move label
+ // positioned in the top of the chunk
+ if (!key || move_details) {
+ return [chunk];
+ }
+
+ const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
+ return this.breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
+ const subChunk: DiffContent = {};
+ subChunk[key!] = subChunkLines;
+ if (chunk.due_to_rebase) {
+ subChunk.due_to_rebase = true;
+ }
+ if (chunk.move_details) {
+ subChunk.move_details = chunk.move_details;
+ }
+ return subChunk;
+ });
+ }
+
+ /**
+ * Given an array and a size, return an array of arrays where no inner array
+ * is larger than that size, preserving the original order.
+ */
+ // visible for testing
+ breakdown<T>(array: T[], size: number): T[][] {
+ if (!array.length) {
+ return [];
+ }
+ if (array.length < size) {
+ return [array];
+ }
+
+ const head = array.slice(0, array.length - size);
+ const tail = array.slice(array.length - size);
+
+ return this.breakdown(head, size).concat([tail]);
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-processor/gr-diff-processor_test.ts
new file mode 100644
index 0000000..335f0d0
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-processor/gr-diff-processor_test.ts
@@ -0,0 +1,1136 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-processor';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffProcessor, State} from './gr-diff-processor';
+import {DiffContent} from '../../../types/diff';
+import {assert} from '@open-wc/testing';
+import {FILE, GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-processor tests', () => {
+ const WHOLE_FILE = -1;
+ const loremIpsum =
+ 'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+ 'Duo animal omnesque fabellas et. Id has phaedrum dignissim ' +
+ 'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+ 'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+ 'fugit assum per.';
+
+ let element: GrDiffProcessor;
+ let groups: GrDiffGroup[];
+
+ setup(() => {});
+
+ suite('not logged in', () => {
+ setup(() => {
+ groups = [];
+ element = new GrDiffProcessor();
+ element.consumer = {
+ addGroup(group: GrDiffGroup) {
+ groups.push(group);
+ },
+ clearGroups() {
+ groups = [];
+ },
+ };
+ element.context = 4;
+ });
+
+ test('process loaded content', () => {
+ const content: DiffContent[] = [
+ {
+ ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
+ },
+ {
+ a: [' Welcome ', ' to the wooorld of tomorrow!'],
+ b: [' Hello, world!'],
+ },
+ {
+ ab: [
+ 'Leela: This is the only place the ship can’t hear us, so ',
+ 'everyone pretend to shower.',
+ 'Fry: Same as every day. Got it.',
+ ],
+ },
+ ];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+ assert.equal(groups.length, 4);
+
+ let group = groups[0];
+ assert.equal(group.type, GrDiffGroupType.BOTH);
+ assert.equal(group.lines.length, 1);
+ assert.equal(group.lines[0].text, '');
+ assert.equal(group.lines[0].beforeNumber, FILE);
+ assert.equal(group.lines[0].afterNumber, FILE);
+
+ group = groups[1];
+ assert.equal(group.type, GrDiffGroupType.BOTH);
+ assert.equal(group.lines.length, 2);
+
+ function beforeNumberFn(l: GrDiffLine) {
+ return l.beforeNumber;
+ }
+ function afterNumberFn(l: GrDiffLine) {
+ return l.afterNumber;
+ }
+ function textFn(l: GrDiffLine) {
+ return l.text;
+ }
+
+ assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+ assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+ assert.deepEqual(group.lines.map(textFn), [
+ '<!DOCTYPE html>',
+ '<meta charset="utf-8">',
+ ]);
+
+ group = groups[2];
+ assert.equal(group.type, GrDiffGroupType.DELTA);
+ assert.equal(group.lines.length, 3);
+ assert.equal(group.adds.length, 1);
+ assert.equal(group.removes.length, 2);
+ assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+ assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+ assert.deepEqual(group.removes.map(textFn), [
+ ' Welcome ',
+ ' to the wooorld of tomorrow!',
+ ]);
+ assert.deepEqual(group.adds.map(textFn), [' Hello, world!']);
+
+ group = groups[3];
+ assert.equal(group.type, GrDiffGroupType.BOTH);
+ assert.equal(group.lines.length, 3);
+ assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+ assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+ assert.deepEqual(group.lines.map(textFn), [
+ 'Leela: This is the only place the ship can’t hear us, so ',
+ 'everyone pretend to shower.',
+ 'Fry: Same as every day. Got it.',
+ ]);
+ });
+ });
+
+ test('first group is for file', () => {
+ const content = [{b: ['foo']}];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ assert.equal(groups[0].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[0].lines.length, 1);
+ assert.equal(groups[0].lines[0].text, '');
+ assert.equal(groups[0].lines[0].beforeNumber, FILE);
+ assert.equal(groups[0].lines[0].afterNumber, FILE);
+ });
+ });
+
+ suite('context groups', () => {
+ test('at the beginning, larger than context', () => {
+ element.context = 10;
+ const content = [
+ {
+ ab: Array.from<string>({length: 100}).fill(
+ 'all work and no play make jack a dull boy'
+ ),
+ },
+ {a: ['all work and no play make andybons a dull boy']},
+ ];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+
+ assert.equal(groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(groups[1].contextGroups[0], GrDiffGroup);
+ assert.equal(groups[1].contextGroups[0].lines.length, 90);
+ for (const l of groups[1].contextGroups[0].lines) {
+ assert.equal(l.text, 'all work and no play make jack a dull boy');
+ }
+
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 10);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'all work and no play make jack a dull boy');
+ }
+ });
+ });
+
+ test('at the beginning with skip chunks', async () => {
+ element.context = 10;
+ const content = [
+ {
+ ab: Array.from<string>({length: 20}).fill(
+ 'all work and no play make jack a dull boy'
+ ),
+ },
+ {skip: 43900},
+ {ab: Array.from<string>({length: 30}).fill('some other content')},
+ {a: ['some other content']},
+ ];
+
+ await element.process(content, false);
+
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+
+ const commonGroup = groups[1];
+
+ // Hidden context before
+ assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+ assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+ for (const l of commonGroup.contextGroups[0].lines) {
+ assert.equal(l.text, 'all work and no play make jack a dull boy');
+ }
+
+ // Skipped group
+ const skipGroup = commonGroup.contextGroups[1];
+ assert.equal(skipGroup.skip, 43900);
+ const expectedRange = {
+ left: {start_line: 21, end_line: 43920},
+ right: {start_line: 21, end_line: 43920},
+ };
+ assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+ // Hidden context after
+ assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+ for (const l of commonGroup.contextGroups[2].lines) {
+ assert.equal(l.text, 'some other content');
+ }
+
+ // Displayed lines
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 10);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'some other content');
+ }
+ });
+
+ test('at the beginning, smaller than context', () => {
+ element.context = 10;
+ const content = [
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jack a dull boy'
+ ),
+ },
+ {a: ['all work and no play make andybons a dull boy']},
+ ];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+
+ assert.equal(groups[1].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[1].lines.length, 5);
+ for (const l of groups[1].lines) {
+ assert.equal(l.text, 'all work and no play make jack a dull boy');
+ }
+ });
+ });
+
+ test('at the end, larger than context', () => {
+ element.context = 10;
+ const content = [
+ {a: ['all work and no play make andybons a dull boy']},
+ {
+ ab: Array.from<string>({length: 100}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ ];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+ // group[1] is the "a" group
+
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 10);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+
+ assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+ assert.equal(groups[3].contextGroups[0].lines.length, 90);
+ for (const l of groups[3].contextGroups[0].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ });
+ });
+
+ test('at the end, smaller than context', () => {
+ element.context = 10;
+ const content = [
+ {a: ['all work and no play make andybons a dull boy']},
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ ];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+ // group[1] is the "a" group
+
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 5);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ });
+ });
+
+ test('for interleaved ab and common: true chunks', () => {
+ element.context = 10;
+ const content = [
+ {a: ['all work and no play make andybons a dull boy']},
+ {
+ ab: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ {
+ a: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ b: Array.from<string>({length: 3}).fill(
+ ' all work and no play make jill a dull girl'
+ ),
+ common: true,
+ },
+ {
+ ab: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ {
+ a: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ b: Array.from<string>({length: 3}).fill(
+ ' all work and no play make jill a dull girl'
+ ),
+ common: true,
+ },
+ {
+ ab: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ ];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+ // group[1] is the "a" group
+
+ // The first three interleaved chunks are completely shown because
+ // they are part of the context (3 * 3 <= 10)
+
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 3);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+
+ assert.equal(groups[3].type, GrDiffGroupType.DELTA);
+ assert.equal(groups[3].lines.length, 6);
+ assert.equal(groups[3].adds.length, 3);
+ assert.equal(groups[3].removes.length, 3);
+ for (const l of groups[3].removes) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ for (const l of groups[3].adds) {
+ assert.equal(
+ l.text,
+ ' all work and no play make jill a dull girl'
+ );
+ }
+
+ assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[4].lines.length, 3);
+ for (const l of groups[4].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+
+ // The next chunk is partially shown, so it results in two groups
+
+ assert.equal(groups[5].type, GrDiffGroupType.DELTA);
+ assert.equal(groups[5].lines.length, 2);
+ assert.equal(groups[5].adds.length, 1);
+ assert.equal(groups[5].removes.length, 1);
+ for (const l of groups[5].removes) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ for (const l of groups[5].adds) {
+ assert.equal(
+ l.text,
+ ' all work and no play make jill a dull girl'
+ );
+ }
+
+ assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(groups[6].contextGroups.length, 2);
+
+ assert.equal(groups[6].contextGroups[0].lines.length, 4);
+ assert.equal(groups[6].contextGroups[0].removes.length, 2);
+ assert.equal(groups[6].contextGroups[0].adds.length, 2);
+ for (const l of groups[6].contextGroups[0].removes) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ for (const l of groups[6].contextGroups[0].adds) {
+ assert.equal(
+ l.text,
+ ' all work and no play make jill a dull girl'
+ );
+ }
+
+ // The final chunk is completely hidden
+ assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[6].contextGroups[1].lines.length, 3);
+ for (const l of groups[6].contextGroups[1].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ });
+ });
+
+ test('in the middle, larger than context', () => {
+ element.context = 10;
+ const content = [
+ {a: ['all work and no play make andybons a dull boy']},
+ {
+ ab: Array.from<string>({length: 100}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ {a: ['all work and no play make andybons a dull boy']},
+ ];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+ // group[1] is the "a" group
+
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 10);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+
+ assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+ assert.equal(groups[3].contextGroups[0].lines.length, 80);
+ for (const l of groups[3].contextGroups[0].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+
+ assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[4].lines.length, 10);
+ for (const l of groups[4].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ });
+ });
+
+ test('in the middle, smaller than context', () => {
+ element.context = 10;
+ const content = [
+ {a: ['all work and no play make andybons a dull boy']},
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ {a: ['all work and no play make andybons a dull boy']},
+ ];
+
+ return element.process(content, false).then(() => {
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+ // group[1] is the "a" group
+
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 5);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ });
+ });
+ });
+
+ test('in the middle with skip chunks', async () => {
+ element.context = 10;
+ const content = [
+ {a: ['all work and no play make andybons a dull boy']},
+ {
+ ab: Array.from<string>({length: 20}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ {skip: 60},
+ {
+ ab: Array.from<string>({length: 20}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ {a: ['all work and no play make andybons a dull boy']},
+ ];
+
+ await element.process(content, false);
+
+ groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+ // group[0] is the file group
+ // group[1] is the chunk with a
+ // group[2] is the displayed part of ab before
+
+ const commonGroup = groups[3];
+
+ // Hidden context before
+ assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+ assert.equal(commonGroup.contextGroups[0].lines.length, 10);
+ for (const l of commonGroup.contextGroups[0].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+
+ // Skipped group
+ const skipGroup = commonGroup.contextGroups[1];
+ assert.equal(skipGroup.skip, 60);
+ const expectedRange = {
+ left: {start_line: 22, end_line: 81},
+ right: {start_line: 21, end_line: 80},
+ };
+ assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+ // Hidden context after
+ assert.equal(commonGroup.contextGroups[2].lines.length, 10);
+ for (const l of commonGroup.contextGroups[2].lines) {
+ assert.equal(l.text, 'all work and no play make jill a dull girl');
+ }
+ // group[4] is the displayed part of the second ab
+ });
+
+ test('works with skip === 0', async () => {
+ element.context = 3;
+ const content = [
+ {
+ skip: 0,
+ },
+ {
+ b: [
+ '/**',
+ ' * @license',
+ ' * Copyright 2015 Google LLC',
+ ' * SPDX-License-Identifier: Apache-2.0',
+ ' */',
+ "import '../../../test/common-test-setup';",
+ ],
+ },
+ ];
+ await element.process(content, false);
+ });
+
+ test('break up common diff chunks', () => {
+ element.keyLocations = {
+ left: {1: true},
+ right: {10: true},
+ };
+
+ const content = [
+ {
+ ab: [
+ 'copy',
+ '',
+ 'asdf',
+ 'qwer',
+ 'zxcv',
+ '',
+ 'http',
+ '',
+ 'vbnm',
+ 'dfgh',
+ 'yuio',
+ 'sdfg',
+ '1234',
+ ],
+ },
+ ];
+ const result = element.splitCommonChunksWithKeyLocations(content);
+ assert.deepEqual(result, [
+ {
+ ab: ['copy'],
+ keyLocation: true,
+ },
+ {
+ ab: ['', 'asdf', 'qwer', 'zxcv', '', 'http', '', 'vbnm'],
+ keyLocation: false,
+ },
+ {
+ ab: ['dfgh'],
+ keyLocation: true,
+ },
+ {
+ ab: ['yuio', 'sdfg', '1234'],
+ keyLocation: false,
+ },
+ ]);
+ });
+
+ test('breaks down shared chunks w/ whole-file', () => {
+ const maxGroupSize = 128;
+ const size = maxGroupSize * 2 + 5;
+ const ab = Array(size)
+ .fill(0)
+ .map(() => `${Math.random()}`);
+ const content = [{ab}];
+ element.context = -1;
+ const result = element.splitLargeChunks(content);
+ assert.equal(result.length, 2);
+ assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
+ assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
+ });
+
+ test('breaks down added chunks', () => {
+ const maxGroupSize = 128;
+ const size = maxGroupSize * 2 + 5;
+ const content = Array(size)
+ .fill(0)
+ .map(() => `${Math.random()}`);
+ element.context = 5;
+ const splitContent = element
+ .splitLargeChunks([{a: [], b: content}])
+ .map(r => r.b);
+ assert.equal(splitContent.length, 3);
+ assert.deepEqual(splitContent[0], content.slice(0, 5));
+ assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
+ assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
+ });
+
+ test('breaks down removed chunks', () => {
+ const maxGroupSize = 128;
+ const size = maxGroupSize * 2 + 5;
+ const content = Array(size)
+ .fill(0)
+ .map(() => `${Math.random()}`);
+ element.context = 5;
+ const splitContent = element
+ .splitLargeChunks([{a: content, b: []}])
+ .map(r => r.a);
+ assert.equal(splitContent.length, 3);
+ assert.deepEqual(splitContent[0], content.slice(0, 5));
+ assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
+ assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
+ });
+
+ test('does not break down moved chunks', () => {
+ const size = 120 * 2 + 5;
+ const content = Array(size)
+ .fill(0)
+ .map(() => `${Math.random()}`);
+ element.context = 5;
+ const splitContent = element
+ .splitLargeChunks([
+ {
+ a: content,
+ b: [],
+ move_details: {changed: false, range: {start: 1, end: 1}},
+ },
+ ])
+ .map(r => r.a);
+ assert.equal(splitContent.length, 1);
+ assert.deepEqual(splitContent[0], content);
+ });
+
+ test('does not break-down common chunks w/ context', () => {
+ const ab = Array(75)
+ .fill(0)
+ .map(() => `${Math.random()}`);
+ const content = [{ab}];
+ element.context = 4;
+ const result = element.splitCommonChunksWithKeyLocations(content);
+ assert.equal(result.length, 1);
+ assert.deepEqual(result[0].ab, content[0].ab);
+ assert.isFalse(result[0].keyLocation);
+ });
+
+ test('intraline normalization', () => {
+ // The content and highlights are in the format returned by the Gerrit
+ // REST API.
+ let content = [
+ ' <section class="summary">',
+ ' <gr-formatted-text content="' +
+ '[[_computeCurrentRevisionMessage(change)]]"></gr-formatted-text>',
+ ' </section>',
+ ];
+ let highlights = [
+ [31, 34],
+ [42, 26],
+ ];
+
+ let results = element.convertIntralineInfos(content, highlights);
+ assert.deepEqual(results, [
+ {
+ contentIndex: 0,
+ startIndex: 31,
+ },
+ {
+ contentIndex: 1,
+ startIndex: 0,
+ endIndex: 33,
+ },
+ {
+ contentIndex: 1,
+ endIndex: 101,
+ startIndex: 75,
+ },
+ ]);
+ const lines = element.linesFromRows(
+ GrDiffLineType.BOTH,
+ content,
+ 0,
+ highlights
+ );
+ assert.equal(lines.length, 3);
+ assert.isTrue(lines[0].hasIntralineInfo);
+ assert.equal(lines[0].highlights.length, 1);
+ assert.isTrue(lines[1].hasIntralineInfo);
+ assert.equal(lines[1].highlights.length, 2);
+ assert.isTrue(lines[2].hasIntralineInfo);
+ assert.equal(lines[2].highlights.length, 0);
+
+ content = [
+ ' this._path = value.path;',
+ '',
+ ' // 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 (!this._patchRange.patchNum) {',
+ ];
+ highlights = [
+ [14, 17],
+ [11, 70],
+ [12, 67],
+ [12, 67],
+ [14, 29],
+ ];
+ results = element.convertIntralineInfos(content, highlights);
+ assert.deepEqual(results, [
+ {
+ contentIndex: 0,
+ startIndex: 14,
+ endIndex: 31,
+ },
+ {
+ contentIndex: 2,
+ startIndex: 8,
+ endIndex: 78,
+ },
+ {
+ contentIndex: 3,
+ startIndex: 11,
+ endIndex: 78,
+ },
+ {
+ contentIndex: 4,
+ startIndex: 11,
+ endIndex: 78,
+ },
+ {
+ contentIndex: 5,
+ startIndex: 12,
+ endIndex: 41,
+ },
+ ]);
+
+ content = ['🙈 a', '🙉 b', '🙊 c'];
+ highlights = [[2, 7]];
+ results = element.convertIntralineInfos(content, highlights);
+ assert.deepEqual(results, [
+ {
+ contentIndex: 0,
+ startIndex: 2,
+ },
+ {
+ contentIndex: 1,
+ startIndex: 0,
+ },
+ {
+ contentIndex: 2,
+ startIndex: 0,
+ endIndex: 1,
+ },
+ ]);
+ });
+
+ test('isScrolling paused', () => {
+ const content = Array(200).fill({ab: ['', '']});
+ element.isScrolling = true;
+ element.process(content, false);
+ // Just the FILE and LOST groups.
+ assert.equal(groups.length, 2);
+ });
+
+ test('isScrolling unpaused', () => {
+ const content = Array(200).fill({ab: ['', '']});
+ element.isScrolling = false;
+ element.process(content, false);
+ // More groups have been processed. How many does not matter here.
+ assert.isAtLeast(groups.length, 3);
+ });
+
+ test('image diffs', () => {
+ const content = Array(200).fill({ab: ['', '']});
+ element.process(content, true);
+ assert.equal(groups.length, 2);
+
+ // Image diffs don't process content, just the 'FILE' line.
+ assert.equal(groups[0].lines.length, 1);
+ });
+
+ suite('processNext', () => {
+ let rows: string[];
+
+ setup(() => {
+ rows = loremIpsum.split(' ');
+ });
+
+ test('WHOLE_FILE', () => {
+ element.context = WHOLE_FILE;
+ const state: State = {
+ lineNums: {left: 10, right: 100},
+ chunkIndex: 1,
+ };
+ const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+ const result = element.processNext(state, chunks);
+
+ // Results in one, uncollapsed group with all rows.
+ assert.equal(result.groups.length, 1);
+ assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+ assert.equal(result.groups[0].lines.length, rows.length);
+
+ // Line numbers are set correctly.
+ assert.equal(
+ result.groups[0].lines[0].beforeNumber,
+ state.lineNums.left + 1
+ );
+ assert.equal(
+ result.groups[0].lines[0].afterNumber,
+ state.lineNums.right + 1
+ );
+
+ assert.equal(
+ result.groups[0].lines[rows.length - 1].beforeNumber,
+ state.lineNums.left + rows.length
+ );
+ assert.equal(
+ result.groups[0].lines[rows.length - 1].afterNumber,
+ state.lineNums.right + rows.length
+ );
+ });
+
+ test('WHOLE_FILE with skip chunks still get collapsed', () => {
+ element.context = WHOLE_FILE;
+ const lineNums = {left: 10, right: 100};
+ const state = {
+ lineNums,
+ chunkIndex: 1,
+ };
+ const skip = 10000;
+ const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
+ const result = element.processNext(state, chunks);
+ // Results in one, uncollapsed group with all rows.
+ assert.equal(result.groups.length, 1);
+ assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
+
+ // Skip and ab group are hidden in the same context control
+ assert.equal(result.groups[0].contextGroups.length, 2);
+ const [skippedGroup, abGroup] = result.groups[0].contextGroups;
+
+ // Line numbers are set correctly.
+ assert.deepEqual(skippedGroup.lineRange, {
+ left: {
+ start_line: lineNums.left + 1,
+ end_line: lineNums.left + skip,
+ },
+ right: {
+ start_line: lineNums.right + 1,
+ end_line: lineNums.right + skip,
+ },
+ });
+
+ assert.deepEqual(abGroup.lineRange, {
+ left: {
+ start_line: lineNums.left + skip + 1,
+ end_line: lineNums.left + skip + rows.length,
+ },
+ right: {
+ start_line: lineNums.right + skip + 1,
+ end_line: lineNums.right + skip + rows.length,
+ },
+ });
+ });
+
+ test('with context', () => {
+ element.context = 10;
+ const state = {
+ lineNums: {left: 10, right: 100},
+ chunkIndex: 1,
+ };
+ const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+ const result = element.processNext(state, chunks);
+ const expectedCollapseSize = rows.length - 2 * element.context;
+
+ assert.equal(result.groups.length, 3, 'Results in three groups');
+
+ // The first and last are uncollapsed context, whereas the middle has
+ // a single context-control line.
+ assert.equal(result.groups[0].lines.length, element.context);
+ assert.equal(result.groups[2].lines.length, element.context);
+
+ // The collapsed group has the hidden lines as its context group.
+ assert.equal(
+ result.groups[1].contextGroups[0].lines.length,
+ expectedCollapseSize
+ );
+ });
+
+ test('first', () => {
+ element.context = 10;
+ const state = {
+ lineNums: {left: 10, right: 100},
+ chunkIndex: 0,
+ };
+ const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+ const result = element.processNext(state, chunks);
+ const expectedCollapseSize = rows.length - element.context;
+
+ assert.equal(result.groups.length, 2, 'Results in two groups');
+
+ // Only the first group is collapsed.
+ assert.equal(result.groups[1].lines.length, element.context);
+
+ // The collapsed group has the hidden lines as its context group.
+ assert.equal(
+ result.groups[0].contextGroups[0].lines.length,
+ expectedCollapseSize
+ );
+ });
+
+ test('few-rows', () => {
+ // Only ten rows.
+ rows = rows.slice(0, 10);
+ element.context = 10;
+ const state = {
+ lineNums: {left: 10, right: 100},
+ chunkIndex: 0,
+ };
+ const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+ const result = element.processNext(state, chunks);
+
+ // Results in one uncollapsed group with all rows.
+ assert.equal(result.groups.length, 1, 'Results in one group');
+ assert.equal(result.groups[0].lines.length, rows.length);
+ });
+
+ test('no single line collapse', () => {
+ rows = rows.slice(0, 7);
+ element.context = 3;
+ const state = {
+ lineNums: {left: 10, right: 100},
+ chunkIndex: 1,
+ };
+ const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+ const result = element.processNext(state, chunks);
+
+ // Results in one uncollapsed group with all rows.
+ assert.equal(result.groups.length, 1, 'Results in one group');
+ assert.equal(result.groups[0].lines.length, rows.length);
+ });
+
+ suite('with key location', () => {
+ let state: State;
+ let chunks: DiffContent[];
+
+ setup(() => {
+ state = {
+ lineNums: {left: 10, right: 100},
+ chunkIndex: 0,
+ };
+ element.context = 10;
+ chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
+ });
+
+ test('context before', () => {
+ state.chunkIndex = 0;
+ const result = element.processNext(state, chunks);
+
+ // The first chunk is split into two groups:
+ // 1) A context-control, hiding everything but the context before
+ // the key location.
+ // 2) The context before the key location.
+ // The key location is not processed in this call to processNext
+ assert.equal(result.groups.length, 2);
+ // The collapsed group has the hidden lines as its context group.
+ assert.equal(
+ result.groups[0].contextGroups[0].lines.length,
+ rows.length - element.context
+ );
+ assert.equal(result.groups[1].lines.length, element.context);
+ });
+
+ test('key location itself', () => {
+ state.chunkIndex = 1;
+ const result = element.processNext(state, chunks);
+
+ // The second chunk results in a single group, that is just the
+ // line with the key location
+ assert.equal(result.groups.length, 1);
+ assert.equal(result.groups[0].lines.length, 1);
+ assert.equal(result.lineDelta.left, 1);
+ assert.equal(result.lineDelta.right, 1);
+ });
+
+ test('context after', () => {
+ state.chunkIndex = 2;
+ const result = element.processNext(state, chunks);
+
+ // The last chunk is split into two groups:
+ // 1) The context after the key location.
+ // 1) A context-control, hiding everything but the context after the
+ // key location.
+ assert.equal(result.groups.length, 2);
+ assert.equal(result.groups[0].lines.length, element.context);
+ // The collapsed group has the hidden lines as its context group.
+ assert.equal(
+ result.groups[1].contextGroups[0].lines.length,
+ rows.length - element.context
+ );
+ });
+ });
+ });
+
+ suite('gr-diff-processor helpers', () => {
+ let rows: string[];
+
+ setup(() => {
+ rows = loremIpsum.split(' ');
+ });
+
+ test('linesFromRows', () => {
+ const startLineNum = 10;
+ let result = element.linesFromRows(
+ GrDiffLineType.ADD,
+ rows,
+ startLineNum + 1
+ );
+
+ assert.equal(result.length, rows.length);
+ assert.equal(result[0].type, GrDiffLineType.ADD);
+ assert.notOk(result[0].hasIntralineInfo);
+ assert.equal(result[0].afterNumber, startLineNum + 1);
+ assert.notOk(result[0].beforeNumber);
+ assert.equal(
+ result[result.length - 1].afterNumber,
+ startLineNum + rows.length
+ );
+ assert.notOk(result[result.length - 1].beforeNumber);
+
+ result = element.linesFromRows(
+ GrDiffLineType.REMOVE,
+ rows,
+ startLineNum + 1
+ );
+
+ assert.equal(result.length, rows.length);
+ assert.equal(result[0].type, GrDiffLineType.REMOVE);
+ assert.notOk(result[0].hasIntralineInfo);
+ assert.equal(result[0].beforeNumber, startLineNum + 1);
+ assert.notOk(result[0].afterNumber);
+ assert.equal(
+ result[result.length - 1].beforeNumber,
+ startLineNum + rows.length
+ );
+ assert.notOk(result[result.length - 1].afterNumber);
+ });
+ });
+
+ suite('breakdown*', () => {
+ test('breakdownChunk breaks down additions', () => {
+ const breakdownSpy = sinon.spy(element, 'breakdown');
+ const chunk = {b: ['blah', 'blah', 'blah']};
+ const result = element.breakdownChunk(chunk);
+ assert.deepEqual(result, [chunk]);
+ assert.isTrue(breakdownSpy.called);
+ });
+
+ test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
+ sinon.spy(element, 'breakdown');
+ const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+ const result = element.breakdownChunk(chunk);
+ for (const subResult of result) {
+ assert.isTrue(subResult.due_to_rebase);
+ }
+ });
+
+ test('breakdown common case', () => {
+ const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+ ' '
+ );
+ const size = 3;
+
+ const result = element.breakdown(array, size);
+
+ for (const subResult of result) {
+ assert.isAtMost(subResult.length, size);
+ }
+ const flattened = result.reduce((a, b) => a.concat(b), []);
+ assert.deepEqual(flattened, array);
+ });
+
+ test('breakdown smaller than size', () => {
+ const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+ ' '
+ );
+ const size = 10;
+ const expected = [array];
+
+ const result = element.breakdown(array, size);
+
+ assert.deepEqual(result, expected);
+ });
+
+ test('breakdown empty', () => {
+ const array: string[] = [];
+ const size = 10;
+ const expected: string[][] = [];
+
+ const result = element.breakdown(array, size);
+
+ assert.deepEqual(result, expected);
+ });
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-selection/gr-diff-selection.ts
new file mode 100644
index 0000000..a9ec6a2
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-selection/gr-diff-selection.ts
@@ -0,0 +1,247 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import {normalize} from '../gr-diff-highlight/gr-range-normalizer';
+import {
+ descendedFromClass,
+ parentWithClass,
+ querySelectorAll,
+} from '../../../utils/dom-util';
+import {DiffInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {
+ getLineElByChild,
+ getSide,
+ getSideByLineEl,
+ isThreadEl,
+} from '../../diff/gr-diff/gr-diff-utils';
+
+/**
+ * Possible CSS classes indicating the state of selection. Dynamically added/
+ * removed based on where the user clicks within the diff.
+ */
+const SelectionClass = {
+ COMMENT: 'selected-comment',
+ LEFT: 'selected-left',
+ RIGHT: 'selected-right',
+ BLAME: 'selected-blame',
+};
+
+function selectionClassForSide(side?: Side) {
+ return side === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT;
+}
+
+interface LinesCache {
+ left: string[] | null;
+ right: string[] | null;
+}
+
+function getNewCache(): LinesCache {
+ return {left: null, right: null};
+}
+
+export class GrDiffSelection {
+ // visible for testing
+ diff?: DiffInfo;
+
+ // visible for testing
+ diffTable?: HTMLElement;
+
+ // visible for testing
+ linesCache: LinesCache = getNewCache();
+
+ init(diff: DiffInfo, diffTable: HTMLElement) {
+ this.cleanup();
+ this.diff = diff;
+ this.diffTable = diffTable;
+ this.diffTable.classList.add(SelectionClass.RIGHT);
+ this.diffTable.addEventListener('copy', this.handleCopy);
+ this.diffTable.addEventListener('mousedown', this.handleDown);
+ this.linesCache = getNewCache();
+ }
+
+ cleanup() {
+ if (!this.diffTable) return;
+ this.diffTable.removeEventListener('copy', this.handleCopy);
+ this.diffTable.removeEventListener('mousedown', this.handleDown);
+ }
+
+ handleDown = (e: Event) => {
+ const target = e.target;
+ if (!(target instanceof Element)) return;
+
+ const commentEl = parentWithClass(target, 'comment-thread', this.diffTable);
+ if (commentEl && isThreadEl(commentEl)) {
+ this.setClasses([
+ SelectionClass.COMMENT,
+ selectionClassForSide(getSide(commentEl)),
+ ]);
+ return;
+ }
+
+ const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
+ if (blameSelected) {
+ this.setClasses([SelectionClass.BLAME]);
+ return;
+ }
+
+ // This works for both, the content and the line number cells.
+ const lineEl = getLineElByChild(target);
+ if (lineEl) {
+ this.setClasses([selectionClassForSide(getSideByLineEl(lineEl))]);
+ return;
+ }
+ };
+
+ /**
+ * Set the provided list of classes on the element, to the exclusion of all
+ * other SelectionClass values.
+ */
+ setClasses(targetClasses: string[]) {
+ if (!this.diffTable) return;
+ // Remove any selection classes that do not belong.
+ for (const className of Object.values(SelectionClass)) {
+ if (!targetClasses.includes(className)) {
+ this.diffTable.classList.remove(className);
+ }
+ }
+ // Add new selection classes iff they are not already present.
+ for (const targetClass of targetClasses) {
+ if (!this.diffTable.classList.contains(targetClass)) {
+ this.diffTable.classList.add(targetClass);
+ }
+ }
+ }
+
+ handleCopy = (e: ClipboardEvent) => {
+ const target = e.composedPath()[0];
+ if (!(target instanceof Element)) return;
+ if (target instanceof HTMLTextAreaElement) return;
+ if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
+ if (!this.diffTable) return;
+ if (this.diffTable.classList.contains(SelectionClass.COMMENT)) return;
+
+ const lineEl = getLineElByChild(target);
+ if (!lineEl) return;
+ const side = getSideByLineEl(lineEl);
+ const text = this.getSelectedText(side);
+ if (text && e.clipboardData) {
+ e.clipboardData.setData('Text', text);
+ e.preventDefault();
+ }
+ };
+
+ getSelection() {
+ const diffHosts = querySelectorAll(document.body, 'gr-diff');
+ if (!diffHosts.length) return document.getSelection();
+
+ const curDiffHost = diffHosts.find(diffHost => {
+ if (!diffHost?.shadowRoot?.getSelection) return false;
+ const selection = diffHost.shadowRoot.getSelection();
+ // Pick the one with valid selection:
+ // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
+ return selection && selection.type !== 'None';
+ });
+
+ return curDiffHost?.shadowRoot?.getSelection
+ ? curDiffHost.shadowRoot.getSelection()
+ : document.getSelection();
+ }
+
+ /**
+ * Get the text of the current selection. If commentSelected is
+ * true, it returns only the text of comments within the selection.
+ * Otherwise it returns the text of the selected diff region.
+ *
+ * @param side The side that is selected.
+ * @param commentSelected Whether or not a comment is selected.
+ * @return The selected text.
+ */
+ getSelectedText(side: Side) {
+ const sel = this.getSelection();
+ if (!sel || sel.rangeCount !== 1) {
+ return ''; // No multi-select support yet.
+ }
+ const range = normalize(sel.getRangeAt(0));
+ const startLineEl = getLineElByChild(range.startContainer);
+ if (!startLineEl) return;
+ const endLineEl = getLineElByChild(range.endContainer);
+ // Happens when triple click in side-by-side mode with other side empty.
+ const endsAtOtherEmptySide =
+ !endLineEl &&
+ range.endOffset === 0 &&
+ range.endContainer.nodeName === 'TD' &&
+ range.endContainer instanceof HTMLTableCellElement &&
+ (range.endContainer.classList.contains('left') ||
+ range.endContainer.classList.contains('right'));
+ const startLineDataValue = startLineEl.getAttribute('data-value');
+ if (!startLineDataValue) return;
+ const startLineNum = Number(startLineDataValue);
+ let endLineNum;
+ if (endsAtOtherEmptySide) {
+ endLineNum = startLineNum + 1;
+ } else if (endLineEl) {
+ const endLineDataValue = endLineEl.getAttribute('data-value');
+ if (endLineDataValue) endLineNum = Number(endLineDataValue);
+ }
+
+ return this.getRangeFromDiff(
+ startLineNum,
+ range.startOffset,
+ endLineNum,
+ range.endOffset,
+ side
+ );
+ }
+
+ /**
+ * Query the diff object for the selected lines.
+ */
+ getRangeFromDiff(
+ startLineNum: number,
+ startOffset: number,
+ endLineNum: number | undefined,
+ endOffset: number,
+ side: Side
+ ) {
+ const skipChunk = this.diff?.content.find(chunk => chunk.skip);
+ if (skipChunk) {
+ startLineNum -= skipChunk.skip!;
+ if (endLineNum) endLineNum -= skipChunk.skip!;
+ }
+ const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
+ if (lines.length) {
+ lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
+ lines[0] = lines[0].substring(startOffset);
+ }
+ return lines.join('\n');
+ }
+
+ /**
+ * Query the diff object for the lines from a particular side.
+ *
+ * @param side The side that is currently selected.
+ * @return An array of strings indexed by line number.
+ */
+ getDiffLines(side: Side): string[] {
+ if (this.linesCache[side]) {
+ return this.linesCache[side]!;
+ }
+ if (!this.diff) return [];
+ let lines: string[] = [];
+ for (const chunk of this.diff.content) {
+ if (chunk.ab) {
+ lines = lines.concat(chunk.ab);
+ } else if (side === Side.LEFT && chunk.a) {
+ lines = lines.concat(chunk.a);
+ } else if (side === Side.RIGHT && chunk.b) {
+ lines = lines.concat(chunk.b);
+ }
+ }
+ this.linesCache[side] = lines;
+ return lines;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff-selection/gr-diff-selection_test.ts
new file mode 100644
index 0000000..f216e04
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff-selection/gr-diff-selection_test.ts
@@ -0,0 +1,219 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-selection';
+import '../gr-diff/gr-diff';
+import '../../../elements/shared/gr-comment-thread/gr-comment-thread';
+import {GrDiffSelection} from './gr-diff-selection';
+import {createDiff} from '../../../test/test-data-generators';
+import {DiffInfo, Side} from '../../../api/diff';
+import {fixture, html, assert} from '@open-wc/testing';
+import {mouseDown} from '../../../test/test-utils';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+
+function firstTextNode(el: HTMLElement) {
+ return [...el.childNodes].filter(node => node.nodeType === Node.TEXT_NODE)[0];
+}
+
+suite('gr-diff-selection', () => {
+ let element: GrDiffSelection;
+ let diffTable: HTMLElement;
+ let grDiff: GrDiff;
+
+ const emulateCopyOn = function (target: HTMLElement | null) {
+ const fakeEvent = {
+ target,
+ preventDefault: sinon.stub(),
+ composedPath() {
+ return [target];
+ },
+ clipboardData: {
+ setData: sinon.stub(),
+ },
+ };
+ element.handleCopy(fakeEvent as unknown as ClipboardEvent);
+ return fakeEvent;
+ };
+
+ setup(async () => {
+ grDiff = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+ element = grDiff.diffSelection;
+
+ const diff: DiffInfo = {
+ ...createDiff(),
+ content: [
+ {
+ a: ['ba ba'],
+ b: ['some other text'],
+ },
+ {
+ a: ['zin'],
+ b: ['more more more'],
+ },
+ {
+ a: ['ga ga'],
+ b: ['some other text'],
+ },
+ ],
+ };
+ grDiff.prefs = createDefaultDiffPrefs();
+ grDiff.diff = diff;
+ await waitForEventOnce(grDiff, 'render');
+ assert.isOk(element.diffTable);
+ diffTable = element.diffTable!;
+ });
+
+ test('applies selected-left on left side click', () => {
+ diffTable.classList.add('selected-right');
+ const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
+ if (!lineNumberEl) assert.fail('line number element missing');
+ mouseDown(lineNumberEl);
+ assert.isTrue(
+ diffTable.classList.contains('selected-left'),
+ 'adds selected-left'
+ );
+ assert.isFalse(
+ diffTable.classList.contains('selected-right'),
+ 'removes selected-right'
+ );
+ });
+
+ test('applies selected-right on right side click', () => {
+ diffTable.classList.add('selected-left');
+ const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
+ if (!lineNumberEl) assert.fail('line number element missing');
+ mouseDown(lineNumberEl);
+ assert.isTrue(
+ diffTable.classList.contains('selected-right'),
+ 'adds selected-right'
+ );
+ assert.isFalse(
+ diffTable.classList.contains('selected-left'),
+ 'removes selected-left'
+ );
+ });
+
+ test('applies selected-blame on blame click', () => {
+ diffTable.classList.add('selected-left');
+ const blameDiv = document.createElement('div');
+ blameDiv.classList.add('blame');
+ diffTable.appendChild(blameDiv);
+ mouseDown(blameDiv);
+ assert.isTrue(
+ diffTable.classList.contains('selected-blame'),
+ 'adds selected-right'
+ );
+ assert.isFalse(
+ diffTable.classList.contains('selected-left'),
+ 'removes selected-left'
+ );
+ });
+
+ test('ignores copy for non-content Element', () => {
+ const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+ emulateCopyOn(diffTable.querySelector('.not-diff-row'));
+ assert.isFalse(getSelectedTextStub.called);
+ });
+
+ test('asks for text for left side Elements', () => {
+ const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+ emulateCopyOn(diffTable.querySelector('div.contentText'));
+ assert.deepEqual([Side.LEFT], getSelectedTextStub.lastCall.args);
+ });
+
+ test('reacts to copy for content Elements', () => {
+ const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+ emulateCopyOn(diffTable.querySelector('div.contentText'));
+ assert.isTrue(getSelectedTextStub.called);
+ });
+
+ test('copy event is prevented for content Elements', () => {
+ const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+ getSelectedTextStub.returns('test');
+ const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+ assert.isTrue(event.preventDefault.called);
+ });
+
+ test('inserts text into clipboard on copy', () => {
+ sinon.stub(element, 'getSelectedText').returns('the text');
+ const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+ assert.deepEqual(
+ ['Text', 'the text'],
+ event.clipboardData.setData.lastCall.args
+ );
+ });
+
+ test('setClasses adds given SelectionClass values, removes others', () => {
+ diffTable.classList.add('selected-right');
+ element.setClasses(['selected-comment', 'selected-left']);
+ assert.isTrue(diffTable.classList.contains('selected-comment'));
+ assert.isTrue(diffTable.classList.contains('selected-left'));
+ assert.isFalse(diffTable.classList.contains('selected-right'));
+ assert.isFalse(diffTable.classList.contains('selected-blame'));
+
+ element.setClasses(['selected-blame']);
+ assert.isFalse(diffTable.classList.contains('selected-comment'));
+ assert.isFalse(diffTable.classList.contains('selected-left'));
+ assert.isFalse(diffTable.classList.contains('selected-right'));
+ assert.isTrue(diffTable.classList.contains('selected-blame'));
+ });
+
+ test('setClasses removes before it ads', () => {
+ diffTable.classList.add('selected-right');
+ const addStub = sinon.stub(diffTable.classList, 'add');
+ const removeStub = sinon
+ .stub(diffTable.classList, 'remove')
+ .callsFake(() => {
+ assert.isFalse(addStub.called);
+ });
+ element.setClasses(['selected-comment', 'selected-left']);
+ assert.isTrue(addStub.called);
+ assert.isTrue(removeStub.called);
+ });
+
+ test('copies content correctly', () => {
+ diffTable.classList.add('selected-left');
+ diffTable.classList.remove('selected-right');
+
+ const selection = document.getSelection();
+ if (selection === null) assert.fail('no selection');
+ selection.removeAllRanges();
+ const range = document.createRange();
+ const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+ range.setStart(firstTextNode(texts[0]), 3);
+ range.setEnd(firstTextNode(texts[4]), 2);
+ selection.addRange(range);
+
+ assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\nga');
+ });
+
+ test('defers to default behavior for textarea', () => {
+ diffTable.classList.add('selected-left');
+ diffTable.classList.remove('selected-right');
+ const selectedTextSpy = sinon.spy(element, 'getSelectedText');
+ emulateCopyOn(diffTable.querySelector('textarea'));
+
+ assert.isFalse(selectedTextSpy.called);
+ });
+
+ test('regression test for 4794', () => {
+ diffTable.classList.add('selected-right');
+ diffTable.classList.remove('selected-left');
+
+ const selection = document.getSelection();
+ if (!selection) assert.fail('no selection');
+ selection.removeAllRanges();
+ const range = document.createRange();
+ const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+ range.setStart(firstTextNode(texts[1]), 4);
+ range.setEnd(firstTextNode(texts[1]), 10);
+ selection.addRange(range);
+
+ assert.equal(element.getSelectedText(Side.RIGHT), ' other');
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-group.ts
new file mode 100644
index 0000000..c11cfb6
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-group.ts
@@ -0,0 +1,528 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {BLANK_LINE, GrDiffLine} from './gr-diff-line';
+import {
+ FILE,
+ GrDiffLineType,
+ LOST,
+ LineNumber,
+ LineRange,
+ Side,
+} from '../../../api/diff';
+import {assertIsDefined, assert} from '../../../utils/common-util';
+import {untilRendered} from '../../../utils/dom-util';
+import {isDefined} from '../../../types/types';
+import {LitElement} from 'lit';
+
+export enum GrDiffGroupType {
+ /** Unchanged context. */
+ BOTH = 'both',
+
+ /** A widget used to show more context. */
+ CONTEXT_CONTROL = 'contextControl',
+
+ /** Added, removed or modified chunk. */
+ DELTA = 'delta',
+}
+
+export interface GrDiffLinePair {
+ left: GrDiffLine;
+ right: GrDiffLine;
+}
+
+/**
+ * Hides lines in the given range behind a context control group.
+ *
+ * Groups that would be partially visible are split into their visible and
+ * hidden parts, respectively.
+ * The groups need to be "common groups", meaning they have to have either
+ * originated from an `ab` chunk, or from an `a`+`b` chunk with
+ * `common: true`.
+ *
+ * If the hidden range is 3 lines or less, nothing is hidden and no context
+ * control group is created.
+ *
+ * @param groups Common groups, ordered by their line ranges.
+ * @param hiddenStart The first element to be hidden, as a
+ * non-negative line number offset relative to the first group's start
+ * line, left and right respectively.
+ * @param hiddenEnd The first visible element after the hidden range,
+ * as a non-negative line number offset relative to the first group's
+ * start line, left and right respectively.
+ */
+export function hideInContextControl(
+ groups: readonly GrDiffGroup[],
+ hiddenStart: number,
+ hiddenEnd: number
+): GrDiffGroup[] {
+ if (groups.length === 0) return [];
+ // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
+ hiddenStart = Math.max(hiddenStart, 0);
+ hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+
+ let before: GrDiffGroup[] = [];
+ let hidden = groups;
+ let after: readonly GrDiffGroup[] = [];
+
+ const numHidden = hiddenEnd - hiddenStart;
+
+ // Showing a context control row for less than 4 lines does not make much,
+ // because then that row would consume as much space as the collapsed code.
+ if (numHidden > 3) {
+ if (hiddenStart) {
+ [before, hidden] = splitCommonGroups(hidden, hiddenStart);
+ }
+ if (hiddenEnd) {
+ let beforeLength = 0;
+ if (before.length > 0) {
+ const beforeStart = before[0].lineRange.left.start_line;
+ const beforeEnd = before[before.length - 1].lineRange.left.end_line;
+ beforeLength = beforeEnd - beforeStart + 1;
+ }
+ [hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
+ }
+ } else {
+ [hidden, after] = [[], hidden];
+ }
+
+ const result = [...before];
+ if (hidden.length) {
+ result.push(
+ new GrDiffGroup({
+ type: GrDiffGroupType.CONTEXT_CONTROL,
+ contextGroups: [...hidden],
+ })
+ );
+ }
+ result.push(...after);
+ return result;
+}
+
+/**
+ * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
+ * used in function splitCommonGroups
+ * Groups with some lines before and some lines after the split will be split
+ * into two groups, which will be put into the first and second list.
+ *
+ * @param group The group to be split in two
+ * @param leftSplit The line number relative to the split on the left side
+ * @param rightSplit The line number relative to the split on the right side
+ * @return two new groups, one before the split and another after it
+ */
+function splitGroupInTwo(
+ group: GrDiffGroup,
+ leftSplit: number,
+ rightSplit: number
+) {
+ let beforeSplit: GrDiffGroup | undefined;
+ let afterSplit: GrDiffGroup | undefined;
+ // split line is in the middle of a group, we need to break the group
+ // in lines before and after the split.
+ if (group.skip) {
+ // Currently we assume skip chunks "refuse" to be split. Expanding this
+ // group will in the future mean load more data - and therefore we want to
+ // fire an event when user wants to do it.
+ const closerToStartThanEnd =
+ leftSplit - group.lineRange.left.start_line <
+ group.lineRange.right.end_line - leftSplit;
+ if (closerToStartThanEnd) {
+ afterSplit = group;
+ } else {
+ beforeSplit = group;
+ }
+ } else {
+ const before = [];
+ const after = [];
+ for (const line of group.lines) {
+ if (
+ (line.beforeNumber &&
+ typeof line.beforeNumber === 'number' &&
+ line.beforeNumber < leftSplit) ||
+ (line.afterNumber &&
+ typeof line.afterNumber === 'number' &&
+ line.afterNumber < rightSplit)
+ ) {
+ before.push(line);
+ } else {
+ after.push(line);
+ }
+ }
+ if (before.length) {
+ beforeSplit =
+ before.length === group.lines.length
+ ? group
+ : group.cloneWithLines(before);
+ }
+ if (after.length) {
+ afterSplit =
+ after.length === group.lines.length
+ ? group
+ : group.cloneWithLines(after);
+ }
+ }
+ return {beforeSplit, afterSplit};
+}
+
+/**
+ * Splits a list of common groups into two lists of groups.
+ *
+ * Groups where all lines are before or all lines are after the split will be
+ * retained as is and put into the first or second list respectively. Groups
+ * with some lines before and some lines after the split will be split into
+ * two groups, which will be put into the first and second list.
+ *
+ * @param split A line number offset relative to the first group's
+ * start line at which the groups should be split.
+ * @return The outer array has 2 elements, the
+ * list of groups before and the list of groups after the split.
+ */
+function splitCommonGroups(
+ groups: readonly GrDiffGroup[],
+ split: number
+): GrDiffGroup[][] {
+ if (groups.length === 0) return [[], []];
+ const leftSplit = groups[0].lineRange.left.start_line + split;
+ const rightSplit = groups[0].lineRange.right.start_line + split;
+
+ const beforeGroups = [];
+ const afterGroups = [];
+ for (const group of groups) {
+ const isCompletelyBefore =
+ group.lineRange.left.end_line < leftSplit ||
+ group.lineRange.right.end_line < rightSplit;
+ const isCompletelyAfter =
+ leftSplit <= group.lineRange.left.start_line ||
+ rightSplit <= group.lineRange.right.start_line;
+ if (isCompletelyBefore) {
+ beforeGroups.push(group);
+ } else if (isCompletelyAfter) {
+ afterGroups.push(group);
+ } else {
+ const {beforeSplit, afterSplit} = splitGroupInTwo(
+ group,
+ leftSplit,
+ rightSplit
+ );
+ if (beforeSplit) {
+ beforeGroups.push(beforeSplit);
+ }
+ if (afterSplit) {
+ afterGroups.push(afterSplit);
+ }
+ }
+ }
+ return [beforeGroups, afterGroups];
+}
+
+export interface GrMoveDetails {
+ changed: boolean;
+ range?: {
+ start: number;
+ end: number;
+ };
+}
+
+/** A chunk of the diff that should be rendered together. */
+export class GrDiffGroup {
+ constructor(
+ options:
+ | {
+ type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
+ lines?: GrDiffLine[];
+ skip?: undefined;
+ moveDetails?: GrMoveDetails;
+ dueToRebase?: boolean;
+ ignoredWhitespaceOnly?: boolean;
+ keyLocation?: boolean;
+ }
+ | {
+ type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
+ lines?: undefined;
+ skip: number;
+ offsetLeft: number;
+ offsetRight: number;
+ moveDetails?: GrMoveDetails;
+ dueToRebase?: boolean;
+ ignoredWhitespaceOnly?: boolean;
+ keyLocation?: boolean;
+ }
+ | {
+ type: GrDiffGroupType.CONTEXT_CONTROL;
+ contextGroups: GrDiffGroup[];
+ }
+ ) {
+ this.type = options.type;
+ switch (options.type) {
+ case GrDiffGroupType.BOTH:
+ case GrDiffGroupType.DELTA: {
+ this.moveDetails = options.moveDetails;
+ this.dueToRebase = options.dueToRebase ?? false;
+ this.ignoredWhitespaceOnly = options.ignoredWhitespaceOnly ?? false;
+ this.keyLocation = options.keyLocation ?? false;
+ if (options.skip && options.lines) {
+ throw new Error('Cannot set skip and lines');
+ }
+ this.skip = options.skip;
+ if (options.skip !== undefined) {
+ this.lineRange = {
+ left: {
+ start_line: options.offsetLeft,
+ end_line: options.offsetLeft + options.skip - 1,
+ },
+ right: {
+ start_line: options.offsetRight,
+ end_line: options.offsetRight + options.skip - 1,
+ },
+ };
+ } else {
+ assertIsDefined(options.lines);
+ assert(options.lines.length > 0, 'diff group must have lines');
+ for (const line of options.lines) {
+ this.addLine(line);
+ }
+ }
+ break;
+ }
+ case GrDiffGroupType.CONTEXT_CONTROL: {
+ this.contextGroups = options.contextGroups;
+ if (this.contextGroups.length > 0) {
+ const firstGroup = this.contextGroups[0];
+ const lastGroup = this.contextGroups[this.contextGroups.length - 1];
+ this.lineRange = {
+ left: {
+ start_line: firstGroup.lineRange.left.start_line,
+ end_line: lastGroup.lineRange.left.end_line,
+ },
+ right: {
+ start_line: firstGroup.lineRange.right.start_line,
+ end_line: lastGroup.lineRange.right.end_line,
+ },
+ };
+ }
+ break;
+ }
+ default:
+ throw new Error(`Unknown group type: ${this.type}`);
+ }
+ }
+
+ readonly type: GrDiffGroupType;
+
+ readonly dueToRebase: boolean = false;
+
+ /**
+ * True means all changes in this line are whitespace changes that should
+ * not be highlighted as changed as per the user settings.
+ */
+ readonly ignoredWhitespaceOnly: boolean = false;
+
+ /**
+ * True means it should not be collapsed (because it was in the URL, or
+ * there is a comment on that line)
+ */
+ readonly keyLocation: boolean = false;
+
+ /**
+ * Once rendered the diff builder sets this to the diff section element.
+ */
+ element?: HTMLElement;
+
+ readonly lines: GrDiffLine[] = [];
+
+ readonly adds: GrDiffLine[] = [];
+
+ readonly removes: GrDiffLine[] = [];
+
+ readonly contextGroups: GrDiffGroup[] = [];
+
+ readonly skip?: number;
+
+ /** Both start and end line are inclusive. */
+ readonly lineRange: {[side in Side]: LineRange} = {
+ [Side.LEFT]: {start_line: 0, end_line: 0},
+ [Side.RIGHT]: {start_line: 0, end_line: 0},
+ };
+
+ readonly moveDetails?: GrMoveDetails;
+
+ /**
+ * Creates a new group with the same properties but different lines.
+ *
+ * The element property is not copied, because the original element is still a
+ * rendering of the old lines, so that would not make sense.
+ */
+ cloneWithLines(lines: GrDiffLine[]): GrDiffGroup {
+ if (
+ this.type !== GrDiffGroupType.BOTH &&
+ this.type !== GrDiffGroupType.DELTA
+ ) {
+ throw new Error('Cannot clone context group with lines');
+ }
+ const group = new GrDiffGroup({
+ type: this.type,
+ lines,
+ dueToRebase: this.dueToRebase,
+ ignoredWhitespaceOnly: this.ignoredWhitespaceOnly,
+ });
+ return group;
+ }
+
+ private addLine(line: GrDiffLine) {
+ this.lines.push(line);
+
+ const notDelta =
+ this.type === GrDiffGroupType.BOTH ||
+ this.type === GrDiffGroupType.CONTEXT_CONTROL;
+ if (
+ notDelta &&
+ (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.REMOVE)
+ ) {
+ throw Error('Cannot add delta line to a non-delta group.');
+ }
+
+ if (line.type === GrDiffLineType.ADD) {
+ this.adds.push(line);
+ } else if (line.type === GrDiffLineType.REMOVE) {
+ this.removes.push(line);
+ }
+ this._updateRangeWithNewLine(line);
+ }
+
+ getSideBySidePairs(): GrDiffLinePair[] {
+ if (
+ this.type === GrDiffGroupType.BOTH ||
+ this.type === GrDiffGroupType.CONTEXT_CONTROL
+ ) {
+ return this.lines.map(line => {
+ return {left: line, right: line};
+ });
+ }
+
+ const pairs: GrDiffLinePair[] = [];
+ let i = 0;
+ let j = 0;
+ while (i < this.removes.length || j < this.adds.length) {
+ pairs.push({
+ left: this.removes[i] || BLANK_LINE,
+ right: this.adds[j] || BLANK_LINE,
+ });
+ i++;
+ j++;
+ }
+ return pairs;
+ }
+
+ getUnifiedPairs(): GrDiffLinePair[] {
+ return this.lines
+ .map(line => {
+ if (line.type === GrDiffLineType.ADD) {
+ return {left: BLANK_LINE, right: line};
+ }
+ if (line.type === GrDiffLineType.REMOVE) {
+ if (this.ignoredWhitespaceOnly) return undefined;
+ return {left: line, right: BLANK_LINE};
+ }
+ return {left: line, right: line};
+ })
+ .filter(isDefined);
+ }
+
+ /** Returns true if it is, or contains, a skip group. */
+ hasSkipGroup() {
+ return (
+ this.skip !== undefined ||
+ this.contextGroups?.some(g => g.skip !== undefined)
+ );
+ }
+
+ containsLine(side: Side, line: LineNumber) {
+ if (typeof line !== 'number') {
+ // For FILE and LOST, beforeNumber and afterNumber are the same
+ return this.lines[0]?.beforeNumber === line;
+ }
+ const lineRange = this.lineRange[side];
+ return lineRange.start_line <= line && line <= lineRange.end_line;
+ }
+
+ startLine(side: Side): LineNumber {
+ // For both CONTEXT_CONTROL groups and SKIP groups the `lines` array will
+ // be empty. So we have to use `lineRange` instead of looking at the first
+ // line.
+ if (
+ this.type === GrDiffGroupType.CONTEXT_CONTROL ||
+ this.skip !== undefined
+ ) {
+ return side === Side.LEFT
+ ? this.lineRange.left.start_line
+ : this.lineRange.right.start_line;
+ }
+ // For "normal" groups we could also use the `lineRange`, but for FILE or
+ // LOST lines we want to return FILE or LOST. The `lineRange` contains
+ // numbers only.
+ return this.lines[0].lineNumber(side);
+ }
+
+ private _updateRangeWithNewLine(line: GrDiffLine) {
+ if (typeof line.beforeNumber !== 'number') return;
+ if (typeof line.afterNumber !== 'number') return;
+
+ if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
+ if (
+ this.lineRange.right.start_line === 0 ||
+ line.afterNumber < this.lineRange.right.start_line
+ ) {
+ this.lineRange.right.start_line = line.afterNumber;
+ }
+ if (line.afterNumber > this.lineRange.right.end_line) {
+ this.lineRange.right.end_line = line.afterNumber;
+ }
+ }
+
+ if (
+ line.type === GrDiffLineType.REMOVE ||
+ line.type === GrDiffLineType.BOTH
+ ) {
+ if (
+ this.lineRange.left.start_line === 0 ||
+ line.beforeNumber < this.lineRange.left.start_line
+ ) {
+ this.lineRange.left.start_line = line.beforeNumber;
+ }
+ if (line.beforeNumber > this.lineRange.left.end_line) {
+ this.lineRange.left.end_line = line.beforeNumber;
+ }
+ }
+ }
+
+ async waitUntilRendered() {
+ const lineNumber = this.lines[0]?.beforeNumber;
+ // The LOST or FILE lines may be hidden and thus never resolve an
+ // untilRendered() promise.
+ if (
+ this.skip !== undefined ||
+ lineNumber === LOST ||
+ lineNumber === FILE ||
+ this.type === GrDiffGroupType.CONTEXT_CONTROL
+ ) {
+ return Promise.resolve();
+ }
+ assertIsDefined(this.element);
+ await (this.element as LitElement).updateComplete;
+ await untilRendered(this.element.firstElementChild as HTMLElement);
+ }
+
+ /**
+ * Determines whether the group is either totally an addition or totally
+ * a removal.
+ */
+ isTotal(): boolean {
+ return (
+ this.type === GrDiffGroupType.DELTA &&
+ (!this.adds.length || !this.removes.length) &&
+ !(!this.adds.length && !this.removes.length)
+ );
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-group_test.ts
new file mode 100644
index 0000000..bbbb4ad
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-group_test.ts
@@ -0,0 +1,314 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {GrDiffLine, BLANK_LINE} from './gr-diff-line';
+import {
+ GrDiffGroup,
+ GrDiffGroupType,
+ hideInContextControl,
+} from './gr-diff-group';
+import {assert} from '@open-wc/testing';
+import {FILE, GrDiffLineType, LOST, Side} from '../../../api/diff';
+
+suite('gr-diff-group tests', () => {
+ test('delta line pairs', () => {
+ const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
+ const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
+ const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
+ let group = new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [l1, l2, l3],
+ });
+ assert.deepEqual(group.lines, [l1, l2, l3]);
+ assert.deepEqual(group.adds, [l1, l2]);
+ assert.deepEqual(group.removes, [l3]);
+ assert.deepEqual(group.lineRange, {
+ left: {start_line: 64, end_line: 64},
+ right: {start_line: 128, end_line: 129},
+ });
+
+ let pairs = group.getSideBySidePairs();
+ assert.deepEqual(pairs, [
+ {left: l3, right: l1},
+ {left: BLANK_LINE, right: l2},
+ ]);
+
+ group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [l1, l2, l3]});
+ assert.deepEqual(group.lines, [l1, l2, l3]);
+ assert.deepEqual(group.adds, [l1, l2]);
+ assert.deepEqual(group.removes, [l3]);
+
+ pairs = group.getSideBySidePairs();
+ assert.deepEqual(pairs, [
+ {left: l3, right: l1},
+ {left: BLANK_LINE, right: l2},
+ ]);
+ });
+
+ test('group must have lines', () => {
+ try {
+ new GrDiffGroup({type: GrDiffGroupType.BOTH});
+ } catch (e) {
+ // expected
+ return;
+ }
+ assert.fail('a standard diff group cannot be empty');
+ });
+
+ test('group/header line pairs', () => {
+ const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
+ const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
+ const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
+
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [l1, l2, l3],
+ });
+
+ assert.deepEqual(group.lines, [l1, l2, l3]);
+ assert.deepEqual(group.adds, []);
+ assert.deepEqual(group.removes, []);
+
+ assert.deepEqual(group.lineRange, {
+ left: {start_line: 64, end_line: 66},
+ right: {start_line: 128, end_line: 130},
+ });
+
+ const pairs = group.getSideBySidePairs();
+ assert.deepEqual(pairs, [
+ {left: l1, right: l1},
+ {left: l2, right: l2},
+ {left: l3, right: l3},
+ ]);
+ });
+
+ test('adding delta lines to non-delta group', () => {
+ const l1 = new GrDiffLine(GrDiffLineType.ADD);
+ const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
+ const l3 = new GrDiffLine(GrDiffLineType.BOTH);
+
+ assert.throws(
+ () => new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]})
+ );
+ });
+
+ suite('hideInContextControl', () => {
+ let groups: GrDiffGroup[];
+ setup(() => {
+ groups = [
+ new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [
+ new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+ new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+ new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+ ],
+ }),
+ new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [
+ new GrDiffLine(GrDiffLineType.REMOVE, 8),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+ new GrDiffLine(GrDiffLineType.REMOVE, 9),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+ new GrDiffLine(GrDiffLineType.REMOVE, 10),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+ new GrDiffLine(GrDiffLineType.REMOVE, 11),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+ ],
+ }),
+ new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [
+ new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+ new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+ new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+ ],
+ }),
+ ];
+ });
+
+ test('hides hidden groups in context control', () => {
+ const collapsedGroups = hideInContextControl(groups, 3, 7);
+ assert.equal(collapsedGroups.length, 3);
+
+ assert.equal(collapsedGroups[0], groups[0]);
+
+ assert.equal(collapsedGroups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(collapsedGroups[1].contextGroups.length, 1);
+ assert.equal(collapsedGroups[1].contextGroups[0], groups[1]);
+
+ assert.equal(collapsedGroups[2], groups[2]);
+ });
+
+ test('splits partially hidden groups', () => {
+ const collapsedGroups = hideInContextControl(groups, 4, 8);
+ assert.equal(collapsedGroups.length, 4);
+ assert.equal(collapsedGroups[0], groups[0]);
+
+ assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
+ assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+ assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+ assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(collapsedGroups[2].contextGroups.length, 2);
+
+ assert.equal(
+ collapsedGroups[2].contextGroups[0].type,
+ GrDiffGroupType.DELTA
+ );
+ assert.deepEqual(
+ collapsedGroups[2].contextGroups[0].adds,
+ groups[1].adds.slice(1)
+ );
+ assert.deepEqual(
+ collapsedGroups[2].contextGroups[0].removes,
+ groups[1].removes.slice(1)
+ );
+
+ assert.equal(
+ collapsedGroups[2].contextGroups[1].type,
+ GrDiffGroupType.BOTH
+ );
+ assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+ groups[2].lines[0],
+ ]);
+
+ assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
+ assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+ });
+
+ suite('with skip chunks', () => {
+ setup(() => {
+ const skipGroup = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ skip: 60,
+ offsetLeft: 8,
+ offsetRight: 10,
+ });
+ groups = [
+ new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [
+ new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+ new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+ new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+ ],
+ }),
+ skipGroup,
+ new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [
+ new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+ new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+ new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+ ],
+ }),
+ ];
+ });
+
+ test('refuses to split skip group when closer to before', () => {
+ const collapsedGroups = hideInContextControl(groups, 4, 10);
+ assert.deepEqual(groups, collapsedGroups);
+ });
+ });
+
+ test('groups unchanged if the hidden range is empty', () => {
+ assert.deepEqual(hideInContextControl(groups, 0, 0), groups);
+ });
+
+ test('groups unchanged if there is only 1 line to hide', () => {
+ assert.deepEqual(hideInContextControl(groups, 3, 4), groups);
+ });
+ });
+
+ suite('isTotal', () => {
+ test('is total for add', () => {
+ const lines = [];
+ for (let idx = 0; idx < 10; idx++) {
+ lines.push(new GrDiffLine(GrDiffLineType.ADD));
+ }
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.isTrue(group.isTotal());
+ });
+
+ test('is total for remove', () => {
+ const lines = [];
+ for (let idx = 0; idx < 10; idx++) {
+ lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
+ }
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.isTrue(group.isTotal());
+ });
+
+ test('not total for non-delta', () => {
+ const lines = [];
+ for (let idx = 0; idx < 10; idx++) {
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH));
+ }
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.isFalse(group.isTotal());
+ });
+ });
+
+ suite('startLine', () => {
+ test('DELTA', () => {
+ const lines: GrDiffLine[] = [];
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.equal(group.startLine(Side.LEFT), 3);
+ assert.equal(group.startLine(Side.RIGHT), 4);
+ });
+
+ test('CONTEXT CONTROL', () => {
+ const lines: GrDiffLine[] = [];
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+ const delta = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.CONTEXT_CONTROL,
+ contextGroups: [delta],
+ });
+ assert.equal(group.startLine(Side.LEFT), 3);
+ assert.equal(group.startLine(Side.RIGHT), 4);
+ });
+
+ test('SKIP', () => {
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ skip: 10,
+ offsetLeft: 3,
+ offsetRight: 6,
+ });
+ assert.equal(group.startLine(Side.LEFT), 3);
+ assert.equal(group.startLine(Side.RIGHT), 6);
+
+ const group2 = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ skip: 0,
+ offsetLeft: 3,
+ offsetRight: 6,
+ });
+ assert.equal(group2.startLine(Side.LEFT), 3);
+ assert.equal(group2.startLine(Side.RIGHT), 6);
+ });
+
+ test('FILE', () => {
+ const lines: GrDiffLine[] = [];
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, FILE, FILE));
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.equal(group.startLine(Side.LEFT), FILE);
+ assert.equal(group.startLine(Side.RIGHT), FILE);
+ });
+
+ test('LOST', () => {
+ const lines: GrDiffLine[] = [];
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, LOST, LOST));
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.equal(group.startLine(Side.LEFT), LOST);
+ assert.equal(group.startLine(Side.RIGHT), LOST);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-line.ts
new file mode 100644
index 0000000..1a89207
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-line.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+ FILE,
+ GrDiffLine as GrDiffLineApi,
+ GrDiffLineType,
+ LineNumber,
+ Side,
+} from '../../../api/diff';
+
+export class GrDiffLine implements GrDiffLineApi {
+ constructor(
+ readonly type: GrDiffLineType,
+ public beforeNumber: LineNumber = 0,
+ public afterNumber: LineNumber = 0
+ ) {}
+
+ hasIntralineInfo = false;
+
+ highlights: Highlights[] = [];
+
+ text = '';
+
+ lineNumber(side: Side) {
+ return side === Side.LEFT ? this.beforeNumber : this.afterNumber;
+ }
+
+ // TODO(TS): remove this properties
+ static readonly Type = GrDiffLineType;
+
+ static readonly File = FILE;
+}
+
+/**
+ * A line highlight object consists of three fields:
+ * - contentIndex: The index of the chunk `content` field (the line
+ * being referred to).
+ * - startIndex: Index of the character where the highlight should begin.
+ * - endIndex: (optional) Index of the character where the highlight should
+ * end. If omitted, the highlight is meant to be a continuation onto the
+ * next line.
+ */
+export interface Highlights {
+ contentIndex: number;
+ startIndex: number;
+ endIndex?: number;
+}
+
+export const BLANK_LINE = new GrDiffLine(GrDiffLineType.BLANK);
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-styles.ts
new file mode 100644
index 0000000..e7f4b51
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff-styles.ts
@@ -0,0 +1,671 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const grDiffStyles = css`
+ /* This is used to hide all left side of the diff (e.g. diffs besides
+ comments in the change log). Since we want to remove the first 4
+ cells consistently in all rows except context buttons (.dividerRow). */
+ :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+ :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
+ display: none;
+ }
+ :host(.disable-context-control-buttons) {
+ --context-control-display: none;
+ }
+ :host(.disable-context-control-buttons) .section {
+ border-right: none;
+ }
+ :host(.hide-line-length-indicator) .full-width td.content .contentText {
+ background-image: none;
+ }
+
+ :host {
+ font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+ font-size: var(--font-size, var(--font-size-code, 12px));
+ /* usually 16px = 12px + 4px */
+ line-height: calc(
+ var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
+ );
+ }
+
+ .thread-group {
+ display: block;
+ max-width: var(--content-width, 80ch);
+ white-space: normal;
+ background-color: var(--diff-blank-background-color);
+ }
+ .diffContainer {
+ max-width: var(--diff-max-width, none);
+ font-family: var(--monospace-font-family);
+ }
+ table {
+ border-collapse: collapse;
+ table-layout: fixed;
+ }
+ td.lineNum {
+ /* Enforces background whenever lines wrap */
+ background-color: var(--diff-blank-background-color);
+ }
+
+ /* Provides the option to add side borders (left and right) to the line
+ number column. */
+ td.lineNum,
+ td.blankLineNum,
+ td.moveControlsLineNumCol,
+ td.contextLineNum {
+ box-shadow: var(--line-number-box-shadow, unset);
+ }
+
+ /* Context controls break up the table visually, so we set the right
+ border on individual sections to leave a gap for the divider.
+
+ Also taken into account for max-width calculations in SHRINK_ONLY mode
+ (check GrDiff.updatePreferenceStyles). */
+ .section {
+ border-right: 1px solid var(--border-color);
+ }
+ .section.contextControl {
+ /* Divider inside this section must not have border; we set borders on
+ the padding rows below. */
+ border-right-width: 0;
+ }
+ /* Padding rows behind context controls. The diff is styled to be cut
+ into two halves by the negative space of the divider on which the
+ context control buttons are anchored. */
+ .contextBackground {
+ border-right: 1px solid var(--border-color);
+ }
+ .contextBackground.above {
+ border-bottom: 1px solid var(--border-color);
+ }
+ .contextBackground.below {
+ border-top: 1px solid var(--border-color);
+ }
+
+ .lineNumButton {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: var(--diff-blank-background-color);
+ box-shadow: var(--line-number-box-shadow, unset);
+ }
+ td.lineNum {
+ vertical-align: top;
+ }
+
+ /* The only way to focus this (clicking) will apply our own focus
+ styling, so this default styling is not needed and distracting. */
+ .lineNumButton:focus {
+ outline: none;
+ }
+ gr-image-viewer {
+ width: 100%;
+ height: 100%;
+ max-width: var(--image-viewer-max-width, 95vw);
+ max-height: var(--image-viewer-max-height, 90vh);
+ /* Defined by paper-styles default-theme and used in various
+ components. background-color-secondary is a compromise between
+ fairly light in light theme (where we ideally would want
+ background-color-primary) yet slightly offset against the app
+ background in dark mode, where drop shadows e.g. around paper-card
+ are almost invisible. */
+ --primary-background-color: var(--background-color-secondary);
+ }
+ .image-diff .gr-diff {
+ text-align: center;
+ }
+ .image-diff img {
+ box-shadow: var(--elevation-level-1);
+ max-width: 50em;
+ }
+ .image-diff .right.lineNumButton {
+ border-left: 1px solid var(--border-color);
+ }
+ .image-diff label {
+ font-family: var(--font-family);
+ font-style: italic;
+ }
+ tbody.binary-diff td {
+ font-family: var(--font-family);
+ font-style: italic;
+ text-align: center;
+ padding: var(--spacing-s) 0;
+ }
+ .diff-row {
+ outline: none;
+ user-select: none;
+ }
+ .diff-row.target-row.target-side-left .lineNumButton.left,
+ .diff-row.target-row.target-side-right .lineNumButton.right,
+ .diff-row.target-row.unified .lineNumButton {
+ color: var(--primary-text-color);
+ }
+
+ /* Preparing selected line cells with position relative so it allows a
+ positioned overlay with 'position: absolute'. */
+ .target-row td {
+ position: relative;
+ }
+
+ /* Defines an overlay to the selected line for drawing an outline without
+ blocking user interaction (e.g. text selection). */
+ .target-row td::before {
+ border-width: 0;
+ border-style: solid;
+ border-color: var(--focused-line-outline-color);
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ user-select: none;
+ content: ' ';
+ }
+
+ /* The outline for the selected content cell should be the same in all
+ cases. */
+ .target-row.target-side-left td.left.content::before,
+ .target-row.target-side-right td.right.content::before,
+ .unified.target-row td.content::before {
+ border-width: 1px 1px 1px 0;
+ }
+
+ /* The outline for the sign cell should be always be contiguous
+ top/bottom. */
+ .target-row.target-side-left td.left.sign::before,
+ .target-row.target-side-right td.right.sign::before {
+ border-width: 1px 0;
+ }
+
+ /* For side-by-side we need to select the correct line number to
+ "visually close" the outline. */
+ .side-by-side.target-row.target-side-left td.left.lineNum::before,
+ .side-by-side.target-row.target-side-right td.right.lineNum::before {
+ border-width: 1px 0 1px 1px;
+ }
+
+ /* For unified diff we always start the overlay from the left cell. */
+ .unified.target-row td.left:not(.content)::before {
+ border-width: 1px 0 1px 1px;
+ }
+
+ /* For unified diff we should continue the top/bottom border in right
+ line number column. */
+ .unified.target-row td.right:not(.content)::before {
+ border-width: 1px 0;
+ }
+
+ .content {
+ background-color: var(--diff-blank-background-color);
+ }
+
+ /* Describes two states of semantic tokens: whenever a token has a
+ definition that can be navigated to (navigable) and whenever
+ the token is actually clickable to perform this navigation. */
+ .semantic-token.navigable {
+ text-decoration-style: dotted;
+ text-decoration-line: underline;
+ }
+ .semantic-token.navigable.clickable {
+ text-decoration-style: solid;
+ cursor: pointer;
+ }
+
+ /* The file line, which has no contentText, add some margin before the
+ first comment. We cannot add padding the container because we only
+ want it if there is at least one comment thread, and the slotting
+ makes :empty not work as expected. */
+ .content.file slot:first-child::slotted(.comment-thread) {
+ display: block;
+ margin-top: var(--spacing-xs);
+ }
+ .contentText {
+ background-color: var(--view-background-color);
+ }
+ .blank {
+ background-color: var(--diff-blank-background-color);
+ }
+ .image-diff .content {
+ background-color: var(--diff-blank-background-color);
+ }
+ .responsive {
+ width: 100%;
+ }
+ .responsive .contentText {
+ white-space: break-spaces;
+ word-break: break-all;
+ }
+ .lineNumButton,
+ .content {
+ vertical-align: top;
+ white-space: pre;
+ }
+ .contextLineNum,
+ .lineNumButton {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ color: var(--deemphasized-text-color);
+ padding: 0 var(--spacing-m);
+ text-align: right;
+ }
+ .canComment .lineNumButton {
+ cursor: pointer;
+ }
+ .sign {
+ min-width: 1ch;
+ width: 1ch;
+ background-color: var(--view-background-color);
+ }
+ .sign.blank {
+ background-color: var(--diff-blank-background-color);
+ }
+ .content {
+ /* Set min width since setting width on table cells still allows them
+ to shrink. Do not set max width because CJK
+ (Chinese-Japanese-Korean) glyphs have variable width. */
+ min-width: var(--content-width, 80ch);
+ width: var(--content-width, 80ch);
+ }
+ /* If there are no intraline info, consider everything changed */
+ .content.add .contentText .intraline,
+ .content.add.no-intraline-info .contentText,
+ .sign.add.no-intraline-info,
+ .delta.total .content.add .contentText {
+ background-color: var(--dark-add-highlight-color);
+ }
+ .content.add .contentText,
+ .sign.add {
+ background-color: var(--light-add-highlight-color);
+ }
+ /* If there are no intraline info, consider everything changed */
+ .content.remove .contentText .intraline,
+ .content.remove.no-intraline-info .contentText,
+ .delta.total .content.remove .contentText,
+ .sign.remove.no-intraline-info {
+ background-color: var(--dark-remove-highlight-color);
+ }
+ .content.remove .contentText,
+ .sign.remove {
+ background-color: var(--light-remove-highlight-color);
+ }
+
+ .ignoredWhitespaceOnly .sign.no-intraline-info {
+ background-color: var(--view-background-color);
+ }
+
+ /* dueToRebase */
+ .dueToRebase .content.add .contentText .intraline,
+ .delta.total.dueToRebase .content.add .contentText {
+ background-color: var(--dark-rebased-add-highlight-color);
+ }
+ .dueToRebase .content.add .contentText {
+ background-color: var(--light-rebased-add-highlight-color);
+ }
+ .dueToRebase .content.remove .contentText .intraline,
+ .delta.total.dueToRebase .content.remove .contentText {
+ background-color: var(--dark-rebased-remove-highlight-color);
+ }
+ .dueToRebase .content.remove .contentText {
+ background-color: var(--light-rebased-remove-highlight-color);
+ }
+
+ /* dueToMove */
+ .dueToMove .sign.add,
+ .dueToMove .content.add .contentText,
+ .dueToMove .moveControls.movedIn .sign.right,
+ .dueToMove .moveControls.movedIn .moveHeader,
+ .delta.total.dueToMove .content.add .contentText {
+ background-color: var(--diff-moved-in-background);
+ }
+
+ .dueToMove.changed .sign.add,
+ .dueToMove.changed .content.add .contentText,
+ .dueToMove.changed .moveControls.movedIn .sign.right,
+ .dueToMove.changed .moveControls.movedIn .moveHeader,
+ .delta.total.dueToMove.changed .content.add .contentText {
+ background-color: var(--diff-moved-in-changed-background);
+ }
+
+ .dueToMove .sign.remove,
+ .dueToMove .content.remove .contentText,
+ .dueToMove .moveControls.movedOut .moveHeader,
+ .dueToMove .moveControls.movedOut .sign.left,
+ .delta.total.dueToMove .content.remove .contentText {
+ background-color: var(--diff-moved-out-background);
+ }
+
+ .delta.dueToMove .movedIn .moveHeader {
+ --gr-range-header-color: var(--diff-moved-in-label-color);
+ }
+ .delta.dueToMove.changed .movedIn .moveHeader {
+ --gr-range-header-color: var(--diff-moved-in-changed-label-color);
+ }
+ .delta.dueToMove .movedOut .moveHeader {
+ --gr-range-header-color: var(--diff-moved-out-label-color);
+ }
+
+ .moveHeader a {
+ color: inherit;
+ }
+
+ /* ignoredWhitespaceOnly */
+ .ignoredWhitespaceOnly .content.add .contentText .intraline,
+ .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+ .ignoredWhitespaceOnly .content.add .contentText,
+ .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+ .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+ .ignoredWhitespaceOnly .content.remove .contentText {
+ background-color: var(--view-background-color);
+ }
+
+ .content .contentText gr-diff-text:empty:after,
+ .content .contentText gr-legacy-text:empty:after,
+ .content .contentText:empty:after {
+ /* Newline, to ensure empty lines are one line-height tall. */
+ content: '\\A';
+ }
+
+ /* Context controls */
+ .contextControl {
+ display: var(--context-control-display, table-row-group);
+ background-color: transparent;
+ border: none;
+ --divider-height: var(--spacing-s);
+ --divider-border: 1px;
+ }
+ /* TODO: Is this still used? */
+ .contextControl gr-button gr-icon {
+ /* should match line-height of gr-button */
+ font-size: var(--line-height-mono, 18px);
+ }
+ .contextControl td:not(.lineNumButton) {
+ text-align: center;
+ }
+
+ /* Padding rows behind context controls. Styled as a continuation of the
+ line gutters and code area. */
+ .contextBackground > .contextLineNum {
+ background-color: var(--diff-blank-background-color);
+ }
+ .contextBackground > td:not(.contextLineNum) {
+ background-color: var(--view-background-color);
+ }
+ .contextBackground {
+ /* One line of background behind the context expanders which they can
+ render on top of, plus some padding. */
+ height: calc(var(--line-height-normal) + var(--spacing-s));
+ }
+
+ .dividerCell {
+ vertical-align: top;
+ }
+ .dividerRow.show-both .dividerCell {
+ height: var(--divider-height);
+ }
+ .dividerRow.show-above .dividerCell,
+ .dividerRow.show-above .dividerCell {
+ height: 0;
+ }
+
+ .br:after {
+ /* Line feed */
+ content: '\\A';
+ }
+ .tab {
+ display: inline-block;
+ }
+ .tab-indicator:before {
+ color: var(--diff-tab-indicator-color);
+ /* >> character */
+ content: '\\00BB';
+ position: absolute;
+ }
+ .special-char-indicator {
+ /* spacing so elements don't collide */
+ padding-right: var(--spacing-m);
+ }
+ .special-char-indicator:before {
+ color: var(--diff-tab-indicator-color);
+ content: '•';
+ position: absolute;
+ }
+ .special-char-warning {
+ /* spacing so elements don't collide */
+ padding-right: var(--spacing-m);
+ }
+ .special-char-warning:before {
+ color: var(--warning-foreground);
+ content: '!';
+ position: absolute;
+ }
+ /* Is defined after other background-colors, such that this
+ rule wins in case of same specificity. */
+ .trailing-whitespace,
+ .content .contentText .trailing-whitespace,
+ .trailing-whitespace .intraline,
+ .content .contentText .trailing-whitespace .intraline {
+ border-radius: var(--border-radius, 4px);
+ background-color: var(--diff-trailing-whitespace-indicator);
+ }
+ #diffHeader {
+ background-color: var(--table-header-background-color);
+ border-bottom: 1px solid var(--border-color);
+ color: var(--link-color);
+ padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+ }
+ #diffTable {
+ /* for gr-selection-action-box positioning */
+ position: relative;
+ }
+ #diffTable:focus {
+ outline: none;
+ }
+ #loadingError,
+ #sizeWarning {
+ display: block;
+ margin: var(--spacing-l) auto;
+ max-width: 60em;
+ text-align: center;
+ }
+ #loadingError {
+ color: var(--error-text-color);
+ }
+ #sizeWarning gr-button {
+ margin: var(--spacing-l);
+ }
+ .target-row td.blame {
+ background: var(--diff-selection-background-color);
+ }
+ td.lost div {
+ background-color: var(--info-background);
+ }
+ td.lost div.lost-message {
+ font-family: var(--font-family, 'Roboto');
+ font-size: var(--font-size-normal, 14px);
+ line-height: var(--line-height-normal);
+ padding: var(--spacing-s) 0;
+ }
+ td.lost div.lost-message gr-icon {
+ padding: 0 var(--spacing-s) 0 var(--spacing-m);
+ color: var(--blue-700);
+ }
+
+ col.sign,
+ td.sign {
+ display: none;
+ }
+
+ /* Sign column should only be shown in high-contrast mode. */
+ :host(.with-sign-col) col.sign {
+ display: table-column;
+ }
+ :host(.with-sign-col) td.sign {
+ display: table-cell;
+ }
+ col.blame {
+ display: none;
+ }
+ td.blame {
+ display: none;
+ padding: 0 var(--spacing-m);
+ white-space: pre;
+ }
+ :host(.showBlame) col.blame {
+ display: table-column;
+ }
+ :host(.showBlame) td.blame {
+ display: table-cell;
+ }
+ td.blame > span {
+ opacity: 0.6;
+ }
+ td.blame > span.startOfRange {
+ opacity: 1;
+ }
+ td.blame .blameDate {
+ font-family: var(--monospace-font-family);
+ color: var(--link-color);
+ text-decoration: none;
+ }
+ .responsive td.blame {
+ overflow: hidden;
+ width: 200px;
+ }
+ /** Support the line length indicator **/
+ .responsive td.content .contentText {
+ /* Same strategy as in
+ https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+ */
+ background-image: linear-gradient(
+ var(--line-length-indicator-color),
+ var(--line-length-indicator-color)
+ );
+ background-size: 1px 100%;
+ background-position: var(--line-limit-marker) 0;
+ background-repeat: no-repeat;
+ }
+ .newlineWarning {
+ color: var(--deemphasized-text-color);
+ text-align: center;
+ }
+ .newlineWarning.hidden {
+ display: none;
+ }
+ .lineNum.COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background-color: var(--coverage-covered, #e0f2f1);
+ }
+ .lineNum.NOT_COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background-color: var(--coverage-not-covered, #ffd1a4);
+ }
+ .lineNum.PARTIALLY_COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background: linear-gradient(
+ to right bottom,
+ var(--coverage-not-covered, #ffd1a4) 0%,
+ var(--coverage-not-covered, #ffd1a4) 50%,
+ var(--coverage-covered, #e0f2f1) 50%,
+ var(--coverage-covered, #e0f2f1) 100%
+ );
+ }
+
+ // TODO: Investigate whether this CSS is still necessary.
+ /* BEGIN: Select and copy for Polymer 2 */
+ /* Below was copied and modified from the original css in gr-diff-selection.html. */
+ .content,
+ .contextControl,
+ .blame {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ }
+
+ .selected-left:not(.selected-comment)
+ .side-by-side
+ .left
+ + .content
+ .contentText,
+ .selected-right:not(.selected-comment)
+ .side-by-side
+ .right
+ + .content
+ .contentText,
+ .selected-left:not(.selected-comment)
+ .unified
+ .left.lineNum
+ ~ .content:not(.both)
+ .contentText,
+ .selected-right:not(.selected-comment)
+ .unified
+ .right.lineNum
+ ~ .content
+ .contentText,
+ .selected-left.selected-comment .side-by-side .left + .content .message,
+ .selected-right.selected-comment
+ .side-by-side
+ .right
+ + .content
+ .message
+ :not(.collapsedContent),
+ .selected-comment .unified .message :not(.collapsedContent),
+ .selected-blame .blame {
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+ }
+
+ /* Make comments and check results selectable when selected */
+ .selected-left.selected-comment ::slotted(.comment-thread[diff-side='left']),
+ .selected-right.selected-comment
+ ::slotted(.comment-thread[diff-side='right']) {
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+ }
+ /* END: Select and copy for Polymer 2 */
+
+ .whitespace-change-only-message {
+ background-color: var(--diff-context-control-background-color);
+ border: 1px solid var(--diff-context-control-border-color);
+ text-align: center;
+ }
+
+ .token-highlight {
+ background-color: var(--token-highlighting-color, #fffd54);
+ }
+
+ gr-selection-action-box {
+ /* Needs z-index to appear above wrapped content, since it's inserted
+ into DOM before it. */
+ z-index: 10;
+ }
+
+ gr-diff-image-new,
+ gr-diff-image-old,
+ gr-diff-section,
+ gr-context-controls-section,
+ gr-diff-row {
+ display: contents;
+ }
+`;
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff.ts
new file mode 100644
index 0000000..1beaf6e
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff.ts
@@ -0,0 +1,1128 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import '../../../elements/shared/gr-button/gr-button';
+import '../../../elements/shared/gr-icon/gr-icon';
+import '../gr-diff-builder/gr-diff-builder-element';
+import '../gr-diff-highlight/gr-diff-highlight';
+import '../gr-diff-selection/gr-diff-selection';
+import '../../diff/gr-syntax-themes/gr-syntax-theme';
+import '../../diff/gr-ranged-comment-themes/gr-ranged-comment-theme';
+import '../../diff/gr-ranged-comment-hint/gr-ranged-comment-hint';
+import {
+ getLine,
+ getLineElByChild,
+ getLineNumber,
+ getRange,
+ getSide,
+ GrDiffThreadElement,
+ isLongCommentRange,
+ isThreadEl,
+ rangesEqual,
+ getResponsiveMode,
+ isResponsive,
+ isNewDiff,
+} from '../../diff/gr-diff/gr-diff-utils';
+import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {
+ CreateRangeCommentEventDetail,
+ GrDiffHighlight,
+} from '../gr-diff-highlight/gr-diff-highlight';
+import {
+ GrDiffBuilderElement,
+ getLineNumberCellWidth,
+} from '../gr-diff-builder/gr-diff-builder-element';
+import {CoverageRange, DiffLayer} from '../../../types/types';
+import {CommentRangeLayer} from '../../diff/gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {
+ createDefaultDiffPrefs,
+ DiffViewMode,
+ Side,
+} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {fire, fireAlert} from '../../../utils/event-util';
+import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
+import {getContentEditableRange} from '../../../utils/safari-selection-util';
+import {AbortStop} from '../../../api/core';
+import {
+ RenderPreferences,
+ GrDiff as GrDiffApi,
+ DisplayLine,
+ LineNumber,
+ LOST,
+} from '../../../api/diff';
+import {isSafari, toggleClass} from '../../../utils/dom-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {
+ debounceP,
+ DelayedPromise,
+ DELAYED_CANCELLATION,
+} from '../../../utils/async-util';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
+import {property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {html, LitElement, nothing, PropertyValues} from 'lit';
+import {when} from 'lit/directives/when.js';
+import {grSyntaxTheme} from '../../diff/gr-syntax-themes/gr-syntax-theme';
+import {grRangedCommentTheme} from '../../diff/gr-ranged-comment-themes/gr-ranged-comment-theme';
+import {classMap} from 'lit/directives/class-map.js';
+import {iconStyles} from '../../../styles/gr-icon-styles';
+import {expandFileMode} from '../../../utils/file-util';
+import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {provide} from '../../../models/dependency';
+import {grDiffStyles} from './gr-diff-styles';
+import {getDiffLength} from '../../../utils/diff-util';
+
+const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+const LARGE_DIFF_THRESHOLD_LINES = 10000;
+const FULL_CONTEXT = -1;
+
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+/**
+ * 72 is the unofficial length standard for git commit messages.
+ * Derived from the fact that git log/show appends 4 ws in the beginning of
+ * each line when displaying commit messages. To center the commit message
+ * in an 80 char terminal a 4 ws border is added to the rightmost side:
+ * 4 + 72 + 4
+ */
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+export class GrDiff extends LitElement implements GrDiffApi {
+ /**
+ * Fired when the user selects a line.
+ *
+ * @event line-selected
+ */
+
+ /**
+ * Fired if being logged in is required.
+ *
+ * @event show-auth-required
+ */
+
+ /**
+ * Fired when a comment is created
+ *
+ * @event create-comment
+ */
+
+ /**
+ * Fired when rendering, including syntax highlighting, is done. Also fired
+ * when no rendering can be done because required preferences are not set.
+ *
+ * @event render
+ */
+
+ /**
+ * Fired for interaction reporting when a diff context is expanded.
+ * Contains an event.detail with numLines about the number of lines that
+ * were expanded.
+ *
+ * @event diff-context-expanded
+ */
+
+ @query('#diffTable')
+ diffTable?: HTMLTableElement;
+
+ @property({type: Boolean})
+ noAutoRender = false;
+
+ @property({type: String})
+ path?: string;
+
+ @property({type: Object})
+ prefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ renderPrefs: RenderPreferences = {};
+
+ @property({type: Boolean})
+ isImageDiff?: boolean;
+
+ @property({type: Boolean, reflect: true})
+ override hidden = false;
+
+ @property({type: Boolean})
+ noRenderOnPrefsChange?: boolean;
+
+ // Private but used in tests.
+ @state()
+ commentRanges: CommentRangeLayer[] = [];
+
+ // explicitly highlight a range if it is not associated with any comment
+ @property({type: Object})
+ highlightRange?: CommentRange;
+
+ @property({type: Array})
+ coverageRanges: CoverageRange[] = [];
+
+ @property({type: Boolean})
+ lineWrapping = false;
+
+ @property({type: String})
+ viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+ @property({type: Object})
+ lineOfInterest?: DisplayLine;
+
+ /**
+ * True when diff is changed, until the content is done rendering.
+ * Use getter/setter loading instead of this.
+ */
+ private _loading = true;
+
+ get loading() {
+ return this._loading;
+ }
+
+ set loading(loading: boolean) {
+ if (this._loading === loading) return;
+ const oldLoading = this._loading;
+ this._loading = loading;
+ fire(this, 'loading-changed', {value: this._loading});
+ this.requestUpdate('loading', oldLoading);
+ }
+
+ @property({type: Boolean})
+ loggedIn = false;
+
+ @property({type: Object})
+ diff?: DiffInfo;
+
+ @state()
+ private diffTableClass = '';
+
+ @property({type: Object})
+ baseImage?: ImageInfo;
+
+ @property({type: Object})
+ revisionImage?: ImageInfo;
+
+ /**
+ * In order to allow multi-select in Safari browsers, a workaround is required
+ * to trigger 'beforeinput' events to get a list of static ranges. This is
+ * obtained by making the content of the diff table "contentEditable".
+ */
+ @property({type: Boolean})
+ override isContentEditable = isSafari();
+
+ /**
+ * Whether the safety check for large diffs when whole-file is set has
+ * been bypassed. If the value is null, then the safety has not been
+ * bypassed. If the value is a number, then that number represents the
+ * context preference to use when rendering the bypassed diff.
+ *
+ * Private but used in tests.
+ */
+ @state()
+ safetyBypass: number | null = null;
+
+ // Private but used in tests.
+ @state()
+ showWarning?: boolean;
+
+ @property({type: String})
+ errorMessage: string | null = null;
+
+ @property({type: Array})
+ blame: BlameInfo[] | null = null;
+
+ @property({type: Boolean})
+ showNewlineWarningLeft = false;
+
+ @property({type: Boolean})
+ showNewlineWarningRight = false;
+
+ @property({type: Boolean})
+ useNewImageDiffUi = false;
+
+ // Private but used in tests.
+ @state()
+ diffLength?: number;
+
+ /**
+ * Observes comment nodes added or removed at any point.
+ * Can be used to unregister upon detachment.
+ */
+ private nodeObserver?: MutationObserver;
+
+ @property({type: Array})
+ layers?: DiffLayer[];
+
+ // Private but used in tests.
+ renderDiffTableTask?: DelayedPromise<void>;
+
+ // Private but used in tests.
+ diffSelection = new GrDiffSelection();
+
+ // Private but used in tests.
+ highlights = new GrDiffHighlight();
+
+ // Private but used in tests.
+ diffBuilder = new GrDiffBuilderElement();
+
+ private diffModel = new DiffModel(undefined);
+
+ static override get styles() {
+ return [
+ iconStyles,
+ sharedStyles,
+ grSyntaxTheme,
+ grRangedCommentTheme,
+ grDiffStyles,
+ ];
+ }
+
+ constructor() {
+ super();
+ provide(this, diffModelToken, () => this.diffModel);
+ this.addEventListener(
+ 'create-range-comment',
+ (e: CustomEvent<CreateRangeCommentEventDetail>) =>
+ this.handleCreateRangeComment(e)
+ );
+ this.addEventListener('render-content', () => this.handleRenderContent());
+ this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
+ this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
+ });
+ }
+
+ override connectedCallback() {
+ super.connectedCallback();
+ if (this.loggedIn) {
+ this.addSelectionListeners();
+ }
+ if (this.diff && this.diffTable) {
+ this.diffSelection.init(this.diff, this.diffTable);
+ }
+ if (this.diffTable && this.diffBuilder) {
+ this.highlights.init(this.diffTable, this.diffBuilder);
+ }
+ this.diffBuilder.init();
+ }
+
+ override disconnectedCallback() {
+ this.removeSelectionListeners();
+ this.renderDiffTableTask?.cancel();
+ this.diffSelection.cleanup();
+ this.highlights.cleanup();
+ this.diffBuilder.cleanup();
+ super.disconnectedCallback();
+ }
+
+ protected override willUpdate(changedProperties: PropertyValues<this>): void {
+ if (
+ changedProperties.has('path') ||
+ changedProperties.has('lineWrapping') ||
+ changedProperties.has('viewMode') ||
+ changedProperties.has('useNewImageDiffUi') ||
+ changedProperties.has('prefs')
+ ) {
+ this.prefsChanged();
+ }
+ if (changedProperties.has('blame')) {
+ this.blameChanged();
+ }
+ if (changedProperties.has('renderPrefs')) {
+ this.renderPrefsChanged();
+ }
+ if (changedProperties.has('loggedIn')) {
+ if (this.loggedIn && this.isConnected) {
+ this.addSelectionListeners();
+ } else {
+ this.removeSelectionListeners();
+ }
+ }
+ if (changedProperties.has('coverageRanges')) {
+ this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+ }
+ if (changedProperties.has('lineOfInterest')) {
+ this.lineOfInterestChanged();
+ }
+ }
+
+ protected override updated(changedProperties: PropertyValues<this>): void {
+ if (changedProperties.has('diff')) {
+ // diffChanged relies on diffTable ahving been rendered.
+ this.diffChanged();
+ }
+ }
+
+ override render() {
+ return html`
+ ${this.renderHeader()} ${this.renderContainer()}
+ ${this.renderNewlineWarning()} ${this.renderLoadingError()}
+ ${this.renderSizeWarning()}
+ `;
+ }
+
+ private renderHeader() {
+ const diffheaderItems = this.computeDiffHeaderItems();
+ if (diffheaderItems.length === 0) return nothing;
+ return html`
+ <div id="diffHeader">
+ ${diffheaderItems.map(item => html`<div>${item}</div>`)}
+ </div>
+ `;
+ }
+
+ private renderContainer() {
+ const cssClasses = {
+ diffContainer: true,
+ unified: this.viewMode === DiffViewMode.UNIFIED,
+ sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
+ canComment: this.loggedIn,
+ };
+ return html`
+ <div class=${classMap(cssClasses)} @click=${this.handleTap}>
+ <table
+ id="diffTable"
+ class=${this.diffTableClass}
+ ?contenteditable=${this.isContentEditable}
+ ></table>
+ ${when(
+ this.showNoChangeMessage(),
+ () => html`
+ <div class="whitespace-change-only-message">
+ This file only contains whitespace changes. Modify the whitespace
+ setting to see the changes.
+ </div>
+ `
+ )}
+ </div>
+ `;
+ }
+
+ private renderNewlineWarning() {
+ const newlineWarning = this.computeNewlineWarning();
+ if (!newlineWarning) return nothing;
+ return html`<div class="newlineWarning">${newlineWarning}</div>`;
+ }
+
+ private renderLoadingError() {
+ if (!this.errorMessage) return nothing;
+ return html`<div id="loadingError">${this.errorMessage}</div>`;
+ }
+
+ private renderSizeWarning() {
+ if (!this.showWarning) return nothing;
+ // TODO: Update comment about 'Whole file' as it's not in settings.
+ return html`
+ <div id="sizeWarning">
+ <p>
+ Prevented render because "Whole file" is enabled and this diff is very
+ large (about ${this.diffLength} lines).
+ </p>
+ <gr-button @click=${this.collapseContext}>
+ Render with limited context
+ </gr-button>
+ <gr-button @click=${this.handleFullBypass}>
+ Render anyway (may be slow)
+ </gr-button>
+ </div>
+ `;
+ }
+
+ private addSelectionListeners() {
+ document.addEventListener('selectionchange', this.handleSelectionChange);
+ document.addEventListener('mouseup', this.handleMouseUp);
+ }
+
+ private removeSelectionListeners() {
+ document.removeEventListener('selectionchange', this.handleSelectionChange);
+ document.removeEventListener('mouseup', this.handleMouseUp);
+ }
+
+ getLineNumEls(side: Side): HTMLElement[] {
+ return this.diffBuilder.getLineNumEls(side);
+ }
+
+ // Private but used in tests.
+ showNoChangeMessage() {
+ return (
+ !this.loading &&
+ this.diff &&
+ !this.diff.binary &&
+ this.prefs &&
+ this.prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+ this.diffLength === 0
+ );
+ }
+
+ private readonly handleSelectionChange = () => {
+ // Because of shadow DOM selections, we handle the selectionchange here,
+ // and pass the shadow DOM selection into gr-diff-highlight, where the
+ // corresponding range is determined and normalized.
+ const selection = this.getShadowOrDocumentSelection();
+ this.highlights.handleSelectionChange(selection, false);
+ };
+
+ private readonly handleMouseUp = () => {
+ // To handle double-click outside of text creating comments, we check on
+ // mouse-up if there's a selection that just covers a line change. We
+ // can't do that on selection change since the user may still be dragging.
+ const selection = this.getShadowOrDocumentSelection();
+ this.highlights.handleSelectionChange(selection, true);
+ };
+
+ /** Gets the current selection, preferring the shadow DOM selection. */
+ private getShadowOrDocumentSelection() {
+ // When using native shadow DOM, the selection returned by
+ // document.getSelection() cannot reference the actual DOM elements making
+ // up the diff in Safari because they are in the shadow DOM of the gr-diff
+ // element. This takes the shadow DOM selection if one exists.
+ return this.shadowRoot?.getSelection
+ ? this.shadowRoot.getSelection()
+ : isSafari()
+ ? getContentEditableRange()
+ : document.getSelection();
+ }
+
+ private updateRanges(
+ addedThreadEls: GrDiffThreadElement[],
+ removedThreadEls: GrDiffThreadElement[]
+ ) {
+ function commentRangeFromThreadEl(
+ threadEl: GrDiffThreadElement
+ ): CommentRangeLayer | undefined {
+ const side = getSide(threadEl);
+ if (!side) return undefined;
+ const range = getRange(threadEl);
+ if (!range) return undefined;
+
+ return {side, range, rootId: threadEl.rootId};
+ }
+
+ // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
+ const addedCommentRanges = addedThreadEls
+ .map(commentRangeFromThreadEl)
+ .filter(range => !!range) as CommentRangeLayer[];
+ const removedCommentRanges = removedThreadEls
+ .map(commentRangeFromThreadEl)
+ .filter(range => !!range) as CommentRangeLayer[];
+ for (const removedCommentRange of removedCommentRanges) {
+ const i = this.commentRanges.findIndex(
+ cr =>
+ cr.side === removedCommentRange.side &&
+ rangesEqual(cr.range, removedCommentRange.range)
+ );
+ this.commentRanges.splice(i, 1);
+ }
+
+ if (addedCommentRanges?.length) {
+ this.commentRanges.push(...addedCommentRanges);
+ }
+ if (this.highlightRange) {
+ this.commentRanges.push({
+ side: Side.RIGHT,
+ range: this.highlightRange,
+ rootId: '',
+ });
+ }
+
+ this.diffBuilder.updateCommentRanges(this.commentRanges);
+ }
+
+ /**
+ * The key locations based on the comments and line of interests,
+ * where lines should not be collapsed.
+ *
+ */
+ private computeKeyLocations() {
+ const keyLocations: KeyLocations = {left: {}, right: {}};
+ if (this.lineOfInterest) {
+ const side = this.lineOfInterest.side;
+ keyLocations[side][this.lineOfInterest.lineNum] = true;
+ }
+ const threadEls = [...this.childNodes].filter(isThreadEl);
+
+ for (const threadEl of threadEls) {
+ const side = getSide(threadEl);
+ if (!side) continue;
+ const lineNum = getLine(threadEl);
+ const commentRange = getRange(threadEl);
+ keyLocations[side][lineNum] = true;
+ // Add start_line as well if exists,
+ // the being and end of the range should not be collapsed.
+ if (commentRange?.start_line) {
+ keyLocations[side][commentRange.start_line] = true;
+ }
+ }
+ return keyLocations;
+ }
+
+ // Dispatch events that are handled by the gr-diff-highlight.
+ private redispatchHoverEvents(
+ hoverEl: HTMLElement,
+ threadEl: GrDiffThreadElement
+ ) {
+ hoverEl.addEventListener('mouseenter', () => {
+ fire(threadEl, 'comment-thread-mouseenter', {});
+ });
+ hoverEl.addEventListener('mouseleave', () => {
+ fire(threadEl, 'comment-thread-mouseleave', {});
+ });
+ }
+
+ /** Cancel any remaining diff builder rendering work. */
+ cancel() {
+ this.diffBuilder.cleanup();
+ this.renderDiffTableTask?.cancel();
+ }
+
+ getCursorStops(): Array<HTMLElement | AbortStop> {
+ if (this.hidden && this.noAutoRender) return [];
+
+ // Get rendered stops.
+ const stops: Array<HTMLElement | AbortStop> =
+ this.diffBuilder.getLineNumberRows();
+
+ // If we are still loading this diff, abort after the rendered stops to
+ // avoid skipping over to e.g. the next file.
+ if (this.loading) {
+ stops.push(new AbortStop());
+ }
+ return stops;
+ }
+
+ isRangeSelected() {
+ return !!this.highlights.selectedRange;
+ }
+
+ toggleLeftDiff() {
+ toggleClass(this, 'no-left');
+ }
+
+ private blameChanged() {
+ this.diffBuilder.setBlame(this.blame);
+ if (this.blame) {
+ this.classList.add('showBlame');
+ } else {
+ this.classList.remove('showBlame');
+ }
+ }
+
+ // Private but used in tests.
+ handleTap(e: Event) {
+ const el = e.target as Element;
+
+ if (
+ el.getAttribute('data-value') !== LOST &&
+ (el.classList.contains('lineNum') ||
+ el.classList.contains('lineNumButton'))
+ ) {
+ this.addDraftAtLine(el);
+ } else if (
+ el.tagName === 'HL' ||
+ el.classList.contains('content') ||
+ el.classList.contains('contentText')
+ ) {
+ const target = getLineElByChild(el);
+ if (target) {
+ this.selectLine(target);
+ }
+ }
+ }
+
+ // Private but used in tests.
+ selectLine(el: Element) {
+ const lineNumber = Number(el.getAttribute('data-value'));
+ const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
+ this.dispatchSelectedLine(lineNumber, side);
+ }
+
+ private dispatchSelectedLine(number: LineNumber, side: Side) {
+ fire(this, 'line-selected', {
+ number,
+ side,
+ path: this.path,
+ });
+ }
+
+ addDraftAtLine(el: Element) {
+ this.selectLine(el);
+
+ const lineNum = getLineNumber(el);
+ if (lineNum === null) {
+ fireAlert(this, 'Invalid line number');
+ return;
+ }
+
+ this.createComment(el, lineNum);
+ }
+
+ createRangeComment() {
+ if (!this.isRangeSelected()) {
+ throw Error('Selection is needed for new range comment');
+ }
+ const selectedRange = this.highlights.selectedRange;
+ if (!selectedRange) throw Error('selected range not set');
+ const {side, range} = selectedRange;
+ this.createCommentForSelection(side, range);
+ }
+
+ createCommentForSelection(side: Side, range: CommentRange) {
+ const lineNum = range.end_line;
+ const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
+ if (lineEl) {
+ this.createComment(lineEl, lineNum, side, range);
+ }
+ }
+
+ private handleCreateRangeComment(
+ e: CustomEvent<CreateRangeCommentEventDetail>
+ ) {
+ const range = e.detail.range;
+ const side = e.detail.side;
+ this.createCommentForSelection(side, range);
+ }
+
+ // Private but used in tests.
+ createComment(
+ lineEl: Element,
+ lineNum: LineNumber,
+ side?: Side,
+ range?: CommentRange
+ ) {
+ const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+ if (!contentEl) throw new Error('content el not found for line el');
+ side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
+ fire(this, 'create-comment', {
+ side,
+ lineNum,
+ range,
+ });
+ }
+
+ private getCommentSideByLineAndContent(
+ lineEl: Element,
+ contentEl: Element
+ ): Side {
+ return lineEl.classList.contains(Side.LEFT) ||
+ contentEl.classList.contains('remove')
+ ? Side.LEFT
+ : Side.RIGHT;
+ }
+
+ private lineOfInterestChanged() {
+ if (this.loading) return;
+ if (!this.lineOfInterest) return;
+ const lineNum = this.lineOfInterest.lineNum;
+ if (typeof lineNum !== 'number') return;
+ this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+ }
+
+ private cleanup() {
+ this.cancel();
+ this.blame = null;
+ this.safetyBypass = null;
+ this.showWarning = false;
+ this.clearDiffContent();
+ }
+
+ private prefsChanged() {
+ if (!this.prefs) return;
+ this.diffModel.updateState({diffPrefs: this.prefs});
+
+ this.blame = null;
+ this.updatePreferenceStyles();
+
+ if (this.diff && !this.noRenderOnPrefsChange) {
+ this.debounceRenderDiffTable();
+ }
+ }
+
+ private updatePreferenceStyles() {
+ assertIsDefined(this.prefs, 'prefs');
+ const lineLength =
+ this.path === COMMIT_MSG_PATH
+ ? COMMIT_MSG_LINE_LENGTH
+ : this.prefs.line_length;
+ const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
+
+ const responsiveMode = getResponsiveMode(this.prefs, this.renderPrefs);
+ const responsive = isResponsive(responsiveMode);
+ this.diffTableClass = responsive ? 'responsive' : '';
+ const lineLimit = `${lineLength}ch`;
+ this.style.setProperty(
+ '--line-limit-marker',
+ responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px'
+ );
+ this.style.setProperty('--content-width', responsive ? 'none' : lineLimit);
+ if (responsiveMode === 'SHRINK_ONLY') {
+ // Calculating ideal (initial) width for the whole table including
+ // width of each table column (content and line number columns) and
+ // border. We also add a 1px correction as some values are calculated
+ // in 'ch'.
+
+ // We might have 1 to 2 columns for content depending if side-by-side
+ // or unified mode
+ const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
+
+ // We always have 2 columns for line number
+ const lineNumberWidth = `2 * ${getLineNumberCellWidth(this.prefs)}px`;
+
+ // border-right in ".section" css definition (in gr-diff_html.ts)
+ const sectionRightBorder = '1px';
+
+ // each sign col has 1ch width.
+ const signColsWidth =
+ sideBySide && this.renderPrefs?.show_sign_col ? '2ch' : '0ch';
+
+ // As some of these calculations are done using 'ch' we end up having <1px
+ // difference between ideal and calculated size for each side leading to
+ // lines using the max columns (e.g. 80) to wrap (decided exclusively by
+ // the browser).This happens even in monospace fonts. Empirically adding
+ // 2px as correction to be sure wrapping won't happen in these cases so it
+ // doesn't block further experimentation with the SHRINK_MODE. This was
+ // previously set to 1px but due to to a more aggressive text wrapping
+ // (via word-break: break-all; - check .contextText) we need to be even
+ // more lenient in some cases. If we find another way to avoid this
+ // correction we will change it.
+ const dontWrapCorrection = '2px';
+ this.style.setProperty(
+ '--diff-max-width',
+ `calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`
+ );
+ } else {
+ this.style.setProperty('--diff-max-width', 'none');
+ }
+ if (this.prefs.font_size) {
+ this.style.setProperty('--font-size', `${this.prefs.font_size}px`);
+ }
+ }
+
+ private renderPrefsChanged() {
+ this.diffModel.updateState({renderPrefs: this.renderPrefs});
+ if (this.renderPrefs.hide_left_side) {
+ this.classList.add('no-left');
+ }
+ if (this.renderPrefs.disable_context_control_buttons) {
+ this.classList.add('disable-context-control-buttons');
+ }
+ if (this.renderPrefs.hide_line_length_indicator) {
+ this.classList.add('hide-line-length-indicator');
+ }
+ if (this.renderPrefs.show_sign_col) {
+ this.classList.add('with-sign-col');
+ }
+ if (this.prefs) {
+ this.updatePreferenceStyles();
+ }
+ this.diffBuilder.updateRenderPrefs(this.renderPrefs);
+ }
+
+ private diffChanged() {
+ this.loading = true;
+ this.cleanup();
+ if (this.diff) {
+ this.diffLength = this.getDiffLength(this.diff);
+ this.debounceRenderDiffTable();
+ assertIsDefined(this.diffTable, 'diffTable');
+ this.diffSelection.init(this.diff, this.diffTable);
+ this.highlights.init(this.diffTable, this.diffBuilder);
+ }
+ }
+
+ // Implemented so the test can stub it.
+ getDiffLength(diff?: DiffInfo) {
+ return getDiffLength(diff);
+ }
+
+ /**
+ * When called multiple times from the same task, will call
+ * _renderDiffTable only once, in the next task (scheduled via `setTimeout`).
+ *
+ * This should be used instead of calling _renderDiffTable directly to
+ * render the diff in response to an input change, because there may be
+ * multiple inputs changing in the same microtask, but we only want to
+ * render once.
+ */
+ private debounceRenderDiffTable() {
+ // at this point gr-diff might be considered as rendered from the outside
+ // (client), although it was not actually rendered. Clients need to know
+ // when it is safe to perform operations like cursor moves, for example,
+ // and if changing an input actually requires a reload of the diff table.
+ // Since `fire` is synchronous it allows clients to be aware when an
+ // async render is needed and that they can wait for a further `render`
+ // event to actually take further action.
+ fire(this, 'render-required', {});
+ this.renderDiffTableTask = debounceP(
+ this.renderDiffTableTask,
+ async () => await this.renderDiffTable()
+ );
+ this.renderDiffTableTask.catch((e: unknown) => {
+ if (e === DELAYED_CANCELLATION) return;
+ throw e;
+ });
+ }
+
+ // Private but used in tests.
+ async renderDiffTable() {
+ this.unobserveNodes();
+ if (!this.diff || !this.prefs) {
+ fire(this, 'render', {});
+ return;
+ }
+ if (
+ this.prefs.context === -1 &&
+ this.diffLength &&
+ this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
+ this.safetyBypass === null
+ ) {
+ this.showWarning = true;
+ fire(this, 'render', {});
+ return;
+ }
+
+ this.showWarning = false;
+
+ const keyLocations = this.computeKeyLocations();
+
+ this.diffModel.setState({
+ diff: this.diff,
+ path: this.path,
+ renderPrefs: this.renderPrefs,
+ diffPrefs: this.prefs,
+ });
+
+ // TODO: Setting tons of public properties like this is obviously a code
+ // smell. We are introducing a diff model for managing all this
+ // data. Then diff builder will only need access to that model.
+ this.diffBuilder.prefs = this.getBypassPrefs();
+ this.diffBuilder.renderPrefs = this.renderPrefs;
+ this.diffBuilder.diff = this.diff;
+ this.diffBuilder.path = this.path;
+ this.diffBuilder.viewMode = this.viewMode;
+ this.diffBuilder.layers = this.layers ?? [];
+ this.diffBuilder.isImageDiff = this.isImageDiff;
+ this.diffBuilder.baseImage = this.baseImage ?? null;
+ this.diffBuilder.revisionImage = this.revisionImage ?? null;
+ this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
+ this.diffBuilder.diffElement = this.diffTable;
+ // `this.commentRanges` are probably empty here, because they will only be
+ // populated by the node observer, which starts observing *after* rendering.
+ this.diffBuilder.updateCommentRanges(this.commentRanges);
+ this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+ await this.diffBuilder.render(keyLocations);
+ }
+
+ private handleRenderContent() {
+ this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
+ element.remove()
+ );
+ this.loading = false;
+ this.observeNodes();
+ // We are just converting 'render-content' into 'render' here. Maybe we
+ // should retire the 'render' event in favor of 'render-content'?
+ fire(this, 'render', {});
+ }
+
+ private observeNodes() {
+ // First stop observing old nodes.
+ this.unobserveNodes();
+ // Then introduce a Mutation observer that watches for children being added
+ // to gr-diff. If those children are `isThreadEl`, namely then they are
+ // processed.
+ this.nodeObserver = new MutationObserver(mutations => {
+ const addedThreadEls = extractAddedNodes(mutations).filter(isThreadEl);
+ const removedThreadEls =
+ extractRemovedNodes(mutations).filter(isThreadEl);
+ this.processNodes(addedThreadEls, removedThreadEls);
+ });
+ this.nodeObserver.observe(this, {childList: true});
+ // Make sure to process existing gr-comment-threads that already exist.
+ this.processNodes([...this.childNodes].filter(isThreadEl), []);
+ }
+
+ private processNodes(
+ addedThreadEls: GrDiffThreadElement[],
+ removedThreadEls: GrDiffThreadElement[]
+ ) {
+ this.updateRanges(addedThreadEls, removedThreadEls);
+ addedThreadEls.forEach(threadEl =>
+ this.redispatchHoverEvents(threadEl, threadEl)
+ );
+ // Removed nodes do not need to be handled because all this code does is
+ // adding a slot for the added thread elements, and the extra slots do
+ // not hurt. It's probably a bigger performance cost to remove them than
+ // to keep them around. Medium term we can even consider to add one slot
+ // for each line from the start.
+ for (const threadEl of addedThreadEls) {
+ const lineNum = getLine(threadEl);
+ const commentSide = getSide(threadEl);
+ const range = getRange(threadEl);
+ if (!commentSide) continue;
+ const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
+ // When the line the comment refers to does not exist, log an error
+ // but don't crash. This can happen e.g. if the API does not fully
+ // validate e.g. (robot) comments
+ if (!lineEl) {
+ console.error(
+ 'thread attached to line ',
+ commentSide,
+ lineNum,
+ ' which does not exist.'
+ );
+ continue;
+ }
+ const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+ if (!contentEl) continue;
+ if (lineNum === LOST) {
+ this.insertPortedCommentsWithoutRangeMessage(contentEl);
+ }
+
+ const slotAtt = threadEl.getAttribute('slot');
+ if (range && isLongCommentRange(range) && slotAtt) {
+ const longRangeCommentHint = document.createElement(
+ 'gr-ranged-comment-hint'
+ );
+ longRangeCommentHint.range = range;
+ longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
+ longRangeCommentHint.setAttribute('slot', slotAtt);
+ this.insertBefore(longRangeCommentHint, threadEl);
+ this.redispatchHoverEvents(longRangeCommentHint, threadEl);
+ }
+ }
+
+ for (const threadEl of removedThreadEls) {
+ this.querySelector(
+ `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
+ )?.remove();
+ }
+ }
+
+ private unobserveNodes() {
+ if (this.nodeObserver) {
+ this.nodeObserver.disconnect();
+ this.nodeObserver = undefined;
+ }
+ // You only stop observing for comment thread elements when the diff is
+ // completely rendered from scratch. And then comment thread elements
+ // will be (re-)added *after* rendering is done. That is also when we
+ // re-start observing. So it is appropriate to thoroughly clean up
+ // everything that the observer is managing.
+ this.commentRanges = [];
+ }
+
+ private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
+ const existingMessage = lostCell.querySelector('div.lost-message');
+ if (existingMessage) return;
+
+ const div = document.createElement('div');
+ div.className = 'lost-message';
+ const icon = document.createElement('gr-icon');
+ icon.setAttribute('icon', 'info');
+ div.appendChild(icon);
+ const span = document.createElement('span');
+ span.innerText = 'Original comment position not found in this patchset';
+ div.appendChild(span);
+ lostCell.insertBefore(div, lostCell.firstChild);
+ }
+
+ /**
+ * Get the preferences object including the safety bypass context (if any).
+ */
+ private getBypassPrefs() {
+ assertIsDefined(this.prefs, 'prefs');
+ if (this.safetyBypass !== null) {
+ return {...this.prefs, context: this.safetyBypass};
+ }
+ return this.prefs;
+ }
+
+ clearDiffContent() {
+ this.unobserveNodes();
+ if (!this.diffTable) return;
+ while (this.diffTable.hasChildNodes()) {
+ this.diffTable.removeChild(this.diffTable.lastChild!);
+ }
+ }
+
+ // Private but used in tests.
+ computeDiffHeaderItems() {
+ return (this.diff?.diff_header ?? [])
+ .filter(
+ item =>
+ !(
+ item.startsWith('diff --git ') ||
+ item.startsWith('index ') ||
+ item.startsWith('+++ ') ||
+ item.startsWith('--- ') ||
+ item === 'Binary files differ'
+ )
+ )
+ .map(expandFileMode);
+ }
+
+ private handleFullBypass() {
+ this.safetyBypass = FULL_CONTEXT;
+ this.debounceRenderDiffTable();
+ }
+
+ private collapseContext() {
+ // Uses the default context amount if the preference is for the entire file.
+ this.safetyBypass =
+ this.prefs?.context && this.prefs.context >= 0
+ ? null
+ : createDefaultDiffPrefs().context;
+ this.debounceRenderDiffTable();
+ }
+
+ toggleAllContext() {
+ if (!this.prefs) {
+ return;
+ }
+ if (this.getBypassPrefs().context < 0) {
+ this.collapseContext();
+ } else {
+ this.handleFullBypass();
+ }
+ }
+
+ private computeNewlineWarning(): string | undefined {
+ const messages = [];
+ if (this.showNewlineWarningLeft) {
+ messages.push(NO_NEWLINE_LEFT);
+ }
+ if (this.showNewlineWarningRight) {
+ messages.push(NO_NEWLINE_RIGHT);
+ }
+ if (!messages.length) {
+ return undefined;
+ }
+ return messages.join(' \u2014 '); // \u2014 - '—'
+ }
+}
+
+function extractAddedNodes(mutations: MutationRecord[]) {
+ return mutations.flatMap(mutation => [...mutation.addedNodes]);
+}
+
+function extractRemovedNodes(mutations: MutationRecord[]) {
+ return mutations.flatMap(mutation => [...mutation.removedNodes]);
+}
+
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+ customElements.define('gr-diff', GrDiff);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff': LitElement;
+ }
+ interface HTMLElementEventMap {
+ 'comment-thread-mouseenter': CustomEvent<{}>;
+ 'comment-thread-mouseleave': CustomEvent<{}>;
+ 'loading-changed': ValueChangedEvent<boolean>;
+ 'render-required': CustomEvent<{}>;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff_test.ts
new file mode 100644
index 0000000..645a64a
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-new/gr-diff/gr-diff_test.ts
@@ -0,0 +1,4184 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {createDiff} from '../../../test/test-data-generators';
+import './gr-diff';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import '@polymer/paper-button/paper-button';
+import {
+ DiffContent,
+ DiffInfo,
+ DiffPreferencesInfo,
+ DiffViewMode,
+ IgnoreWhitespaceType,
+ Side,
+} from '../../../api/diff';
+import {
+ mockPromise,
+ mouseDown,
+ query,
+ queryAll,
+ queryAndAssert,
+ waitEventLoop,
+ waitQueryAndAssert,
+ waitUntil,
+} from '../../../test/test-utils';
+import {AbortStop} from '../../../api/core';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiff} from './gr-diff';
+import {ImageInfo} from '../../../types/common';
+import {GrRangedCommentHint} from '../../diff/gr-ranged-comment-hint/gr-ranged-comment-hint';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-diff a11y test', () => {
+ test('audit', async () => {
+ assert.isAccessible(await fixture(html`<gr-diff></gr-diff>`));
+ });
+});
+
+suite('gr-diff tests', () => {
+ let element: GrDiff;
+
+ const MINIMAL_PREFS: DiffPreferencesInfo = {
+ tab_size: 2,
+ line_length: 80,
+ font_size: 12,
+ context: 3,
+ ignore_whitespace: 'IGNORE_NONE',
+ };
+
+ setup(async () => {
+ element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+ });
+
+ suite('rendering', () => {
+ test('empty diff', async () => {
+ await element.updateComplete;
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="diffContainer sideBySide">
+ <table id="diffTable"></table>
+ </div>
+ `
+ );
+ });
+
+ test('a unified diff lit', async () => {
+ element.viewMode = DiffViewMode.UNIFIED;
+ element.prefs = {...MINIMAL_PREFS};
+ element.diff = createDiff();
+ await element.updateComplete;
+ await waitForEventOnce(element, 'render');
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="diffContainer unified">
+ <table class="selected-right" id="diffTable">
+ <colgroup>
+ <col class="blame gr-diff" />
+ <col class="gr-diff" width="48" />
+ <col class="gr-diff" width="48" />
+ <col class="gr-diff" />
+ </colgroup>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-LOST right-button-LOST right-content-LOST"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="LOST"></td>
+ <td class="gr-diff left lineNum" data-value="LOST"></td>
+ <td class="gr-diff lineNum right" data-value="LOST"></td>
+ <td class="both content gr-diff lost no-intraline-info right">
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="FILE"></td>
+ <td class="gr-diff left lineNum" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff left lineNumButton"
+ data-value="FILE"
+ id="left-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff lineNumButton right"
+ data-value="FILE"
+ id="right-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="both content file gr-diff no-intraline-info right">
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-1 right-button-1 right-content-1"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-2 right-button-2 right-content-2"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="2"></td>
+ <td class="gr-diff left lineNum" data-value="2">
+ <button
+ aria-label="2 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="2"
+ id="left-button-2"
+ tabindex="-1"
+ >
+ 2
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="2">
+ <button
+ aria-label="2 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="2"
+ id="right-button-2"
+ tabindex="-1"
+ >
+ 2
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-2"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-3 right-button-3 right-content-3"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="3"></td>
+ <td class="gr-diff left lineNum" data-value="3">
+ <button
+ aria-label="3 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="3"
+ id="left-button-3"
+ tabindex="-1"
+ >
+ 3
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="3">
+ <button
+ aria-label="3 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="3"
+ id="right-button-3"
+ tabindex="-1"
+ >
+ 3
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-3"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-4 right-button-4 right-content-4"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="4"></td>
+ <td class="gr-diff left lineNum" data-value="4">
+ <button
+ aria-label="4 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="4"
+ id="left-button-4"
+ tabindex="-1"
+ >
+ 4
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="4">
+ <button
+ aria-label="4 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="4"
+ id="right-button-4"
+ tabindex="-1"
+ >
+ 4
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-4"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="right-button-5 right-content-5"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="5">
+ <button
+ aria-label="5 added"
+ class="gr-diff lineNumButton right"
+ data-value="5"
+ id="right-button-5"
+ tabindex="-1"
+ >
+ 5
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-5"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-6 right-content-6"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="6">
+ <button
+ aria-label="6 added"
+ class="gr-diff lineNumButton right"
+ data-value="6"
+ id="right-button-6"
+ tabindex="-1"
+ >
+ 6
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-6"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-7 right-content-7"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="7">
+ <button
+ aria-label="7 added"
+ class="gr-diff lineNumButton right"
+ data-value="7"
+ id="right-button-7"
+ tabindex="-1"
+ >
+ 7
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-7"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-5 right-button-8 right-content-8"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="5"></td>
+ <td class="gr-diff left lineNum" data-value="5">
+ <button
+ aria-label="5 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="5"
+ id="left-button-5"
+ tabindex="-1"
+ >
+ 5
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="8">
+ <button
+ aria-label="8 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="8"
+ id="right-button-8"
+ tabindex="-1"
+ >
+ 8
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-8"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-6 right-button-9 right-content-9"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="6"></td>
+ <td class="gr-diff left lineNum" data-value="6">
+ <button
+ aria-label="6 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="6"
+ id="left-button-6"
+ tabindex="-1"
+ >
+ 6
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="9">
+ <button
+ aria-label="9 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="9"
+ id="right-button-9"
+ tabindex="-1"
+ >
+ 9
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-9"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-7 right-button-10 right-content-10"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="7"></td>
+ <td class="gr-diff left lineNum" data-value="7">
+ <button
+ aria-label="7 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="7"
+ id="left-button-7"
+ tabindex="-1"
+ >
+ 7
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="10">
+ <button
+ aria-label="10 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="10"
+ id="right-button-10"
+ tabindex="-1"
+ >
+ 10
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-10"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-8 right-button-11 right-content-11"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="8"></td>
+ <td class="gr-diff left lineNum" data-value="8">
+ <button
+ aria-label="8 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="8"
+ id="left-button-8"
+ tabindex="-1"
+ >
+ 8
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="11">
+ <button
+ aria-label="11 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="11"
+ id="right-button-11"
+ tabindex="-1"
+ >
+ 11
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-11"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-9 right-button-12 right-content-12"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="9"></td>
+ <td class="gr-diff left lineNum" data-value="9">
+ <button
+ aria-label="9 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="9"
+ id="left-button-9"
+ tabindex="-1"
+ >
+ 9
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="12">
+ <button
+ aria-label="12 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="12"
+ id="right-button-12"
+ tabindex="-1"
+ >
+ 12
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-12"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="left-button-10 left-content-10"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="10"></td>
+ <td class="gr-diff left lineNum" data-value="10">
+ <button
+ aria-label="10 removed"
+ class="gr-diff left lineNumButton"
+ data-value="10"
+ id="left-button-10"
+ tabindex="-1"
+ >
+ 10
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-10"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-11 left-content-11"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="11"></td>
+ <td class="gr-diff left lineNum" data-value="11">
+ <button
+ aria-label="11 removed"
+ class="gr-diff left lineNumButton"
+ data-value="11"
+ id="left-button-11"
+ tabindex="-1"
+ >
+ 11
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-11"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-12 left-content-12"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="12"></td>
+ <td class="gr-diff left lineNum" data-value="12">
+ <button
+ aria-label="12 removed"
+ class="gr-diff left lineNumButton"
+ data-value="12"
+ id="left-button-12"
+ tabindex="-1"
+ >
+ 12
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-12"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-13 left-content-13"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="13"></td>
+ <td class="gr-diff left lineNum" data-value="13">
+ <button
+ aria-label="13 removed"
+ class="gr-diff left lineNumButton"
+ data-value="13"
+ id="left-button-13"
+ tabindex="-1"
+ >
+ 13
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-13"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+ <tr
+ aria-labelledby="right-button-13 right-content-13"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="13">
+ <button
+ aria-label="13 added"
+ class="gr-diff lineNumButton right"
+ data-value="13"
+ id="right-button-13"
+ tabindex="-1"
+ >
+ 13
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-13"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-14 right-content-14"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="14">
+ <button
+ aria-label="14 added"
+ class="gr-diff lineNumButton right"
+ data-value="14"
+ id="right-button-14"
+ tabindex="-1"
+ >
+ 14
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-14"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section">
+ <tr
+ aria-labelledby="left-button-16 left-content-16"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="16"></td>
+ <td class="gr-diff left lineNum" data-value="16">
+ <button
+ aria-label="16 removed"
+ class="gr-diff left lineNumButton"
+ data-value="16"
+ id="left-button-16"
+ tabindex="-1"
+ >
+ 16
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-16"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-15 right-content-15"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="15">
+ <button
+ aria-label="15 added"
+ class="gr-diff lineNumButton right"
+ data-value="15"
+ id="right-button-15"
+ tabindex="-1"
+ >
+ 15
+ </button>
+ </td>
+ <td class="add content gr-diff right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-15"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-17 right-button-16 right-content-16"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="17"></td>
+ <td class="gr-diff left lineNum" data-value="17">
+ <button
+ aria-label="17 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="17"
+ id="left-button-17"
+ tabindex="-1"
+ >
+ 17
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="16">
+ <button
+ aria-label="16 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="16"
+ id="right-button-16"
+ tabindex="-1"
+ >
+ 16
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-16"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-18 right-button-17 right-content-17"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="18"></td>
+ <td class="gr-diff left lineNum" data-value="18">
+ <button
+ aria-label="18 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="18"
+ id="left-button-18"
+ tabindex="-1"
+ >
+ 18
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="17">
+ <button
+ aria-label="17 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="17"
+ id="right-button-17"
+ tabindex="-1"
+ >
+ 17
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-17"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-19 right-button-18 right-content-18"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="19"></td>
+ <td class="gr-diff left lineNum" data-value="19">
+ <button
+ aria-label="19 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="19"
+ id="left-button-19"
+ tabindex="-1"
+ >
+ 19
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="18">
+ <button
+ aria-label="18 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="18"
+ id="right-button-18"
+ tabindex="-1"
+ >
+ 18
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-18"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="contextControl gr-diff section">
+ <tr class="above contextBackground gr-diff unified">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ <tr class="dividerRow gr-diff show-both">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="dividerCell gr-diff" colspan="3">
+ <gr-context-controls class="gr-diff" showconfig="both">
+ </gr-context-controls>
+ </td>
+ </tr>
+ <tr class="below contextBackground gr-diff unified">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-38 right-button-37 right-content-37"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="38"></td>
+ <td class="gr-diff left lineNum" data-value="38">
+ <button
+ aria-label="38 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="38"
+ id="left-button-38"
+ tabindex="-1"
+ >
+ 38
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="37">
+ <button
+ aria-label="37 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="37"
+ id="right-button-37"
+ tabindex="-1"
+ >
+ 37
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-37"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-39 right-button-38 right-content-38"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="39"></td>
+ <td class="gr-diff left lineNum" data-value="39">
+ <button
+ aria-label="39 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="39"
+ id="left-button-39"
+ tabindex="-1"
+ >
+ 39
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="38">
+ <button
+ aria-label="38 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="38"
+ id="right-button-38"
+ tabindex="-1"
+ >
+ 38
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-38"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-40 right-button-39 right-content-39"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="40"></td>
+ <td class="gr-diff left lineNum" data-value="40">
+ <button
+ aria-label="40 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="40"
+ id="left-button-40"
+ tabindex="-1"
+ >
+ 40
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="39">
+ <button
+ aria-label="39 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="39"
+ id="right-button-39"
+ tabindex="-1"
+ >
+ 39
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-39"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="right-button-40 right-content-40"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="40">
+ <button
+ aria-label="40 added"
+ class="gr-diff lineNumButton right"
+ data-value="40"
+ id="right-button-40"
+ tabindex="-1"
+ >
+ 40
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-40"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-41 right-content-41"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="41">
+ <button
+ aria-label="41 added"
+ class="gr-diff lineNumButton right"
+ data-value="41"
+ id="right-button-41"
+ tabindex="-1"
+ >
+ 41
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-41"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-42 right-content-42"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="42">
+ <button
+ aria-label="42 added"
+ class="gr-diff lineNumButton right"
+ data-value="42"
+ id="right-button-42"
+ tabindex="-1"
+ >
+ 42
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-42"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-43 right-content-43"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="43">
+ <button
+ aria-label="43 added"
+ class="gr-diff lineNumButton right"
+ data-value="43"
+ id="right-button-43"
+ tabindex="-1"
+ >
+ 43
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-43"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-41 right-button-44 right-content-44"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="41"></td>
+ <td class="gr-diff left lineNum" data-value="41">
+ <button
+ aria-label="41 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="41"
+ id="left-button-41"
+ tabindex="-1"
+ >
+ 41
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="44">
+ <button
+ aria-label="44 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="44"
+ id="right-button-44"
+ tabindex="-1"
+ >
+ 44
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-44"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-42 right-button-45 right-content-45"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="42"></td>
+ <td class="gr-diff left lineNum" data-value="42">
+ <button
+ aria-label="42 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="42"
+ id="left-button-42"
+ tabindex="-1"
+ >
+ 42
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="45">
+ <button
+ aria-label="45 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="45"
+ id="right-button-45"
+ tabindex="-1"
+ >
+ 45
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-45"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-43 right-button-46 right-content-46"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="43"></td>
+ <td class="gr-diff left lineNum" data-value="43">
+ <button
+ aria-label="43 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="43"
+ id="left-button-43"
+ tabindex="-1"
+ >
+ 43
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="46">
+ <button
+ aria-label="46 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="46"
+ id="right-button-46"
+ tabindex="-1"
+ >
+ 46
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-46"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-44 right-button-47 right-content-47"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="44"></td>
+ <td class="gr-diff left lineNum" data-value="44">
+ <button
+ aria-label="44 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="44"
+ id="left-button-44"
+ tabindex="-1"
+ >
+ 44
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="47">
+ <button
+ aria-label="47 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="47"
+ id="right-button-47"
+ tabindex="-1"
+ >
+ 47
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-47"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-45 right-button-48 right-content-48"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="45"></td>
+ <td class="gr-diff left lineNum" data-value="45">
+ <button
+ aria-label="45 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="45"
+ id="left-button-45"
+ tabindex="-1"
+ >
+ 45
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="48">
+ <button
+ aria-label="48 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="48"
+ id="right-button-48"
+ tabindex="-1"
+ >
+ 48
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-48"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ `,
+ {
+ ignoreTags: [
+ 'gr-context-controls-section',
+ 'gr-diff-section',
+ 'gr-diff-row',
+ 'gr-diff-text',
+ 'gr-legacy-text',
+ 'slot',
+ ],
+ }
+ );
+ });
+
+ test('a normal diff lit', async () => {
+ element.prefs = {...MINIMAL_PREFS};
+ element.diff = createDiff();
+ await element.updateComplete;
+ await waitForEventOnce(element, 'render');
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="diffContainer sideBySide">
+ <table class="selected-right" id="diffTable">
+ <colgroup>
+ <col class="blame gr-diff" />
+ <col class="gr-diff left" width="48" />
+ <col class="gr-diff left sign" />
+ <col class="gr-diff left" />
+ <col class="gr-diff right" width="48" />
+ <col class="gr-diff right sign" />
+ <col class="gr-diff right" />
+ </colgroup>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-LOST left-content-LOST right-button-LOST right-content-LOST"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="LOST"></td>
+ <td class="gr-diff left lineNum" data-value="LOST"></td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left lost no-intraline-info">
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="LOST"></td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff lost no-intraline-info right">
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="FILE"></td>
+ <td class="gr-diff left lineNum" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff left lineNumButton"
+ data-value="FILE"
+ id="left-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content file gr-diff left no-intraline-info">
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff lineNumButton right"
+ data-value="FILE"
+ id="right-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content file gr-diff no-intraline-info right">
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-2 left-content-2 right-button-2 right-content-2"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="2"></td>
+ <td class="gr-diff left lineNum" data-value="2">
+ <button
+ aria-label="2 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="2"
+ id="left-button-2"
+ tabindex="-1"
+ >
+ 2
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-2"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="2">
+ <button
+ aria-label="2 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="2"
+ id="right-button-2"
+ tabindex="-1"
+ >
+ 2
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-2"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-3 left-content-3 right-button-3 right-content-3"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="3"></td>
+ <td class="gr-diff left lineNum" data-value="3">
+ <button
+ aria-label="3 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="3"
+ id="left-button-3"
+ tabindex="-1"
+ >
+ 3
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-3"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="3">
+ <button
+ aria-label="3 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="3"
+ id="right-button-3"
+ tabindex="-1"
+ >
+ 3
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-3"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-4 left-content-4 right-button-4 right-content-4"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="4"></td>
+ <td class="gr-diff left lineNum" data-value="4">
+ <button
+ aria-label="4 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="4"
+ id="left-button-4"
+ tabindex="-1"
+ >
+ 4
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-4"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="4">
+ <button
+ aria-label="4 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="4"
+ id="right-button-4"
+ tabindex="-1"
+ >
+ 4
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-4"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="right-button-5 right-content-5"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="5">
+ <button
+ aria-label="5 added"
+ class="gr-diff lineNumButton right"
+ data-value="5"
+ id="right-button-5"
+ tabindex="-1"
+ >
+ 5
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-5"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-6 right-content-6"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="6">
+ <button
+ aria-label="6 added"
+ class="gr-diff lineNumButton right"
+ data-value="6"
+ id="right-button-6"
+ tabindex="-1"
+ >
+ 6
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-6"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-7 right-content-7"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="7">
+ <button
+ aria-label="7 added"
+ class="gr-diff lineNumButton right"
+ data-value="7"
+ id="right-button-7"
+ tabindex="-1"
+ >
+ 7
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-7"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-5 left-content-5 right-button-8 right-content-8"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="5"></td>
+ <td class="gr-diff left lineNum" data-value="5">
+ <button
+ aria-label="5 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="5"
+ id="left-button-5"
+ tabindex="-1"
+ >
+ 5
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-5"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="8">
+ <button
+ aria-label="8 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="8"
+ id="right-button-8"
+ tabindex="-1"
+ >
+ 8
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-8"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-6 left-content-6 right-button-9 right-content-9"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="6"></td>
+ <td class="gr-diff left lineNum" data-value="6">
+ <button
+ aria-label="6 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="6"
+ id="left-button-6"
+ tabindex="-1"
+ >
+ 6
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-6"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="9">
+ <button
+ aria-label="9 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="9"
+ id="right-button-9"
+ tabindex="-1"
+ >
+ 9
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-9"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-7 left-content-7 right-button-10 right-content-10"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="7"></td>
+ <td class="gr-diff left lineNum" data-value="7">
+ <button
+ aria-label="7 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="7"
+ id="left-button-7"
+ tabindex="-1"
+ >
+ 7
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-7"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="10">
+ <button
+ aria-label="10 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="10"
+ id="right-button-10"
+ tabindex="-1"
+ >
+ 10
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-10"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-8 left-content-8 right-button-11 right-content-11"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="8"></td>
+ <td class="gr-diff left lineNum" data-value="8">
+ <button
+ aria-label="8 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="8"
+ id="left-button-8"
+ tabindex="-1"
+ >
+ 8
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-8"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="11">
+ <button
+ aria-label="11 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="11"
+ id="right-button-11"
+ tabindex="-1"
+ >
+ 11
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-11"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-9 left-content-9 right-button-12 right-content-12"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="9"></td>
+ <td class="gr-diff left lineNum" data-value="9">
+ <button
+ aria-label="9 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="9"
+ id="left-button-9"
+ tabindex="-1"
+ >
+ 9
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-9"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="12">
+ <button
+ aria-label="12 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="12"
+ id="right-button-12"
+ tabindex="-1"
+ >
+ 12
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-12"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="left-button-10 left-content-10"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="10"></td>
+ <td class="gr-diff left lineNum" data-value="10">
+ <button
+ aria-label="10 removed"
+ class="gr-diff left lineNumButton"
+ data-value="10"
+ id="left-button-10"
+ tabindex="-1"
+ >
+ 10
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-10"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-11 left-content-11"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="11"></td>
+ <td class="gr-diff left lineNum" data-value="11">
+ <button
+ aria-label="11 removed"
+ class="gr-diff left lineNumButton"
+ data-value="11"
+ id="left-button-11"
+ tabindex="-1"
+ >
+ 11
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-11"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-12 left-content-12"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="12"></td>
+ <td class="gr-diff left lineNum" data-value="12">
+ <button
+ aria-label="12 removed"
+ class="gr-diff left lineNumButton"
+ data-value="12"
+ id="left-button-12"
+ tabindex="-1"
+ >
+ 12
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-12"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-13 left-content-13"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="13"></td>
+ <td class="gr-diff left lineNum" data-value="13">
+ <button
+ aria-label="13 removed"
+ class="gr-diff left lineNumButton"
+ data-value="13"
+ id="left-button-13"
+ tabindex="-1"
+ >
+ 13
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-13"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+ <tr
+ aria-labelledby="left-button-14 left-content-14 right-button-13 right-content-13"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="14"></td>
+ <td class="gr-diff left lineNum" data-value="14">
+ <button
+ aria-label="14 removed"
+ class="gr-diff left lineNumButton"
+ data-value="14"
+ id="left-button-14"
+ tabindex="-1"
+ >
+ 14
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-14"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="13">
+ <button
+ aria-label="13 added"
+ class="gr-diff lineNumButton right"
+ data-value="13"
+ id="right-button-13"
+ tabindex="-1"
+ >
+ 13
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-13"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-15 left-content-15 right-button-14 right-content-14"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="15"></td>
+ <td class="gr-diff left lineNum" data-value="15">
+ <button
+ aria-label="15 removed"
+ class="gr-diff left lineNumButton"
+ data-value="15"
+ id="left-button-15"
+ tabindex="-1"
+ >
+ 15
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-15"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="14">
+ <button
+ aria-label="14 added"
+ class="gr-diff lineNumButton right"
+ data-value="14"
+ id="right-button-14"
+ tabindex="-1"
+ >
+ 14
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-14"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section">
+ <tr
+ aria-labelledby="left-button-16 left-content-16 right-button-15 right-content-15"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="16"></td>
+ <td class="gr-diff left lineNum" data-value="16">
+ <button
+ aria-label="16 removed"
+ class="gr-diff left lineNumButton"
+ data-value="16"
+ id="left-button-16"
+ tabindex="-1"
+ >
+ 16
+ </button>
+ </td>
+ <td class="gr-diff left remove sign">-</td>
+ <td class="content gr-diff left remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-16"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="15">
+ <button
+ aria-label="15 added"
+ class="gr-diff lineNumButton right"
+ data-value="15"
+ id="right-button-15"
+ tabindex="-1"
+ >
+ 15
+ </button>
+ </td>
+ <td class="add gr-diff right sign">+</td>
+ <td class="add content gr-diff right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-15"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-17 left-content-17 right-button-16 right-content-16"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="17"></td>
+ <td class="gr-diff left lineNum" data-value="17">
+ <button
+ aria-label="17 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="17"
+ id="left-button-17"
+ tabindex="-1"
+ >
+ 17
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-17"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="16">
+ <button
+ aria-label="16 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="16"
+ id="right-button-16"
+ tabindex="-1"
+ >
+ 16
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-16"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-18 left-content-18 right-button-17 right-content-17"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="18"></td>
+ <td class="gr-diff left lineNum" data-value="18">
+ <button
+ aria-label="18 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="18"
+ id="left-button-18"
+ tabindex="-1"
+ >
+ 18
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-18"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="17">
+ <button
+ aria-label="17 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="17"
+ id="right-button-17"
+ tabindex="-1"
+ >
+ 17
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-17"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-19 left-content-19 right-button-18 right-content-18"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="19"></td>
+ <td class="gr-diff left lineNum" data-value="19">
+ <button
+ aria-label="19 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="19"
+ id="left-button-19"
+ tabindex="-1"
+ >
+ 19
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-19"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="18">
+ <button
+ aria-label="18 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="18"
+ id="right-button-18"
+ tabindex="-1"
+ >
+ 18
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-18"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="contextControl gr-diff section">
+ <tr
+ class="above contextBackground gr-diff side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ <tr class="dividerRow gr-diff show-both">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff"></td>
+ <td class="dividerCell gr-diff" colspan="3">
+ <gr-context-controls
+ class="gr-diff"
+ showconfig="both"
+ ></gr-context-controls>
+ </td>
+ </tr>
+ <tr
+ class="below contextBackground gr-diff side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-38 left-content-38 right-button-37 right-content-37"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="38"></td>
+ <td class="gr-diff left lineNum" data-value="38">
+ <button
+ aria-label="38 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="38"
+ id="left-button-38"
+ tabindex="-1"
+ >
+ 38
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-38"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="37">
+ <button
+ aria-label="37 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="37"
+ id="right-button-37"
+ tabindex="-1"
+ >
+ 37
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-37"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-39 left-content-39 right-button-38 right-content-38"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="39"></td>
+ <td class="gr-diff left lineNum" data-value="39">
+ <button
+ aria-label="39 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="39"
+ id="left-button-39"
+ tabindex="-1"
+ >
+ 39
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-39"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="38">
+ <button
+ aria-label="38 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="38"
+ id="right-button-38"
+ tabindex="-1"
+ >
+ 38
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-38"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-40 left-content-40 right-button-39 right-content-39"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="40"></td>
+ <td class="gr-diff left lineNum" data-value="40">
+ <button
+ aria-label="40 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="40"
+ id="left-button-40"
+ tabindex="-1"
+ >
+ 40
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-40"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="39">
+ <button
+ aria-label="39 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="39"
+ id="right-button-39"
+ tabindex="-1"
+ >
+ 39
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-39"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="right-button-40 right-content-40"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="40">
+ <button
+ aria-label="40 added"
+ class="gr-diff lineNumButton right"
+ data-value="40"
+ id="right-button-40"
+ tabindex="-1"
+ >
+ 40
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-40"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-41 right-content-41"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="41">
+ <button
+ aria-label="41 added"
+ class="gr-diff lineNumButton right"
+ data-value="41"
+ id="right-button-41"
+ tabindex="-1"
+ >
+ 41
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-41"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-42 right-content-42"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="42">
+ <button
+ aria-label="42 added"
+ class="gr-diff lineNumButton right"
+ data-value="42"
+ id="right-button-42"
+ tabindex="-1"
+ >
+ 42
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-42"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-43 right-content-43"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="43">
+ <button
+ aria-label="43 added"
+ class="gr-diff lineNumButton right"
+ data-value="43"
+ id="right-button-43"
+ tabindex="-1"
+ >
+ 43
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-43"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-41 left-content-41 right-button-44 right-content-44"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="41"></td>
+ <td class="gr-diff left lineNum" data-value="41">
+ <button
+ aria-label="41 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="41"
+ id="left-button-41"
+ tabindex="-1"
+ >
+ 41
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-41"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="44">
+ <button
+ aria-label="44 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="44"
+ id="right-button-44"
+ tabindex="-1"
+ >
+ 44
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-44"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-42 left-content-42 right-button-45 right-content-45"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="42"></td>
+ <td class="gr-diff left lineNum" data-value="42">
+ <button
+ aria-label="42 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="42"
+ id="left-button-42"
+ tabindex="-1"
+ >
+ 42
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-42"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="45">
+ <button
+ aria-label="45 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="45"
+ id="right-button-45"
+ tabindex="-1"
+ >
+ 45
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-45"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-43 left-content-43 right-button-46 right-content-46"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="43"></td>
+ <td class="gr-diff left lineNum" data-value="43">
+ <button
+ aria-label="43 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="43"
+ id="left-button-43"
+ tabindex="-1"
+ >
+ 43
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-43"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="46">
+ <button
+ aria-label="46 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="46"
+ id="right-button-46"
+ tabindex="-1"
+ >
+ 46
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-46"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-44 left-content-44 right-button-47 right-content-47"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="44"></td>
+ <td class="gr-diff left lineNum" data-value="44">
+ <button
+ aria-label="44 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="44"
+ id="left-button-44"
+ tabindex="-1"
+ >
+ 44
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-44"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="47">
+ <button
+ aria-label="47 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="47"
+ id="right-button-47"
+ tabindex="-1"
+ >
+ 47
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-47"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-45 left-content-45 right-button-48 right-content-48"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="45"></td>
+ <td class="gr-diff left lineNum" data-value="45">
+ <button
+ aria-label="45 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="45"
+ id="left-button-45"
+ tabindex="-1"
+ >
+ 45
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-45"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="48">
+ <button
+ aria-label="48 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="48"
+ id="right-button-48"
+ tabindex="-1"
+ >
+ 48
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-48"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ `,
+ {
+ ignoreTags: [
+ 'gr-context-controls-section',
+ 'gr-diff-section',
+ 'gr-diff-row',
+ 'gr-diff-text',
+ 'gr-legacy-text',
+ 'slot',
+ ],
+ }
+ );
+ });
+ });
+
+ suite('selectionchange event handling', () => {
+ let handleSelectionChangeStub: sinon.SinonSpy;
+
+ const emulateSelection = function () {
+ document.dispatchEvent(new CustomEvent('selectionchange'));
+ };
+
+ setup(async () => {
+ handleSelectionChangeStub = sinon.spy(
+ element.highlights,
+ 'handleSelectionChange'
+ );
+ });
+
+ test('enabled if logged in', async () => {
+ element.loggedIn = true;
+ await element.updateComplete;
+ emulateSelection();
+ assert.isTrue(handleSelectionChangeStub.called);
+ });
+
+ test('ignored if logged out', async () => {
+ element.loggedIn = false;
+ await element.updateComplete;
+ emulateSelection();
+ assert.isFalse(handleSelectionChangeStub.called);
+ });
+ });
+
+ test('cancel', () => {
+ const cleanupStub = sinon.stub(element.diffBuilder, 'cleanup');
+ element.cancel();
+ assert.isTrue(cleanupStub.calledOnce);
+ });
+
+ test('line limit with line_wrapping', async () => {
+ element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+ await element.updateComplete;
+ assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
+ });
+
+ test('line limit without line_wrapping', async () => {
+ element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+ await element.updateComplete;
+ assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
+ });
+
+ suite('FULL_RESPONSIVE mode', () => {
+ setup(async () => {
+ element.prefs = {...MINIMAL_PREFS};
+ element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+ await element.updateComplete;
+ });
+
+ test('line limit is based on line_length', async () => {
+ element.prefs = {...element.prefs!, line_length: 100};
+ await element.updateComplete;
+ assert.equal(
+ getComputedStyleValue('--line-limit-marker', element),
+ '100ch'
+ );
+ });
+
+ test('content-width should not be defined', () => {
+ assert.equal(getComputedStyleValue('--content-width', element), 'none');
+ });
+ });
+
+ suite('SHRINK_ONLY mode', () => {
+ setup(async () => {
+ element.prefs = {...MINIMAL_PREFS};
+ element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+ await element.updateComplete;
+ });
+
+ test('content-width should not be defined', () => {
+ assert.equal(getComputedStyleValue('--content-width', element), 'none');
+ });
+
+ test('max-width considers two content columns in side-by-side', async () => {
+ element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+ await element.updateComplete;
+ assert.equal(
+ getComputedStyleValue('--diff-max-width', element),
+ 'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+ );
+ });
+
+ test('max-width considers one content column in unified', async () => {
+ element.viewMode = DiffViewMode.UNIFIED;
+ await element.updateComplete;
+ assert.equal(
+ getComputedStyleValue('--diff-max-width', element),
+ 'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+ );
+ });
+
+ test('max-width considers font-size', async () => {
+ element.prefs = {...element.prefs!, font_size: 13};
+ await element.updateComplete;
+ // Each line number column: 4 * 13 = 52px
+ assert.equal(
+ getComputedStyleValue('--diff-max-width', element),
+ 'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)'
+ );
+ });
+
+ test('sign cols are considered if show_sign_col is true', async () => {
+ element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
+ await element.updateComplete;
+ assert.equal(
+ getComputedStyleValue('--diff-max-width', element),
+ 'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)'
+ );
+ });
+ });
+
+ suite('not logged in', () => {
+ setup(async () => {
+ element.loggedIn = false;
+ await element.updateComplete;
+ });
+
+ test('toggleLeftDiff', () => {
+ element.toggleLeftDiff();
+ assert.isTrue(element.classList.contains('no-left'));
+ element.toggleLeftDiff();
+ assert.isFalse(element.classList.contains('no-left'));
+ });
+
+ suite('binary diffs', () => {
+ test('render binary diff', async () => {
+ element.prefs = {
+ ...MINIMAL_PREFS,
+ };
+ element.diff = {
+ meta_a: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+ meta_b: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+ change_type: 'MODIFIED',
+ intraline_status: 'OK',
+ diff_header: [],
+ content: [],
+ binary: true,
+ };
+ await waitForEventOnce(element, 'render');
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="diffContainer sideBySide">
+ <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
+ <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
+ <table class="selected-right" id="diffTable">
+ <colgroup>
+ <col class="blame gr-diff" />
+ <col class="gr-diff left" width="48" />
+ <col class="gr-diff left sign" />
+ <col class="gr-diff left" />
+ <col class="gr-diff right" width="48" />
+ <col class="gr-diff right sign" />
+ <col class="gr-diff right" />
+ </colgroup>
+ <tbody class="binary-diff gr-diff"></tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="FILE"></td>
+ <td class="gr-diff left lineNum" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff left lineNumButton"
+ data-value="FILE"
+ id="left-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td
+ class="both content file gr-diff left no-intraline-info"
+ >
+ <div class="thread-group" data-side="left">
+ <slot name="left-FILE"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff lineNumButton right"
+ data-value="FILE"
+ id="right-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td
+ class="both content file gr-diff no-intraline-info right"
+ >
+ <div class="thread-group" data-side="right">
+ <slot name="right-FILE"> </slot>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="binary-diff gr-diff">
+ <tr class="gr-diff">
+ <td class="gr-diff" colspan="5">
+ <span> Difference in binary files </span>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ `
+ );
+ });
+ });
+
+ suite('image diffs', () => {
+ let mockFile1: ImageInfo;
+ let mockFile2: ImageInfo;
+ setup(() => {
+ mockFile1 = {
+ body:
+ 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+ 'wsAAAAAAAAAAAAAAAAA/w==',
+ type: 'image/bmp',
+ };
+ mockFile2 = {
+ body:
+ 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+ 'wsAAAAAAAAAAAAA/////w==',
+ type: 'image/bmp',
+ };
+
+ element.isImageDiff = true;
+ element.prefs = {
+ context: 10,
+ cursor_blink_rate: 0,
+ font_size: 12,
+ ignore_whitespace: 'IGNORE_NONE',
+ line_length: 100,
+ line_wrapping: false,
+ show_line_endings: true,
+ show_tabs: true,
+ show_whitespace_errors: true,
+ syntax_highlighting: true,
+ tab_size: 8,
+ };
+ });
+
+ test('render image diff', async () => {
+ element.baseImage = mockFile1;
+ element.revisionImage = mockFile2;
+ element.diff = {
+ meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+ meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+ intraline_status: 'OK',
+ change_type: 'MODIFIED',
+ diff_header: [
+ 'diff --git a/carrot.jpg b/carrot.jpg',
+ 'index 2adc47d..f9c2f2c 100644',
+ '--- a/carrot.jpg',
+ '+++ b/carrot.jpg',
+ 'Binary files differ',
+ ],
+ content: [{skip: 66}],
+ binary: true,
+ };
+
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ assert.lightDom.equal(
+ imageDiffSection,
+ /* HTML */ `
+ <tbody class="gr-diff image-diff">
+ <tr class="gr-diff">
+ <td class="blank gr-diff left lineNum"></td>
+ <td class="gr-diff left">
+ <img
+ class="gr-diff left"
+ src="data:image/bmp;base64,${mockFile1.body}"
+ />
+ </td>
+ <td class="blank gr-diff lineNum right"></td>
+ <td class="gr-diff right">
+ <img
+ class="gr-diff right"
+ src="data:image/bmp;base64,${mockFile2.body}"
+ />
+ </td>
+ </tr>
+ <tr class="gr-diff">
+ <td class="blank gr-diff left lineNum"></td>
+ <td class="gr-diff left">
+ <label class="gr-diff">
+ <span class="gr-diff label"> image/bmp </span>
+ </label>
+ </td>
+ <td class="blank gr-diff lineNum right"></td>
+ <td class="gr-diff right">
+ <label class="gr-diff">
+ <span class="gr-diff label"> image/bmp </span>
+ </label>
+ </td>
+ </tr>
+ </tbody>
+ `
+ );
+ const endpoint = queryAndAssert(element, 'tbody.endpoint');
+ assert.dom.equal(
+ endpoint,
+ /* HTML */ `
+ <tbody class="gr-diff endpoint">
+ <tr class="gr-diff">
+ <gr-endpoint-decorator class="gr-diff" name="image-diff">
+ <gr-endpoint-param class="gr-diff" name="baseImage">
+ </gr-endpoint-param>
+ <gr-endpoint-param class="gr-diff" name="revisionImage">
+ </gr-endpoint-param>
+ </gr-endpoint-decorator>
+ </tr>
+ </tbody>
+ `
+ );
+ });
+
+ test('renders image diffs with a different file name', async () => {
+ const mockDiff: DiffInfo = {
+ meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+ meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
+ intraline_status: 'OK',
+ change_type: 'MODIFIED',
+ diff_header: [
+ 'diff --git a/carrot.jpg b/carrot2.jpg',
+ 'index 2adc47d..f9c2f2c 100644',
+ '--- a/carrot.jpg',
+ '+++ b/carrot2.jpg',
+ 'Binary files differ',
+ ],
+ content: [{skip: 66}],
+ binary: true,
+ };
+
+ element.baseImage = mockFile1;
+ element.baseImage._name = mockDiff.meta_a!.name;
+ element.revisionImage = mockFile2;
+ element.revisionImage._name = mockDiff.meta_b!.name;
+ element.diff = mockDiff;
+
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ const leftLabel = queryAndAssert(imageDiffSection, 'td.left label');
+ const rightLabel = queryAndAssert(imageDiffSection, 'td.right label');
+ assert.dom.equal(
+ leftLabel,
+ /* HTML */ `
+ <label class="gr-diff">
+ <span class="gr-diff name"> carrot.jpg </span>
+ <br class="gr-diff" />
+ <span class="gr-diff label"> image/bmp </span>
+ </label>
+ `
+ );
+ assert.dom.equal(
+ rightLabel,
+ /* HTML */ `
+ <label class="gr-diff">
+ <span class="gr-diff name"> carrot2.jpg </span>
+ <br class="gr-diff" />
+ <span class="gr-diff label"> image/bmp </span>
+ </label>
+ `
+ );
+ });
+
+ test('renders added image', async () => {
+ const mockDiff: DiffInfo = {
+ meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+ intraline_status: 'OK',
+ change_type: 'ADDED',
+ diff_header: [
+ 'diff --git a/carrot.jpg b/carrot.jpg',
+ 'index 0000000..f9c2f2c 100644',
+ '--- /dev/null',
+ '+++ b/carrot.jpg',
+ 'Binary files differ',
+ ],
+ content: [{skip: 66}],
+ binary: true,
+ };
+ element.revisionImage = mockFile2;
+ element.diff = mockDiff;
+
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ const leftImage = query(imageDiffSection, 'td.left img');
+ const rightImage = queryAndAssert(imageDiffSection, 'td.right img');
+ assert.isNotOk(leftImage);
+ assert.dom.equal(
+ rightImage,
+ /* HTML */ `
+ <img
+ class="gr-diff right"
+ src="data:image/bmp;base64,${mockFile2.body}"
+ />
+ `
+ );
+ });
+
+ test('renders removed image', async () => {
+ const mockDiff: DiffInfo = {
+ meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+ intraline_status: 'OK',
+ change_type: 'DELETED',
+ diff_header: [
+ 'diff --git a/carrot.jpg b/carrot.jpg',
+ 'index f9c2f2c..0000000 100644',
+ '--- a/carrot.jpg',
+ '+++ /dev/null',
+ 'Binary files differ',
+ ],
+ content: [{skip: 66}],
+ binary: true,
+ };
+ element.baseImage = mockFile1;
+ element.diff = mockDiff;
+
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ const leftImage = queryAndAssert(imageDiffSection, 'td.left img');
+ const rightImage = query(imageDiffSection, 'td.right img');
+ assert.isNotOk(rightImage);
+ assert.dom.equal(
+ leftImage,
+ /* HTML */ `
+ <img
+ class="gr-diff left"
+ src="data:image/bmp;base64,${mockFile1.body}"
+ />
+ `
+ );
+ });
+
+ test('does not render disallowed image type', async () => {
+ const mockDiff: DiffInfo = {
+ meta_a: {
+ name: 'carrot.jpg',
+ content_type: 'image/jpeg-evil',
+ lines: 560,
+ },
+ intraline_status: 'OK',
+ change_type: 'DELETED',
+ diff_header: [
+ 'diff --git a/carrot.jpg b/carrot.jpg',
+ 'index f9c2f2c..0000000 100644',
+ '--- a/carrot.jpg',
+ '+++ /dev/null',
+ 'Binary files differ',
+ ],
+ content: [{skip: 66}],
+ binary: true,
+ };
+ mockFile1.type = 'image/jpeg-evil';
+ element.baseImage = mockFile1;
+ element.diff = mockDiff;
+
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ const leftImage = query(imageDiffSection, 'td.left img');
+ assert.isNotOk(leftImage);
+ });
+ });
+
+ test('handleTap lineNum', async () => {
+ const addDraftStub = sinon.stub(element, 'addDraftAtLine');
+ const el = document.createElement('div');
+ el.className = 'lineNum';
+ const promise = mockPromise();
+ el.addEventListener('click', e => {
+ element.handleTap(e);
+ assert.isTrue(addDraftStub.called);
+ assert.equal(addDraftStub.lastCall.args[0], el);
+ promise.resolve();
+ });
+ el.click();
+ await promise;
+ });
+
+ test('handleTap content', async () => {
+ const content = document.createElement('div');
+ const lineEl = document.createElement('div');
+ lineEl.className = 'lineNum';
+ const row = document.createElement('div');
+ row.appendChild(lineEl);
+ row.appendChild(content);
+
+ const selectStub = sinon.stub(element, 'selectLine');
+
+ content.className = 'content';
+ const promise = mockPromise();
+ content.addEventListener('click', e => {
+ element.handleTap(e);
+ assert.isTrue(selectStub.called);
+ assert.equal(selectStub.lastCall.args[0], lineEl);
+ promise.resolve();
+ });
+ content.click();
+ await promise;
+ });
+
+ suite('getCursorStops', () => {
+ async function setupDiff() {
+ element.diff = createDiff();
+ element.prefs = {
+ context: 10,
+ tab_size: 8,
+ font_size: 12,
+ line_length: 100,
+ cursor_blink_rate: 0,
+ line_wrapping: false,
+
+ show_line_endings: true,
+ show_tabs: true,
+ show_whitespace_errors: true,
+ syntax_highlighting: true,
+ ignore_whitespace: 'IGNORE_NONE',
+ };
+ await element.updateComplete;
+ element.renderDiffTable();
+ }
+
+ test('returns [] when hidden and noAutoRender', async () => {
+ element.noAutoRender = true;
+ await setupDiff();
+ element.loading = false;
+ await element.updateComplete;
+ element.hidden = true;
+ await element.updateComplete;
+ assert.equal(element.getCursorStops().length, 0);
+ });
+
+ test('returns one stop per line and one for the file row', async () => {
+ await setupDiff();
+ element.loading = false;
+ await element.updateComplete;
+ const ROWS = 48;
+ const FILE_ROW = 1;
+ const LOST_ROW = 1;
+ assert.equal(
+ element.getCursorStops().length,
+ ROWS + FILE_ROW + LOST_ROW
+ );
+ });
+
+ test('returns an additional AbortStop when still loading', async () => {
+ await setupDiff();
+ element.loading = true;
+ await element.updateComplete;
+ const ROWS = 48;
+ const FILE_ROW = 1;
+ const LOST_ROW = 1;
+ const actual = element.getCursorStops();
+ assert.equal(actual.length, ROWS + FILE_ROW + LOST_ROW + 1);
+ assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
+ });
+ });
+ });
+
+ suite('logged in', async () => {
+ let fakeLineEl: HTMLElement;
+ setup(async () => {
+ element.loggedIn = true;
+
+ fakeLineEl = {
+ getAttribute: sinon.stub().returns(42),
+ classList: {
+ contains: sinon.stub().returns(true),
+ },
+ } as unknown as HTMLElement;
+ await element.updateComplete;
+ });
+
+ test('addDraftAtLine', () => {
+ sinon.stub(element, 'selectLine');
+ const createCommentStub = sinon.stub(element, 'createComment');
+ element.addDraftAtLine(fakeLineEl);
+ assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
+ });
+
+ test('adds long range comment hint', async () => {
+ const range = {
+ start_line: 1,
+ end_line: 12,
+ start_character: 0,
+ end_character: 0,
+ };
+ const threadEl = document.createElement('div');
+ threadEl.className = 'comment-thread';
+ threadEl.setAttribute('diff-side', 'right');
+ threadEl.setAttribute('line-num', '1');
+ threadEl.setAttribute('range', JSON.stringify(range));
+ threadEl.setAttribute('slot', 'right-1');
+ const content = [
+ {
+ a: ['asdf'],
+ },
+ {
+ ab: Array(13).fill('text'),
+ },
+ ];
+ await setupSampleDiff({content});
+
+ element.appendChild(threadEl);
+
+ const hint = await waitQueryAndAssert<GrRangedCommentHint>(
+ element,
+ 'gr-ranged-comment-hint'
+ );
+ assert.deepEqual(hint.range, range);
+ });
+
+ test('no duplicate range hint for same thread', async () => {
+ const range = {
+ start_line: 1,
+ end_line: 12,
+ start_character: 0,
+ end_character: 0,
+ };
+ const threadEl = document.createElement('div');
+ threadEl.className = 'comment-thread';
+ threadEl.setAttribute('diff-side', 'right');
+ threadEl.setAttribute('line-num', '1');
+ threadEl.setAttribute('range', JSON.stringify(range));
+ threadEl.setAttribute('slot', 'right-1');
+ const firstHint = document.createElement('gr-ranged-comment-hint');
+ firstHint.range = range;
+ firstHint.setAttribute('slot', 'right-1');
+ const content = [
+ {
+ a: ['asdf'],
+ },
+ {
+ ab: Array(13).fill('text'),
+ },
+ ];
+ await setupSampleDiff({content});
+
+ element.appendChild(firstHint);
+ element.appendChild(threadEl);
+
+ assert.equal(
+ element.querySelectorAll('gr-ranged-comment-hint').length,
+ 1
+ );
+ });
+
+ test('removes long range comment hint when comment is discarded', async () => {
+ const range = {
+ start_line: 1,
+ end_line: 7,
+ start_character: 0,
+ end_character: 0,
+ };
+ const threadEl = document.createElement('div');
+ threadEl.className = 'comment-thread';
+ threadEl.setAttribute('diff-side', 'right');
+ threadEl.setAttribute('line-num', '1');
+ threadEl.setAttribute('range', JSON.stringify(range));
+ threadEl.setAttribute('slot', 'right-1');
+ const content = [
+ {
+ ab: Array(8).fill('text'),
+ },
+ ];
+ await setupSampleDiff({content});
+
+ element.appendChild(threadEl);
+ await waitUntil(() => element.commentRanges.length === 1);
+
+ threadEl.remove();
+ await waitUntil(() => element.commentRanges.length === 0);
+
+ assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
+ });
+
+ suite('change in preferences', () => {
+ setup(async () => {
+ element.diff = {
+ meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+ meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+ diff_header: [],
+ intraline_status: 'OK',
+ change_type: 'MODIFIED',
+ content: [{skip: 66}],
+ };
+ await element.updateComplete;
+ await element.renderDiffTableTask?.flush();
+ });
+
+ test('change in preferences re-renders diff', async () => {
+ const stub = sinon.stub(element, 'renderDiffTable');
+ element.prefs = {
+ ...MINIMAL_PREFS,
+ };
+ await element.updateComplete;
+ await element.renderDiffTableTask?.flush();
+ assert.isTrue(stub.called);
+ });
+
+ test('adding/removing property in preferences re-renders diff', async () => {
+ const stub = sinon.stub(element, 'renderDiffTable');
+ const newPrefs1: DiffPreferencesInfo = {
+ ...MINIMAL_PREFS,
+ line_wrapping: true,
+ };
+ element.prefs = newPrefs1;
+ await element.updateComplete;
+ await element.renderDiffTableTask?.flush();
+ assert.isTrue(stub.called);
+ stub.reset();
+
+ const newPrefs2 = {...newPrefs1};
+ delete newPrefs2.line_wrapping;
+ element.prefs = newPrefs2;
+ await element.updateComplete;
+ await element.renderDiffTableTask?.flush();
+ assert.isTrue(stub.called);
+ });
+
+ test(
+ 'change in preferences does not re-renders diff with ' +
+ 'noRenderOnPrefsChange',
+ async () => {
+ const stub = sinon.stub(element, 'renderDiffTable');
+ element.noRenderOnPrefsChange = true;
+ element.prefs = {
+ ...MINIMAL_PREFS,
+ context: 12,
+ };
+ await element.updateComplete;
+ await element.renderDiffTableTask?.flush();
+ assert.isFalse(stub.called);
+ }
+ );
+ });
+ });
+
+ suite('diff header', () => {
+ setup(async () => {
+ element.diff = {
+ meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+ meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+ diff_header: [],
+ intraline_status: 'OK',
+ change_type: 'MODIFIED',
+ content: [{skip: 66}],
+ };
+ await element.updateComplete;
+ });
+
+ test('hidden', async () => {
+ assert.equal(element.computeDiffHeaderItems().length, 0);
+ element.diff?.diff_header?.push('diff --git a/test.jpg b/test.jpg');
+ assert.equal(element.computeDiffHeaderItems().length, 0);
+ element.diff?.diff_header?.push('index 2adc47d..f9c2f2c 100644');
+ assert.equal(element.computeDiffHeaderItems().length, 0);
+ element.diff?.diff_header?.push('--- a/test.jpg');
+ assert.equal(element.computeDiffHeaderItems().length, 0);
+ element.diff?.diff_header?.push('+++ b/test.jpg');
+ assert.equal(element.computeDiffHeaderItems().length, 0);
+ element.diff?.diff_header?.push('test');
+ assert.equal(element.computeDiffHeaderItems().length, 1);
+ element.requestUpdate('diff');
+ await element.updateComplete;
+
+ const header = queryAndAssert(element, '#diffHeader');
+ assert.equal(header.textContent?.trim(), 'test');
+ });
+
+ test('binary files', () => {
+ element.diff!.binary = true;
+ assert.equal(element.computeDiffHeaderItems().length, 0);
+ element.diff?.diff_header?.push('diff --git a/test.jpg b/test.jpg');
+ assert.equal(element.computeDiffHeaderItems().length, 0);
+ element.diff?.diff_header?.push('test');
+ assert.equal(element.computeDiffHeaderItems().length, 1);
+ element.diff?.diff_header?.push('Binary files differ');
+ assert.equal(element.computeDiffHeaderItems().length, 1);
+ });
+ });
+
+ suite('safety and bypass', () => {
+ let renderStub: sinon.SinonStub;
+
+ setup(async () => {
+ renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+ assertIsDefined(element.diffTable);
+ const diffTable = element.diffTable;
+ diffTable.dispatchEvent(
+ new CustomEvent('render', {bubbles: true, composed: true})
+ );
+ return Promise.resolve();
+ });
+ sinon.stub(element, 'getDiffLength').returns(10000);
+ element.diff = createDiff();
+ element.noRenderOnPrefsChange = true;
+ await element.updateComplete;
+ });
+
+ test('large render w/ context = 10', async () => {
+ element.prefs = {...MINIMAL_PREFS, context: 10};
+ element.renderDiffTable();
+ await waitForEventOnce(element, 'render');
+
+ assert.isTrue(renderStub.called);
+ assert.isFalse(element.showWarning);
+ });
+
+ test('large render w/ whole file and bypass', async () => {
+ element.prefs = {...MINIMAL_PREFS, context: -1};
+ element.safetyBypass = 10;
+ element.renderDiffTable();
+ await waitForEventOnce(element, 'render');
+
+ assert.isTrue(renderStub.called);
+ assert.isFalse(element.showWarning);
+ });
+
+ test('large render w/ whole file and no bypass', async () => {
+ element.prefs = {...MINIMAL_PREFS, context: -1};
+ element.renderDiffTable();
+ await waitForEventOnce(element, 'render');
+
+ assert.isFalse(renderStub.called);
+ assert.isTrue(element.showWarning);
+ });
+
+ test('toggles expand context using bypass', async () => {
+ element.prefs = {...MINIMAL_PREFS, context: 3};
+
+ element.toggleAllContext();
+ element.renderDiffTable();
+ await element.updateComplete;
+
+ assert.equal(element.prefs.context, 3);
+ assert.equal(element.safetyBypass, -1);
+ assert.equal(element.diffBuilder.prefs.context, -1);
+ });
+
+ test('toggles collapse context from bypass', async () => {
+ element.prefs = {...MINIMAL_PREFS, context: 3};
+ element.safetyBypass = -1;
+
+ element.toggleAllContext();
+ element.renderDiffTable();
+ await element.updateComplete;
+
+ assert.equal(element.prefs.context, 3);
+ assert.isNull(element.safetyBypass);
+ assert.equal(element.diffBuilder.prefs.context, 3);
+ });
+
+ test('toggles collapse context from pref using default', async () => {
+ element.prefs = {...MINIMAL_PREFS, context: -1};
+
+ element.toggleAllContext();
+ element.renderDiffTable();
+ await element.updateComplete;
+
+ assert.equal(element.prefs.context, -1);
+ assert.equal(element.safetyBypass, 10);
+ assert.equal(element.diffBuilder.prefs.context, 10);
+ });
+ });
+
+ suite('blame', () => {
+ test('unsetting', async () => {
+ element.blame = [];
+ const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+ element.classList.add('showBlame');
+ element.blame = null;
+ await element.updateComplete;
+ assert.isTrue(setBlameSpy.calledWithExactly(null));
+ assert.isFalse(element.classList.contains('showBlame'));
+ });
+
+ test('setting', async () => {
+ element.blame = [
+ {
+ author: 'test-author',
+ time: 12345,
+ commit_msg: '',
+ id: 'commit id',
+ ranges: [{start: 1, end: 2}],
+ },
+ ];
+ await element.updateComplete;
+ assert.isTrue(element.classList.contains('showBlame'));
+ });
+ });
+
+ suite('trailing newline warnings', () => {
+ const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+ const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+ const getWarning = (element: GrDiff) => {
+ const warningElement = query(element, '.newlineWarning');
+ return warningElement?.textContent ?? '';
+ };
+
+ setup(async () => {
+ element.showNewlineWarningLeft = false;
+ element.showNewlineWarningRight = false;
+ await element.updateComplete;
+ });
+
+ test('shows combined warning if both sides set to warn', async () => {
+ element.showNewlineWarningLeft = true;
+ element.showNewlineWarningRight = true;
+ await element.updateComplete;
+ assert.include(
+ getWarning(element),
+ NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+ ); // \u2014 - '—'
+ });
+
+ suite('showNewlineWarningLeft', () => {
+ test('show warning if true', async () => {
+ element.showNewlineWarningLeft = true;
+ await element.updateComplete;
+ assert.include(getWarning(element), NO_NEWLINE_LEFT);
+ });
+
+ test('hide warning if false', async () => {
+ element.showNewlineWarningLeft = false;
+ await element.updateComplete;
+ assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
+ });
+ });
+
+ suite('showNewlineWarningRight', () => {
+ test('show warning if true', async () => {
+ element.showNewlineWarningRight = true;
+ await element.updateComplete;
+ assert.include(getWarning(element), NO_NEWLINE_RIGHT);
+ });
+
+ test('hide warning if false', async () => {
+ element.showNewlineWarningRight = false;
+ await element.updateComplete;
+ assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
+ });
+ });
+ });
+
+ suite('key locations', () => {
+ let renderStub: sinon.SinonStub;
+
+ setup(async () => {
+ element.prefs = {...MINIMAL_PREFS};
+ element.diff = createDiff();
+ renderStub = sinon.stub(element.diffBuilder, 'render');
+ await element.updateComplete;
+ });
+
+ test('lineOfInterest is a key location', () => {
+ element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
+ element.renderDiffTable();
+ assert.isTrue(renderStub.called);
+ assert.deepEqual(renderStub.lastCall.args[0], {
+ left: {789: true},
+ right: {},
+ });
+ });
+
+ test('line comments are key locations', async () => {
+ const threadEl = document.createElement('div');
+ threadEl.className = 'comment-thread';
+ threadEl.setAttribute('diff-side', 'right');
+ threadEl.setAttribute('line-num', '3');
+ element.appendChild(threadEl);
+ await element.updateComplete;
+
+ element.renderDiffTable();
+ assert.isTrue(renderStub.called);
+ assert.deepEqual(renderStub.lastCall.args[0], {
+ left: {},
+ right: {3: true},
+ });
+ });
+
+ test('file comments are key locations', async () => {
+ const threadEl = document.createElement('div');
+ threadEl.className = 'comment-thread';
+ threadEl.setAttribute('diff-side', 'left');
+ element.appendChild(threadEl);
+ await element.updateComplete;
+
+ element.renderDiffTable();
+ assert.isTrue(renderStub.called);
+ assert.deepEqual(renderStub.lastCall.args[0], {
+ left: {FILE: true},
+ right: {},
+ });
+ });
+ });
+ const setupSampleDiff = async function (params: {
+ content: DiffContent[];
+ ignore_whitespace?: IgnoreWhitespaceType;
+ binary?: boolean;
+ }) {
+ const {ignore_whitespace, content} = params;
+ // binary can't be undefined, use false if not set
+ const binary = params.binary || false;
+ element.prefs = {
+ ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
+ context: 10,
+ cursor_blink_rate: 0,
+ font_size: 12,
+
+ line_length: 100,
+ line_wrapping: false,
+ show_line_endings: true,
+ show_tabs: true,
+ show_whitespace_errors: true,
+ syntax_highlighting: true,
+ tab_size: 8,
+ };
+ element.diff = {
+ intraline_status: 'OK',
+ change_type: 'MODIFIED',
+ diff_header: [
+ 'diff --git a/carrot.js b/carrot.js',
+ 'index 2adc47d..f9c2f2c 100644',
+ '--- a/carrot.js',
+ '+++ b/carrot.jjs',
+ 'file differ',
+ ],
+ content,
+ binary,
+ };
+ await element.updateComplete;
+ await element.renderDiffTableTask;
+ };
+
+ test('clear diff table content as soon as diff changes', async () => {
+ const content = [
+ {
+ a: ['all work and no play make andybons a dull boy'],
+ },
+ {
+ b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
+ },
+ ];
+ function diffTableHasContent() {
+ assertIsDefined(element.diffTable);
+ const diffTable = element.diffTable;
+ return diffTable.innerText.includes(content[0].a?.[0] ?? '');
+ }
+ await setupSampleDiff({content});
+ await waitUntil(diffTableHasContent);
+ element.diff = {...element.diff!};
+ await element.updateComplete;
+ // immediately cleaned up
+ assertIsDefined(element.diffTable);
+ const diffTable = element.diffTable;
+ assert.equal(diffTable.innerHTML, '');
+ element.renderDiffTable();
+ await element.updateComplete;
+ // rendered again
+ await waitUntil(diffTableHasContent);
+ });
+
+ suite('selection test', () => {
+ test('user-select set correctly on side-by-side view', async () => {
+ const content = [
+ {
+ a: ['all work and no play make andybons a dull boy'],
+ b: ['elgoog elgoog elgoog'],
+ },
+ {
+ ab: [
+ 'Non eram nescius, Brute, cum, quae summis ingeniis ',
+ 'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+ ],
+ },
+ ];
+ await setupSampleDiff({content});
+ await waitEventLoop();
+
+ const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+ assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+ mouseDown(diffLine);
+ assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+ });
+
+ test('user-select set correctly on unified view', async () => {
+ const content = [
+ {
+ a: ['all work and no play make andybons a dull boy'],
+ b: ['elgoog elgoog elgoog'],
+ },
+ {
+ ab: [
+ 'Non eram nescius, Brute, cum, quae summis ingeniis ',
+ 'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+ ],
+ },
+ ];
+ await setupSampleDiff({content});
+ element.viewMode = DiffViewMode.UNIFIED;
+ await element.updateComplete;
+ const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+ assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+ mouseDown(diffLine);
+ assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+ });
+ });
+
+ suite('whitespace changes only message', () => {
+ test('show the message if ignore_whitespace is criteria matches', async () => {
+ await setupSampleDiff({content: [{skip: 100}]});
+ element.loading = false;
+ assert.isTrue(element.showNoChangeMessage());
+ });
+
+ test('do not show the message for binary files', async () => {
+ await setupSampleDiff({content: [{skip: 100}], binary: true});
+ element.loading = false;
+ assert.isFalse(element.showNoChangeMessage());
+ });
+
+ test('do not show the message if still loading', async () => {
+ await setupSampleDiff({content: [{skip: 100}]});
+ element.loading = true;
+ assert.isFalse(element.showNoChangeMessage());
+ });
+
+ test('do not show the message if contains valid changes', async () => {
+ const content = [
+ {
+ a: ['all work and no play make andybons a dull boy'],
+ b: ['elgoog elgoog elgoog'],
+ },
+ {
+ ab: [
+ 'Non eram nescius, Brute, cum, quae summis ingeniis ',
+ 'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+ ],
+ },
+ ];
+ await setupSampleDiff({content});
+ element.loading = false;
+ assert.equal(element.diffLength, 3);
+ assert.isFalse(element.showNoChangeMessage());
+ });
+
+ test('do not show message if ignore whitespace is disabled', async () => {
+ const content = [
+ {
+ a: ['all work and no play make andybons a dull boy'],
+ b: ['elgoog elgoog elgoog'],
+ },
+ {
+ ab: [
+ 'Non eram nescius, Brute, cum, quae summis ingeniis ',
+ 'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+ ],
+ },
+ ];
+ await setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
+ element.loading = false;
+ assert.isFalse(element.showNoChangeMessage());
+ });
+ });
+
+ test('getDiffLength', () => {
+ const diff = createDiff();
+ assert.equal(element.getDiffLength(diff), 52);
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
index 79c40de..132751f 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -5,15 +5,14 @@
*/
import '../../../elements/shared/gr-button/gr-button';
import {html, LitElement} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {property, state} from 'lit/decorators.js';
import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {diffClasses, isNewDiff} from '../gr-diff/gr-diff-utils';
import {getShowConfig} from './gr-context-controls';
import {ifDefined} from 'lit/directives/if-defined.js';
import {when} from 'lit/directives/when.js';
-@customElement('gr-context-controls-section')
export class GrContextControlsSection extends LitElement {
/** Should context controls be rendered for expanding above the section? */
@property({type: Boolean}) showAbove = false;
@@ -125,8 +124,17 @@
}
}
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (!isNewDiff()) {
+ customElements.define(
+ 'gr-context-controls-section',
+ GrContextControlsSection
+ );
+}
+
declare global {
interface HTMLElementTagNameMap {
- 'gr-context-controls-section': GrContextControlsSection;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-context-controls-section': LitElement;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index 4a2fee5..2639a3d 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -21,7 +21,7 @@
import {DiffInfo} from '../../../types/diff';
import {assertIsDefined} from '../../../utils/common-util';
import {css, html, LitElement, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {property} from 'lit/decorators.js';
import {subscribe} from '../../../elements/lit/subscription-controller';
import {
@@ -32,6 +32,7 @@
} from '../../../api/diff';
import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+import {isNewDiff} from '../gr-diff/gr-diff-utils';
declare global {
interface HTMLElementEventMap {
@@ -82,7 +83,6 @@
return 'both';
}
-@customElement('gr-context-controls')
export class GrContextControls extends LitElement {
@property({type: Object}) renderPreferences?: RenderPreferences;
@@ -365,6 +365,11 @@
});
} else {
fire(this, 'diff-context-expanded', {
+ numLines: this.numLines(),
+ buttonType: type,
+ expandedLines: linesToExpand,
+ });
+ fire(this, 'diff-context-expanded-internal', {
contextGroup: this.group,
groups,
numLines: this.numLines(),
@@ -510,9 +515,14 @@
`;
}
}
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (!isNewDiff()) {
+ customElements.define('gr-context-controls', GrContextControls);
+}
declare global {
interface HTMLElementTagNameMap {
- 'gr-context-controls': GrContextControls;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-context-controls': LitElement;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
index 8e2f432..7f5827c 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
@@ -8,9 +8,14 @@
import './gr-context-controls';
import {GrContextControls} from './gr-context-controls';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
+import {
+ DiffFileMetaInfo,
+ DiffInfo,
+ GrDiffLineType,
+ SyntaxBlock,
+} from '../../../api/diff';
import {fixture, html, assert} from '@open-wc/testing';
import {waitEventLoop} from '../../../test/test-utils';
@@ -18,7 +23,10 @@
let element: GrContextControls;
setup(async () => {
- element = document.createElement('gr-context-controls');
+ // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
+ element = document.createElement(
+ 'gr-context-controls'
+ ) as GrContextControls;
element.diff = {content: []} as any as DiffInfo;
element.renderPreferences = {};
const div = await fixture(html`<div></div>`);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index cc45e1e..7ace605 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -8,6 +8,7 @@
import {createElementDiff} from '../gr-diff/gr-diff-utils';
import {GrDiffGroup} from '../gr-diff/gr-diff-group';
import {html, render} from 'lit';
+import {FILE} from '../../../api/diff';
export class GrDiffBuilderBinary extends GrDiffBuilder {
constructor(
@@ -20,8 +21,8 @@
override buildSectionElement(group: GrDiffGroup): HTMLElement {
const section = createElementDiff('tbody', 'binary-diff');
- // Do not create a diff row for 'LOST'.
- if (group.lines[0].beforeNumber !== 'FILE') return section;
+ // Do not create a diff row for LOST.
+ if (group.lines[0].beforeNumber !== FILE) return section;
return super.buildSectionElement(group);
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 396e9e2..d907011 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -14,7 +14,6 @@
} from './gr-diff-builder';
import {GrDiffBuilderImage} from './gr-diff-builder-image';
import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
import {BlameInfo, ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -28,9 +27,9 @@
GrRangedCommentLayer,
} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {DiffViewMode, LineNumber, RenderPreferences} from '../../../api/diff';
import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
-import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {
GrDiffGroup,
GrDiffGroupType,
@@ -130,13 +129,6 @@
// visible for testing
showTrailingWhitespace?: boolean;
- /**
- * The promise last returned from `render()` while the asynchronous
- * rendering is running - `null` otherwise. Provides a `cancel()`
- * method that rejects it with `{isCancelled: true}`.
- */
- private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
-
private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
@@ -144,7 +136,7 @@
private rangeLayer?: GrRangedCommentLayer;
// visible for testing
- processor = new GrDiffProcessor();
+ processor?: GrDiffProcessor;
/**
* Groups are mostly just passed on to the diff builder (this.builder). But
@@ -156,10 +148,6 @@
*/
private groups: GrDiffGroup[] = [];
- constructor() {
- this.processor.consumer = this;
- }
-
updateCommentRanges(ranges: CommentRangeLayer[]) {
this.rangeLayer?.updateRanges(ranges);
}
@@ -186,8 +174,16 @@
this.builder = this.getDiffBuilder();
this.init();
+ // TODO: Just pass along the diff model here instead of setting many
+ // individual properties.
+ this.processor = new GrDiffProcessor();
+ this.processor.consumer = this;
this.processor.context = this.prefs.context;
this.processor.keyLocations = keyLocations;
+ if (this.renderPrefs?.num_lines_rendered_at_once) {
+ this.processor.asyncThreshold =
+ this.renderPrefs.num_lines_rendered_at_once;
+ }
this.clearDiffContent();
this.builder.addColumns(
@@ -198,14 +194,9 @@
const isBinary = !!(this.isImageDiff || this.diff.binary);
fire(this.diffElement, 'render-start', {});
- // TODO: processor.process() returns a cancelable promise already.
- // Why wrap another one around it?
- this.cancelableRenderPromise = makeCancelable(
- this.processor.process(this.diff.content, isBinary)
- );
- // All then/catch/finally clauses must be outside of makeCancelable().
return (
- this.cancelableRenderPromise
+ this.processor
+ .process(this.diff.content, isBinary)
.then(async () => {
if (isImageDiffBuilder(this.builder)) {
this.builder.renderImageDiff();
@@ -222,9 +213,6 @@
if (!e.isCanceled) return Promise.reject(e);
return;
})
- .finally(() => {
- this.cancelableRenderPromise = null;
- })
);
}
@@ -362,7 +350,7 @@
init() {
this.cleanup();
this.diffElement?.addEventListener(
- 'diff-context-expanded',
+ 'diff-context-expanded-internal',
this.onDiffContextExpanded
);
this.builder?.init();
@@ -376,12 +364,10 @@
* gr-diff re-connects.
*/
cleanup() {
- this.processor.cancel();
+ this.processor?.cancel();
this.builder?.cleanup();
- this.cancelableRenderPromise?.cancel();
- this.cancelableRenderPromise = null;
this.diffElement?.removeEventListener(
- 'diff-context-expanded',
+ 'diff-context-expanded-internal',
this.onDiffContextExpanded
);
}
@@ -584,6 +570,5 @@
updateRenderPrefs(renderPrefs: RenderPreferences) {
this.builder?.updateRenderPrefs(renderPrefs);
- this.processor.updateRenderPrefs(renderPrefs);
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
index d776164..f6f0cb3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -11,12 +11,13 @@
import './gr-diff-builder-element';
import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {
DiffContent,
DiffLayer,
DiffPreferencesInfo,
DiffViewMode,
+ GrDiffLineType,
Side,
} from '../../../api/diff';
import {stubRestApi} from '../../../test/test-utils';
@@ -27,6 +28,7 @@
import {fixture, html, assert} from '@open-wc/testing';
import {GrDiffRow} from './gr-diff-row';
import {GrDiffBuilder} from './gr-diff-builder';
+import {querySelectorAll} from '../../../utils/dom-util';
const DEFAULT_PREFS = createDefaultDiffPrefs();
@@ -470,15 +472,11 @@
});
suite('rendering text, images and binary files', () => {
- let processStub: sinon.SinonStub;
let keyLocations: KeyLocations;
let content: DiffContent[] = [];
setup(() => {
element.viewMode = 'SIDE_BY_SIDE';
- processStub = sinon
- .stub(element.processor, 'process')
- .returns(Promise.resolve());
keyLocations = {left: {}, right: {}};
element.prefs = {
...DEFAULT_PREFS,
@@ -503,8 +501,7 @@
element.diff = {...createEmptyDiff(), content};
element.render(keyLocations);
await waitForEventOnce(diffTable, 'render-content');
- assert.isTrue(processStub.calledOnce);
- assert.isFalse(processStub.lastCall.args[1]);
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
});
test('image', async () => {
@@ -512,16 +509,14 @@
element.isImageDiff = true;
element.render(keyLocations);
await waitForEventOnce(diffTable, 'render-content');
- assert.isTrue(processStub.calledOnce);
- assert.isTrue(processStub.lastCall.args[1]);
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
});
test('binary', async () => {
element.diff = {...createEmptyDiff(), content, binary: true};
element.render(keyLocations);
await waitForEventOnce(diffTable, 'render-content');
- assert.isTrue(processStub.calledOnce);
- assert.isTrue(processStub.lastCall.args[1]);
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
});
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 3bffe08..400674d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -5,11 +5,13 @@
*/
import {ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {RenderPreferences, Side} from '../../../api/diff';
+import {FILE, RenderPreferences, Side} from '../../../api/diff';
import '../gr-diff-image-viewer/gr-image-viewer';
import {html, LitElement, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {property, query, state} from 'lit/decorators.js';
import {GrDiffBuilder} from './gr-diff-builder';
+import {createElementDiff, isNewDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
// MIME types for images we allow showing. Do not include SVG, it can contain
// arbitrary JavaScript.
@@ -28,6 +30,13 @@
super(diff, prefs, outputEl, [], renderPrefs);
}
+ override buildSectionElement(group: GrDiffGroup): HTMLElement {
+ const section = createElementDiff('tbody');
+ // Do not create a diff row for LOST.
+ if (group.lines[0].beforeNumber !== FILE) return section;
+ return super.buildSectionElement(group);
+ }
+
public renderImageDiff() {
const imageDiff = this.useNewImageDiffUi
? this.createImageDiffNew()
@@ -36,7 +45,9 @@
}
private createImageDiffNew() {
- const imageDiff = document.createElement('gr-diff-image-new');
+ const imageDiff = document.createElement(
+ 'gr-diff-image-new'
+ ) as GrDiffImageNew;
imageDiff.automaticBlink = this.autoBlink();
imageDiff.baseImage = this.baseImage ?? undefined;
imageDiff.revisionImage = this.revisionImage ?? undefined;
@@ -44,7 +55,9 @@
}
private createImageDiffOld() {
- const imageDiff = document.createElement('gr-diff-image-old');
+ const imageDiff = document.createElement(
+ 'gr-diff-image-old'
+ ) as GrDiffImageOld;
imageDiff.baseImage = this.baseImage ?? undefined;
imageDiff.revisionImage = this.revisionImage ?? undefined;
return imageDiff;
@@ -66,7 +79,6 @@
}
}
-@customElement('gr-diff-image-new')
class GrDiffImageNew extends LitElement {
@property() baseImage?: ImageInfo;
@@ -104,7 +116,6 @@
}
}
-@customElement('gr-diff-image-old')
class GrDiffImageOld extends LitElement {
@property() baseImage?: ImageInfo;
@@ -255,9 +266,16 @@
: '';
}
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (!isNewDiff()) {
+ customElements.define('gr-diff-image-new', GrDiffImageNew);
+ customElements.define('gr-diff-image-old', GrDiffImageOld);
+}
+
declare global {
interface HTMLElementTagNameMap {
- 'gr-diff-image-new': GrDiffImageNew;
- 'gr-diff-image-old': GrDiffImageOld;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff-image-new': LitElement;
+ 'gr-diff-image-old': LitElement;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index f38ba5c..d826755 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -9,9 +9,9 @@
ContentLoadNeededEventDetail,
DiffContextExpandedExternalDetail,
DiffViewMode,
+ LineNumber,
RenderPreferences,
} from '../../../api/diff';
-import {LineNumber} from '../gr-diff/gr-diff-line';
import {GrDiffGroup} from '../gr-diff/gr-diff-group';
import {BlameInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -30,12 +30,12 @@
/** The context control group that should be replaced by `groups`. */
contextGroup: GrDiffGroup;
groups: GrDiffGroup[];
- numLines: number;
}
declare global {
interface HTMLElementEventMap {
- 'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
+ 'diff-context-expanded-internal': CustomEvent<DiffContextExpandedEventDetail>;
+ 'diff-context-expanded': CustomEvent<DiffContextExpandedExternalDetail>;
'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index 9acda81..ae0a937 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {html, LitElement, nothing, TemplateResult} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {property, state} from 'lit/decorators.js';
import {ifDefined} from 'lit/directives/if-defined.js';
import {createRef, Ref, ref} from 'lit/directives/ref.js';
import {
@@ -12,16 +12,18 @@
Side,
LineNumber,
DiffLayer,
+ GrDiffLineType,
+ LOST,
+ FILE,
} from '../../../api/diff';
import {BlameInfo} from '../../../types/common';
import {assertIsDefined} from '../../../utils/common-util';
import {fire} from '../../../utils/event-util';
import {getBaseUrl} from '../../../utils/url-util';
import './gr-diff-text';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {diffClasses, isResponsive} from '../gr-diff/gr-diff-utils';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {diffClasses, isNewDiff, isResponsive} from '../gr-diff/gr-diff-utils';
-@customElement('gr-diff-row')
export class GrDiffRow extends LitElement {
contentLeftRef: Ref<LitElement> = createRef();
@@ -281,8 +283,8 @@
lineNumber: LineNumber,
side: Side
) {
- if (this.hideFileCommentButton && lineNumber === 'FILE') return;
- if (lineNumber === 'LOST') return;
+ if (this.hideFileCommentButton && lineNumber === FILE) return;
+ if (lineNumber === LOST) return;
// .lineNumButton has `white-space: pre`, so prettier must not add spaces.
// prettier-ignore
return html`
@@ -298,18 +300,18 @@
fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
@mouseleave=${() =>
fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
- >${lineNumber === 'FILE' ? 'File' : lineNumber.toString()}</button>
+ >${lineNumber === FILE ? 'File' : lineNumber.toString()}</button>
`;
}
private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
- if (lineNumber === 'FILE') return 'Add file comment';
+ if (lineNumber === FILE) return 'Add file comment';
// Add aria-labels for valid line numbers.
// For unified diff, this method will be called with number set to 0 for
// the empty line number column for added/removed lines. This should not
// be announced to the screenreader.
- if (lineNumber === 'LOST' || lineNumber <= 0) return undefined;
+ if (lineNumber === LOST || lineNumber <= 0) return undefined;
switch (line.type) {
case GrDiffLineType.REMOVE:
@@ -336,8 +338,8 @@
const extras: string[] = [line.type, side];
if (line.type !== GrDiffLineType.BLANK) extras.push('content');
if (!line.hasIntralineInfo) extras.push('no-intraline-info');
- if (line.beforeNumber === 'FILE') extras.push('file');
- if (line.beforeNumber === 'LOST') extras.push('lost');
+ if (line.beforeNumber === FILE) extras.push('file');
+ if (line.beforeNumber === LOST) extras.push('lost');
// .content has `white-space: pre`, so prettier must not add spaces.
// prettier-ignore
@@ -437,7 +439,7 @@
private renderText(side: Side) {
const line = this.line(side);
const lineNumber = this.lineNumber(side);
- if (lineNumber === 'FILE' || lineNumber === 'LOST') return;
+ if (typeof lineNumber !== 'number') return;
// Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
// another rendering cycle will be initiated in `updated()`.
@@ -467,8 +469,14 @@
}
}
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (!isNewDiff()) {
+ customElements.define('gr-diff-row', GrDiffRow);
+}
+
declare global {
interface HTMLElementTagNameMap {
- 'gr-diff-row': GrDiffRow;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff-row': LitElement;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index e5d3d2e..97bec05 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {html, LitElement} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {property, state} from 'lit/decorators.js';
import {
DiffInfo,
DiffLayer,
@@ -16,9 +16,9 @@
} from '../../../api/diff';
import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
import {
- countLines,
diffClasses,
getResponsiveMode,
+ isNewDiff,
} from '../gr-diff/gr-diff-utils';
import {GrDiffRow} from './gr-diff-row';
import '../gr-context-controls/gr-context-controls-section';
@@ -27,8 +27,8 @@
import './gr-diff-row';
import {when} from 'lit/directives/when.js';
import {fire} from '../../../utils/event-util';
+import {countLines} from '../../../utils/diff-util';
-@customElement('gr-diff-section')
export class GrDiffSection extends LitElement {
@property({type: Object})
group?: GrDiffGroup;
@@ -243,8 +243,14 @@
}
}
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (!isNewDiff()) {
+ customElements.define('gr-diff-section', GrDiffSection);
+}
+
declare global {
interface HTMLElementTagNameMap {
- 'gr-diff-section': GrDiffSection;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff-section': LitElement;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
index c1b13ac..7c26ddc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {LitElement, html, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {property} from 'lit/decorators.js';
import {styleMap} from 'lit/directives/style-map.js';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {diffClasses, isNewDiff} from '../gr-diff/gr-diff-utils';
const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
@@ -25,7 +25,6 @@
* performance. And be aware that building longer lived local state is not
* useful here.
*/
-@customElement('gr-diff-text')
export class GrDiffText extends LitElement {
/**
* The browser API for handling selection does not (yet) work for selection
@@ -145,8 +144,14 @@
}
}
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (!isNewDiff()) {
+ customElements.define('gr-diff-text', GrDiffText);
+}
+
declare global {
interface HTMLElementTagNameMap {
- 'gr-diff-text': GrDiffText;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff-text': LitElement;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 8fd03bb..5651dcf 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -4,8 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-import {Side, TokenHighlightEventDetails} from '../../../api/diff';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {
+ GrDiffLineType,
+ Side,
+ TokenHighlightEventDetails,
+} from '../../../api/diff';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
import {html, render} from 'lit';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 9e3640b..6a32afb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -8,6 +8,7 @@
import {
DiffViewMode,
GrDiffCursor as GrDiffCursorApi,
+ GrDiffLineType,
LineNumber,
LineSelectedEventDetail,
} from '../../../api/diff';
@@ -17,7 +18,6 @@
GrCursorManager,
isTargetable,
} from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
-import {GrDiffLineType} from '../gr-diff/gr-diff-line';
import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
import {GrDiff} from '../gr-diff/gr-diff';
import {fire} from '../../../utils/event-util';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 69c0f5c..0d9250c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -11,7 +11,6 @@
import {Side} from '../../../constants/constants';
import {CommentRange} from '../../../types/common';
import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
-import {FILE} from '../gr-diff/gr-diff-line';
import {
getLineElByChild,
getLineNumberByChild,
@@ -308,7 +307,7 @@
const side = getSideByLineEl(lineEl);
if (!side) return null;
const line = getLineNumberByChild(lineEl);
- if (!line || line === FILE || line === 'LOST') return null;
+ if (typeof line !== 'number') return null;
const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
if (!contentTd) return null;
const contentText = contentTd.querySelector('.contentText');
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 22a71a5..256dc11 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -3,25 +3,18 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- GrDiffLine,
- GrDiffLineType,
- FILE,
- Highlights,
- LineNumber,
-} from '../gr-diff/gr-diff-line';
+import {GrDiffLine, Highlights} from '../gr-diff/gr-diff-line';
import {
GrDiffGroup,
GrDiffGroupType,
hideInContextControl,
} from '../gr-diff/gr-diff-group';
-import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
import {DiffContent} from '../../../types/diff';
import {Side} from '../../../constants/constants';
import {debounce, DelayedTask} from '../../../utils/async-util';
-import {RenderPreferences} from '../../../api/diff';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {FILE, GrDiffLineType, LOST, LineNumber} from '../../../api/diff';
const WHOLE_FILE = -1;
@@ -95,15 +88,17 @@
keyLocations: KeyLocations = {left: {}, right: {}};
- private asyncThreshold = 64;
-
- private nextStepHandle: number | null = null;
-
- private processPromise: CancelablePromise<void> | null = null;
+ asyncThreshold = 64;
// visible for testing
isScrolling?: boolean;
+ /** Just for making sure that process() is only called once. */
+ private isStarted = false;
+
+ /** Indicates that processing should be stopped. */
+ private isCancelled = false;
+
private resetIsScrollingTask?: DelayedTask;
private readonly handleWindowScroll = () => {
@@ -123,94 +118,71 @@
* array of GrDiffGroups when the diff is completely processed.
*/
process(chunks: DiffContent[], isBinary: boolean) {
- // Cancel any still running process() calls, because they append to the
- // same groups field.
- this.cancel();
+ assert(this.isStarted === false, 'diff processor cannot be started twice');
+ this.isStarted = true;
+
window.addEventListener('scroll', this.handleWindowScroll);
assertIsDefined(this.consumer, 'consumer');
this.consumer.clearGroups();
- this.consumer.addGroup(this.makeGroup('LOST'));
+ this.consumer.addGroup(this.makeGroup(LOST));
this.consumer.addGroup(this.makeGroup(FILE));
- // If it's a binary diff, we won't be rendering hunks of text differences
- // so finish processing.
- if (isBinary) {
- return Promise.resolve();
- }
+ if (isBinary) return Promise.resolve();
- // TODO: Canceling this promise does not help much. `nextStep` will continue
- // to be scheduled anyway. So either just remove the cancelable promise, so
- // future programmers are not fooled about this promise can do. Or fix the
- // scheduling of `nextStep` such that cancellation is taken into account.
- // The easiest approach is likely to just not re-use the processor for
- // multiple processing passes. There is no benefit from doing so.
- this.processPromise = makeCancelable(
- new Promise(resolve => {
- const state = {
- lineNums: {left: 0, right: 0},
- chunkIndex: 0,
- };
+ return new Promise<void>(resolve => {
+ const state = {
+ lineNums: {left: 0, right: 0},
+ chunkIndex: 0,
+ };
- chunks = this.splitLargeChunks(chunks);
- chunks = this.splitCommonChunksWithKeyLocations(chunks);
+ chunks = this.splitLargeChunks(chunks);
+ chunks = this.splitCommonChunksWithKeyLocations(chunks);
- let currentBatch = 0;
- const nextStep = () => {
- if (this.isScrolling) {
- this.nextStepHandle = window.setTimeout(nextStep, 100);
- return;
- }
- // If we are done, resolve the promise.
- if (state.chunkIndex >= chunks.length) {
- resolve();
- this.nextStepHandle = null;
- return;
- }
+ let currentBatch = 0;
+ const nextStep = () => {
+ if (this.isCancelled || state.chunkIndex >= chunks.length) {
+ resolve();
+ return;
+ }
+ if (this.isScrolling) {
+ window.setTimeout(nextStep, 100);
+ return;
+ }
- // Process the next chunk and incorporate the result.
- const stateUpdate = this.processNext(state, chunks);
- for (const group of stateUpdate.groups) {
- assertIsDefined(this.consumer, 'consumer');
- this.consumer.addGroup(group);
- currentBatch += group.lines.length;
- }
- state.lineNums.left += stateUpdate.lineDelta.left;
- state.lineNums.right += stateUpdate.lineDelta.right;
+ const stateUpdate = this.processNext(state, chunks);
+ for (const group of stateUpdate.groups) {
+ this.consumer?.addGroup(group);
+ currentBatch += group.lines.length;
+ }
+ state.lineNums.left += stateUpdate.lineDelta.left;
+ state.lineNums.right += stateUpdate.lineDelta.right;
- // Increment the index and recurse.
- state.chunkIndex = stateUpdate.newChunkIndex;
- if (currentBatch >= this.asyncThreshold) {
- currentBatch = 0;
- this.nextStepHandle = window.setTimeout(nextStep, 1);
- } else {
- nextStep.call(this);
- }
- };
+ state.chunkIndex = stateUpdate.newChunkIndex;
+ if (currentBatch >= this.asyncThreshold) {
+ currentBatch = 0;
+ window.setTimeout(nextStep, 1);
+ } else {
+ nextStep.call(this);
+ }
+ };
- nextStep.call(this);
- })
- );
- return this.processPromise.finally(() => {
- this.processPromise = null;
- window.removeEventListener('scroll', this.handleWindowScroll);
+ nextStep.call(this);
+ }).finally(() => {
+ this.finish();
});
}
- /**
- * Cancel any jobs that are running.
- */
- cancel() {
- if (this.nextStepHandle !== null) {
- window.clearTimeout(this.nextStepHandle);
- this.nextStepHandle = null;
- }
- if (this.processPromise) {
- this.processPromise.cancel();
- }
+ finish() {
+ this.consumer = undefined;
window.removeEventListener('scroll', this.handleWindowScroll);
}
+ cancel() {
+ this.isCancelled = true;
+ this.finish();
+ }
+
/**
* Process the next uncollapsible chunk, or the next collapsible chunks.
*/
@@ -739,10 +711,4 @@
return this.breakdown(head, size).concat([tail]);
}
-
- updateRenderPrefs(renderPrefs: RenderPreferences) {
- if (renderPrefs.num_lines_rendered_at_once) {
- this.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
- }
- }
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 5b23ee7..335f0d0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -5,11 +5,12 @@
*/
import '../../../test/common-test-setup';
import './gr-diff-processor';
-import {GrDiffLineType, FILE, GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
import {GrDiffProcessor, State} from './gr-diff-processor';
import {DiffContent} from '../../../types/diff';
import {assert} from '@open-wc/testing';
+import {FILE, GrDiffLineType} from '../../../api/diff';
suite('gr-diff-processor tests', () => {
const WHOLE_FILE = -1;
@@ -784,13 +785,16 @@
]);
});
- test('scrolling pauses rendering', () => {
+ test('isScrolling paused', () => {
const content = Array(200).fill({ab: ['', '']});
element.isScrolling = true;
element.process(content, false);
- // Just the files group - no more processing during scrolling.
+ // Just the FILE and LOST groups.
assert.equal(groups.length, 2);
+ });
+ test('isScrolling unpaused', () => {
+ const content = Array(200).fill({ab: ['', '']});
element.isScrolling = false;
element.process(content, false);
// More groups have been processed. How many does not matter here.
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index 2ec0a2e..771e298 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -3,9 +3,8 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
-import {LineRange, Side} from '../../../api/diff';
-import {LineNumber} from './gr-diff-line';
+import {BLANK_LINE, GrDiffLine} from './gr-diff-line';
+import {GrDiffLineType, LineNumber, LineRange, Side} from '../../../api/diff';
import {assertIsDefined, assert} from '../../../utils/common-util';
import {untilRendered} from '../../../utils/dom-util';
import {isDefined} from '../../../types/types';
@@ -133,12 +132,10 @@
for (const line of group.lines) {
if (
(line.beforeNumber &&
- line.beforeNumber !== 'FILE' &&
- line.beforeNumber !== 'LOST' &&
+ typeof line.beforeNumber === 'number' &&
line.beforeNumber < leftSplit) ||
(line.afterNumber &&
- line.afterNumber !== 'FILE' &&
- line.afterNumber !== 'LOST' &&
+ typeof line.afterNumber === 'number' &&
line.afterNumber < rightSplit)
) {
before.push(line);
@@ -428,11 +425,14 @@
/** Returns true if it is, or contains, a skip group. */
hasSkipGroup() {
- return !!this.skip || this.contextGroups?.some(g => !!g.skip);
+ return (
+ this.skip !== undefined ||
+ this.contextGroups?.some(g => g.skip !== undefined)
+ );
}
containsLine(side: Side, line: LineNumber) {
- if (line === 'FILE' || line === 'LOST') {
+ if (typeof line !== 'number') {
// For FILE and LOST, beforeNumber and afterNumber are the same
return this.lines[0]?.beforeNumber === line;
}
@@ -444,7 +444,10 @@
// For both CONTEXT_CONTROL groups and SKIP groups the `lines` array will
// be empty. So we have to use `lineRange` instead of looking at the first
// line.
- if (this.type === GrDiffGroupType.CONTEXT_CONTROL || this.skip) {
+ if (
+ this.type === GrDiffGroupType.CONTEXT_CONTROL ||
+ this.skip !== undefined
+ ) {
return side === Side.LEFT
? this.lineRange.left.start_line
: this.lineRange.right.start_line;
@@ -456,14 +459,8 @@
}
private _updateRangeWithNewLine(line: GrDiffLine) {
- if (
- line.beforeNumber === 'FILE' ||
- line.afterNumber === 'FILE' ||
- line.beforeNumber === 'LOST' ||
- line.afterNumber === 'LOST'
- ) {
- return;
- }
+ if (typeof line.beforeNumber !== 'number') return;
+ if (typeof line.afterNumber !== 'number') return;
if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
if (
@@ -498,9 +495,8 @@
// The LOST or FILE lines may be hidden and thus never resolve an
// untilRendered() promise.
if (
- this.skip ||
- lineNumber === 'LOST' ||
- lineNumber === 'FILE' ||
+ this.skip !== undefined ||
+ typeof lineNumber !== 'number' ||
this.type === GrDiffGroupType.CONTEXT_CONTROL
) {
return Promise.resolve();
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 06bf92920..bbbb4ad 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {GrDiffLine, BLANK_LINE} from './gr-diff-line';
import {
GrDiffGroup,
GrDiffGroupType,
hideInContextControl,
} from './gr-diff-group';
import {assert} from '@open-wc/testing';
-import {Side} from '../../../api/diff';
+import {FILE, GrDiffLineType, LOST, Side} from '../../../api/diff';
suite('gr-diff-group tests', () => {
test('delta line pairs', () => {
@@ -284,22 +284,31 @@
});
assert.equal(group.startLine(Side.LEFT), 3);
assert.equal(group.startLine(Side.RIGHT), 6);
+
+ const group2 = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ skip: 0,
+ offsetLeft: 3,
+ offsetRight: 6,
+ });
+ assert.equal(group2.startLine(Side.LEFT), 3);
+ assert.equal(group2.startLine(Side.RIGHT), 6);
});
test('FILE', () => {
const lines: GrDiffLine[] = [];
- lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE'));
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, FILE, FILE));
const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
- assert.equal(group.startLine(Side.LEFT), 'FILE');
- assert.equal(group.startLine(Side.RIGHT), 'FILE');
+ assert.equal(group.startLine(Side.LEFT), FILE);
+ assert.equal(group.startLine(Side.RIGHT), FILE);
});
test('LOST', () => {
const lines: GrDiffLine[] = [];
- lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'LOST', 'LOST'));
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, LOST, LOST));
const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
- assert.equal(group.startLine(Side.LEFT), 'LOST');
- assert.equal(group.startLine(Side.RIGHT), 'LOST');
+ assert.equal(group.startLine(Side.LEFT), LOST);
+ assert.equal(group.startLine(Side.RIGHT), LOST);
});
});
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
index 338a275..1a89207 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
@@ -4,17 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
+ FILE,
GrDiffLine as GrDiffLineApi,
GrDiffLineType,
LineNumber,
Side,
} from '../../../api/diff';
-export {GrDiffLineType};
-export type {LineNumber};
-
-export const FILE = 'FILE';
-
export class GrDiffLine implements GrDiffLineApi {
constructor(
readonly type: GrDiffLineType,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
new file mode 100644
index 0000000..e7f4b51
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -0,0 +1,671 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const grDiffStyles = css`
+ /* This is used to hide all left side of the diff (e.g. diffs besides
+ comments in the change log). Since we want to remove the first 4
+ cells consistently in all rows except context buttons (.dividerRow). */
+ :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+ :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
+ display: none;
+ }
+ :host(.disable-context-control-buttons) {
+ --context-control-display: none;
+ }
+ :host(.disable-context-control-buttons) .section {
+ border-right: none;
+ }
+ :host(.hide-line-length-indicator) .full-width td.content .contentText {
+ background-image: none;
+ }
+
+ :host {
+ font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+ font-size: var(--font-size, var(--font-size-code, 12px));
+ /* usually 16px = 12px + 4px */
+ line-height: calc(
+ var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
+ );
+ }
+
+ .thread-group {
+ display: block;
+ max-width: var(--content-width, 80ch);
+ white-space: normal;
+ background-color: var(--diff-blank-background-color);
+ }
+ .diffContainer {
+ max-width: var(--diff-max-width, none);
+ font-family: var(--monospace-font-family);
+ }
+ table {
+ border-collapse: collapse;
+ table-layout: fixed;
+ }
+ td.lineNum {
+ /* Enforces background whenever lines wrap */
+ background-color: var(--diff-blank-background-color);
+ }
+
+ /* Provides the option to add side borders (left and right) to the line
+ number column. */
+ td.lineNum,
+ td.blankLineNum,
+ td.moveControlsLineNumCol,
+ td.contextLineNum {
+ box-shadow: var(--line-number-box-shadow, unset);
+ }
+
+ /* Context controls break up the table visually, so we set the right
+ border on individual sections to leave a gap for the divider.
+
+ Also taken into account for max-width calculations in SHRINK_ONLY mode
+ (check GrDiff.updatePreferenceStyles). */
+ .section {
+ border-right: 1px solid var(--border-color);
+ }
+ .section.contextControl {
+ /* Divider inside this section must not have border; we set borders on
+ the padding rows below. */
+ border-right-width: 0;
+ }
+ /* Padding rows behind context controls. The diff is styled to be cut
+ into two halves by the negative space of the divider on which the
+ context control buttons are anchored. */
+ .contextBackground {
+ border-right: 1px solid var(--border-color);
+ }
+ .contextBackground.above {
+ border-bottom: 1px solid var(--border-color);
+ }
+ .contextBackground.below {
+ border-top: 1px solid var(--border-color);
+ }
+
+ .lineNumButton {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: var(--diff-blank-background-color);
+ box-shadow: var(--line-number-box-shadow, unset);
+ }
+ td.lineNum {
+ vertical-align: top;
+ }
+
+ /* The only way to focus this (clicking) will apply our own focus
+ styling, so this default styling is not needed and distracting. */
+ .lineNumButton:focus {
+ outline: none;
+ }
+ gr-image-viewer {
+ width: 100%;
+ height: 100%;
+ max-width: var(--image-viewer-max-width, 95vw);
+ max-height: var(--image-viewer-max-height, 90vh);
+ /* Defined by paper-styles default-theme and used in various
+ components. background-color-secondary is a compromise between
+ fairly light in light theme (where we ideally would want
+ background-color-primary) yet slightly offset against the app
+ background in dark mode, where drop shadows e.g. around paper-card
+ are almost invisible. */
+ --primary-background-color: var(--background-color-secondary);
+ }
+ .image-diff .gr-diff {
+ text-align: center;
+ }
+ .image-diff img {
+ box-shadow: var(--elevation-level-1);
+ max-width: 50em;
+ }
+ .image-diff .right.lineNumButton {
+ border-left: 1px solid var(--border-color);
+ }
+ .image-diff label {
+ font-family: var(--font-family);
+ font-style: italic;
+ }
+ tbody.binary-diff td {
+ font-family: var(--font-family);
+ font-style: italic;
+ text-align: center;
+ padding: var(--spacing-s) 0;
+ }
+ .diff-row {
+ outline: none;
+ user-select: none;
+ }
+ .diff-row.target-row.target-side-left .lineNumButton.left,
+ .diff-row.target-row.target-side-right .lineNumButton.right,
+ .diff-row.target-row.unified .lineNumButton {
+ color: var(--primary-text-color);
+ }
+
+ /* Preparing selected line cells with position relative so it allows a
+ positioned overlay with 'position: absolute'. */
+ .target-row td {
+ position: relative;
+ }
+
+ /* Defines an overlay to the selected line for drawing an outline without
+ blocking user interaction (e.g. text selection). */
+ .target-row td::before {
+ border-width: 0;
+ border-style: solid;
+ border-color: var(--focused-line-outline-color);
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ user-select: none;
+ content: ' ';
+ }
+
+ /* The outline for the selected content cell should be the same in all
+ cases. */
+ .target-row.target-side-left td.left.content::before,
+ .target-row.target-side-right td.right.content::before,
+ .unified.target-row td.content::before {
+ border-width: 1px 1px 1px 0;
+ }
+
+ /* The outline for the sign cell should be always be contiguous
+ top/bottom. */
+ .target-row.target-side-left td.left.sign::before,
+ .target-row.target-side-right td.right.sign::before {
+ border-width: 1px 0;
+ }
+
+ /* For side-by-side we need to select the correct line number to
+ "visually close" the outline. */
+ .side-by-side.target-row.target-side-left td.left.lineNum::before,
+ .side-by-side.target-row.target-side-right td.right.lineNum::before {
+ border-width: 1px 0 1px 1px;
+ }
+
+ /* For unified diff we always start the overlay from the left cell. */
+ .unified.target-row td.left:not(.content)::before {
+ border-width: 1px 0 1px 1px;
+ }
+
+ /* For unified diff we should continue the top/bottom border in right
+ line number column. */
+ .unified.target-row td.right:not(.content)::before {
+ border-width: 1px 0;
+ }
+
+ .content {
+ background-color: var(--diff-blank-background-color);
+ }
+
+ /* Describes two states of semantic tokens: whenever a token has a
+ definition that can be navigated to (navigable) and whenever
+ the token is actually clickable to perform this navigation. */
+ .semantic-token.navigable {
+ text-decoration-style: dotted;
+ text-decoration-line: underline;
+ }
+ .semantic-token.navigable.clickable {
+ text-decoration-style: solid;
+ cursor: pointer;
+ }
+
+ /* The file line, which has no contentText, add some margin before the
+ first comment. We cannot add padding the container because we only
+ want it if there is at least one comment thread, and the slotting
+ makes :empty not work as expected. */
+ .content.file slot:first-child::slotted(.comment-thread) {
+ display: block;
+ margin-top: var(--spacing-xs);
+ }
+ .contentText {
+ background-color: var(--view-background-color);
+ }
+ .blank {
+ background-color: var(--diff-blank-background-color);
+ }
+ .image-diff .content {
+ background-color: var(--diff-blank-background-color);
+ }
+ .responsive {
+ width: 100%;
+ }
+ .responsive .contentText {
+ white-space: break-spaces;
+ word-break: break-all;
+ }
+ .lineNumButton,
+ .content {
+ vertical-align: top;
+ white-space: pre;
+ }
+ .contextLineNum,
+ .lineNumButton {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ color: var(--deemphasized-text-color);
+ padding: 0 var(--spacing-m);
+ text-align: right;
+ }
+ .canComment .lineNumButton {
+ cursor: pointer;
+ }
+ .sign {
+ min-width: 1ch;
+ width: 1ch;
+ background-color: var(--view-background-color);
+ }
+ .sign.blank {
+ background-color: var(--diff-blank-background-color);
+ }
+ .content {
+ /* Set min width since setting width on table cells still allows them
+ to shrink. Do not set max width because CJK
+ (Chinese-Japanese-Korean) glyphs have variable width. */
+ min-width: var(--content-width, 80ch);
+ width: var(--content-width, 80ch);
+ }
+ /* If there are no intraline info, consider everything changed */
+ .content.add .contentText .intraline,
+ .content.add.no-intraline-info .contentText,
+ .sign.add.no-intraline-info,
+ .delta.total .content.add .contentText {
+ background-color: var(--dark-add-highlight-color);
+ }
+ .content.add .contentText,
+ .sign.add {
+ background-color: var(--light-add-highlight-color);
+ }
+ /* If there are no intraline info, consider everything changed */
+ .content.remove .contentText .intraline,
+ .content.remove.no-intraline-info .contentText,
+ .delta.total .content.remove .contentText,
+ .sign.remove.no-intraline-info {
+ background-color: var(--dark-remove-highlight-color);
+ }
+ .content.remove .contentText,
+ .sign.remove {
+ background-color: var(--light-remove-highlight-color);
+ }
+
+ .ignoredWhitespaceOnly .sign.no-intraline-info {
+ background-color: var(--view-background-color);
+ }
+
+ /* dueToRebase */
+ .dueToRebase .content.add .contentText .intraline,
+ .delta.total.dueToRebase .content.add .contentText {
+ background-color: var(--dark-rebased-add-highlight-color);
+ }
+ .dueToRebase .content.add .contentText {
+ background-color: var(--light-rebased-add-highlight-color);
+ }
+ .dueToRebase .content.remove .contentText .intraline,
+ .delta.total.dueToRebase .content.remove .contentText {
+ background-color: var(--dark-rebased-remove-highlight-color);
+ }
+ .dueToRebase .content.remove .contentText {
+ background-color: var(--light-rebased-remove-highlight-color);
+ }
+
+ /* dueToMove */
+ .dueToMove .sign.add,
+ .dueToMove .content.add .contentText,
+ .dueToMove .moveControls.movedIn .sign.right,
+ .dueToMove .moveControls.movedIn .moveHeader,
+ .delta.total.dueToMove .content.add .contentText {
+ background-color: var(--diff-moved-in-background);
+ }
+
+ .dueToMove.changed .sign.add,
+ .dueToMove.changed .content.add .contentText,
+ .dueToMove.changed .moveControls.movedIn .sign.right,
+ .dueToMove.changed .moveControls.movedIn .moveHeader,
+ .delta.total.dueToMove.changed .content.add .contentText {
+ background-color: var(--diff-moved-in-changed-background);
+ }
+
+ .dueToMove .sign.remove,
+ .dueToMove .content.remove .contentText,
+ .dueToMove .moveControls.movedOut .moveHeader,
+ .dueToMove .moveControls.movedOut .sign.left,
+ .delta.total.dueToMove .content.remove .contentText {
+ background-color: var(--diff-moved-out-background);
+ }
+
+ .delta.dueToMove .movedIn .moveHeader {
+ --gr-range-header-color: var(--diff-moved-in-label-color);
+ }
+ .delta.dueToMove.changed .movedIn .moveHeader {
+ --gr-range-header-color: var(--diff-moved-in-changed-label-color);
+ }
+ .delta.dueToMove .movedOut .moveHeader {
+ --gr-range-header-color: var(--diff-moved-out-label-color);
+ }
+
+ .moveHeader a {
+ color: inherit;
+ }
+
+ /* ignoredWhitespaceOnly */
+ .ignoredWhitespaceOnly .content.add .contentText .intraline,
+ .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+ .ignoredWhitespaceOnly .content.add .contentText,
+ .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+ .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+ .ignoredWhitespaceOnly .content.remove .contentText {
+ background-color: var(--view-background-color);
+ }
+
+ .content .contentText gr-diff-text:empty:after,
+ .content .contentText gr-legacy-text:empty:after,
+ .content .contentText:empty:after {
+ /* Newline, to ensure empty lines are one line-height tall. */
+ content: '\\A';
+ }
+
+ /* Context controls */
+ .contextControl {
+ display: var(--context-control-display, table-row-group);
+ background-color: transparent;
+ border: none;
+ --divider-height: var(--spacing-s);
+ --divider-border: 1px;
+ }
+ /* TODO: Is this still used? */
+ .contextControl gr-button gr-icon {
+ /* should match line-height of gr-button */
+ font-size: var(--line-height-mono, 18px);
+ }
+ .contextControl td:not(.lineNumButton) {
+ text-align: center;
+ }
+
+ /* Padding rows behind context controls. Styled as a continuation of the
+ line gutters and code area. */
+ .contextBackground > .contextLineNum {
+ background-color: var(--diff-blank-background-color);
+ }
+ .contextBackground > td:not(.contextLineNum) {
+ background-color: var(--view-background-color);
+ }
+ .contextBackground {
+ /* One line of background behind the context expanders which they can
+ render on top of, plus some padding. */
+ height: calc(var(--line-height-normal) + var(--spacing-s));
+ }
+
+ .dividerCell {
+ vertical-align: top;
+ }
+ .dividerRow.show-both .dividerCell {
+ height: var(--divider-height);
+ }
+ .dividerRow.show-above .dividerCell,
+ .dividerRow.show-above .dividerCell {
+ height: 0;
+ }
+
+ .br:after {
+ /* Line feed */
+ content: '\\A';
+ }
+ .tab {
+ display: inline-block;
+ }
+ .tab-indicator:before {
+ color: var(--diff-tab-indicator-color);
+ /* >> character */
+ content: '\\00BB';
+ position: absolute;
+ }
+ .special-char-indicator {
+ /* spacing so elements don't collide */
+ padding-right: var(--spacing-m);
+ }
+ .special-char-indicator:before {
+ color: var(--diff-tab-indicator-color);
+ content: '•';
+ position: absolute;
+ }
+ .special-char-warning {
+ /* spacing so elements don't collide */
+ padding-right: var(--spacing-m);
+ }
+ .special-char-warning:before {
+ color: var(--warning-foreground);
+ content: '!';
+ position: absolute;
+ }
+ /* Is defined after other background-colors, such that this
+ rule wins in case of same specificity. */
+ .trailing-whitespace,
+ .content .contentText .trailing-whitespace,
+ .trailing-whitespace .intraline,
+ .content .contentText .trailing-whitespace .intraline {
+ border-radius: var(--border-radius, 4px);
+ background-color: var(--diff-trailing-whitespace-indicator);
+ }
+ #diffHeader {
+ background-color: var(--table-header-background-color);
+ border-bottom: 1px solid var(--border-color);
+ color: var(--link-color);
+ padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+ }
+ #diffTable {
+ /* for gr-selection-action-box positioning */
+ position: relative;
+ }
+ #diffTable:focus {
+ outline: none;
+ }
+ #loadingError,
+ #sizeWarning {
+ display: block;
+ margin: var(--spacing-l) auto;
+ max-width: 60em;
+ text-align: center;
+ }
+ #loadingError {
+ color: var(--error-text-color);
+ }
+ #sizeWarning gr-button {
+ margin: var(--spacing-l);
+ }
+ .target-row td.blame {
+ background: var(--diff-selection-background-color);
+ }
+ td.lost div {
+ background-color: var(--info-background);
+ }
+ td.lost div.lost-message {
+ font-family: var(--font-family, 'Roboto');
+ font-size: var(--font-size-normal, 14px);
+ line-height: var(--line-height-normal);
+ padding: var(--spacing-s) 0;
+ }
+ td.lost div.lost-message gr-icon {
+ padding: 0 var(--spacing-s) 0 var(--spacing-m);
+ color: var(--blue-700);
+ }
+
+ col.sign,
+ td.sign {
+ display: none;
+ }
+
+ /* Sign column should only be shown in high-contrast mode. */
+ :host(.with-sign-col) col.sign {
+ display: table-column;
+ }
+ :host(.with-sign-col) td.sign {
+ display: table-cell;
+ }
+ col.blame {
+ display: none;
+ }
+ td.blame {
+ display: none;
+ padding: 0 var(--spacing-m);
+ white-space: pre;
+ }
+ :host(.showBlame) col.blame {
+ display: table-column;
+ }
+ :host(.showBlame) td.blame {
+ display: table-cell;
+ }
+ td.blame > span {
+ opacity: 0.6;
+ }
+ td.blame > span.startOfRange {
+ opacity: 1;
+ }
+ td.blame .blameDate {
+ font-family: var(--monospace-font-family);
+ color: var(--link-color);
+ text-decoration: none;
+ }
+ .responsive td.blame {
+ overflow: hidden;
+ width: 200px;
+ }
+ /** Support the line length indicator **/
+ .responsive td.content .contentText {
+ /* Same strategy as in
+ https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+ */
+ background-image: linear-gradient(
+ var(--line-length-indicator-color),
+ var(--line-length-indicator-color)
+ );
+ background-size: 1px 100%;
+ background-position: var(--line-limit-marker) 0;
+ background-repeat: no-repeat;
+ }
+ .newlineWarning {
+ color: var(--deemphasized-text-color);
+ text-align: center;
+ }
+ .newlineWarning.hidden {
+ display: none;
+ }
+ .lineNum.COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background-color: var(--coverage-covered, #e0f2f1);
+ }
+ .lineNum.NOT_COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background-color: var(--coverage-not-covered, #ffd1a4);
+ }
+ .lineNum.PARTIALLY_COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background: linear-gradient(
+ to right bottom,
+ var(--coverage-not-covered, #ffd1a4) 0%,
+ var(--coverage-not-covered, #ffd1a4) 50%,
+ var(--coverage-covered, #e0f2f1) 50%,
+ var(--coverage-covered, #e0f2f1) 100%
+ );
+ }
+
+ // TODO: Investigate whether this CSS is still necessary.
+ /* BEGIN: Select and copy for Polymer 2 */
+ /* Below was copied and modified from the original css in gr-diff-selection.html. */
+ .content,
+ .contextControl,
+ .blame {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ }
+
+ .selected-left:not(.selected-comment)
+ .side-by-side
+ .left
+ + .content
+ .contentText,
+ .selected-right:not(.selected-comment)
+ .side-by-side
+ .right
+ + .content
+ .contentText,
+ .selected-left:not(.selected-comment)
+ .unified
+ .left.lineNum
+ ~ .content:not(.both)
+ .contentText,
+ .selected-right:not(.selected-comment)
+ .unified
+ .right.lineNum
+ ~ .content
+ .contentText,
+ .selected-left.selected-comment .side-by-side .left + .content .message,
+ .selected-right.selected-comment
+ .side-by-side
+ .right
+ + .content
+ .message
+ :not(.collapsedContent),
+ .selected-comment .unified .message :not(.collapsedContent),
+ .selected-blame .blame {
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+ }
+
+ /* Make comments and check results selectable when selected */
+ .selected-left.selected-comment ::slotted(.comment-thread[diff-side='left']),
+ .selected-right.selected-comment
+ ::slotted(.comment-thread[diff-side='right']) {
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+ }
+ /* END: Select and copy for Polymer 2 */
+
+ .whitespace-change-only-message {
+ background-color: var(--diff-context-control-background-color);
+ border: 1px solid var(--diff-context-control-border-color);
+ text-align: center;
+ }
+
+ .token-highlight {
+ background-color: var(--token-highlighting-color, #fffd54);
+ }
+
+ gr-selection-action-box {
+ /* Needs z-index to appear above wrapped content, since it's inserted
+ into DOM before it. */
+ z-index: 10;
+ }
+
+ gr-diff-image-new,
+ gr-diff-image-old,
+ gr-diff-section,
+ gr-context-controls-section,
+ gr-diff-row {
+ display: contents;
+ }
+`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 87fd5ca..e1de348 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {BlameInfo, CommentRange} from '../../../types/common';
-import {FILE, LineNumber} from './gr-diff-line';
import {Side} from '../../../constants/constants';
-import {DiffInfo} from '../../../types/diff';
import {
DiffPreferencesInfo,
DiffResponsiveMode,
+ FILE,
+ LOST,
+ LineNumber,
RenderPreferences,
} from '../../../api/diff';
import {getBaseUrl} from '../../../utils/url-util';
@@ -36,22 +37,10 @@
*/
export const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
-// If any line of the diff is more than the character limit, then disable
-// syntax highlighting for the entire file.
-export const SYNTAX_MAX_LINE_LENGTH = 500;
-
-export function countLines(diff?: DiffInfo, side?: Side) {
- if (!diff?.content || !side) return 0;
- return diff.content.reduce((sum, chunk) => {
- const sideChunk = side === Side.LEFT ? chunk.a : chunk.b;
- return sum + (sideChunk?.length ?? chunk.ab?.length ?? chunk.skip ?? 0);
- }, 0);
-}
-
-export function isFileUnchanged(diff: DiffInfo) {
- return !diff.content.some(
- content => (content.a && !content.common) || (content.b && !content.common)
- );
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+export function isNewDiff() {
+ const flags = new Set(window.ENABLED_EXPERIMENTS ?? []);
+ return flags.has('UiFeature__new_diff');
}
export function getResponsiveMode(
@@ -103,9 +92,7 @@
}
export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
- if (!lineNumber) return 0;
- if (lineNumber === 'LOST') return 0;
- if (lineNumber === 'FILE') return 0;
+ if (typeof lineNumber !== 'number') return 0;
return lineNumber;
}
@@ -138,15 +125,15 @@
const lineNumberStr = lineEl.getAttribute('data-value');
if (!lineNumberStr) return null;
if (lineNumberStr === FILE) return FILE;
- if (lineNumberStr === 'LOST') return 'LOST';
+ if (lineNumberStr === LOST) return LOST;
const lineNumber = Number(lineNumberStr);
return Number.isInteger(lineNumber) ? lineNumber : null;
}
export function getLine(threadEl: HTMLElement): LineNumber {
const lineAtt = threadEl.getAttribute('line-num');
- if (lineAtt === 'LOST') return lineAtt;
- if (!lineAtt || lineAtt === 'FILE') return FILE;
+ if (lineAtt === LOST) return lineAtt;
+ if (!lineAtt || lineAtt === FILE) return FILE;
const line = Number(lineAtt);
if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`);
if (line < 1) throw new Error(`line number smaller than 1: ${line}`);
@@ -168,7 +155,7 @@
const rangeAtt = threadEl.getAttribute('range');
if (!rangeAtt) return undefined;
const range = JSON.parse(rangeAtt) as CommentRange;
- if (!range.start_line) throw new Error(`invalid range: ${rangeAtt}`);
+ if (!range.start_line) return undefined;
return range;
}
@@ -189,20 +176,6 @@
}
/**
- * @return whether any of the lines in diff are longer
- * than SYNTAX_MAX_LINE_LENGTH.
- */
-export function anyLineTooLong(diff?: DiffInfo) {
- if (!diff) return false;
- return diff.content.some(section => {
- const lines = section.ab
- ? section.ab
- : (section.a || []).concat(section.b || []);
- return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
- });
-}
-
-/**
* Simple helper method for creating element classes in the context of
* gr-diff. This is just a super simple convenience function.
*/
@@ -380,18 +353,3 @@
return blameNode;
}
-
-/**
- * Get the approximate length of the diff as the sum of the maximum
- * length of the chunks.
- */
-export function getDiffLength(diff?: DiffInfo) {
- if (!diff) return 0;
- return diff.content.reduce((sum, sec) => {
- if (sec.ab) {
- return sum + sec.ab.length;
- } else {
- return sum + Math.max(sec.a?.length ?? 0, sec.b?.length ?? 0);
- }
- }, 0);
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 25dc768..6549230 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -4,14 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '@open-wc/testing';
-import {DiffInfo} from '../../../api/diff';
import '../../../test/common-test-setup';
-import {createDiff} from '../../../test/test-data-generators';
import {
createElementDiff,
formatText,
createTabWrapper,
- isFileUnchanged,
+ getRange,
} from './gr-diff-utils';
const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
@@ -164,35 +162,19 @@
expectTextLength('\t\t\t\t\t', 20, 100);
});
- test('isFileUnchanged', () => {
- let diff: DiffInfo = {
- ...createDiff(),
- content: [
- {a: ['abcd'], ab: ['ef']},
- {b: ['ancd'], a: ['xx']},
- ],
+ test('getRange returns undefined with start_line = 0', () => {
+ const range = {
+ start_line: 0,
+ end_line: 12,
+ start_character: 0,
+ end_character: 0,
};
- assert.equal(isFileUnchanged(diff), false);
- diff = {
- ...createDiff(),
- content: [{ab: ['abcd']}, {ab: ['ancd']}],
- };
- assert.equal(isFileUnchanged(diff), true);
- diff = {
- ...createDiff(),
- content: [
- {a: ['abcd'], ab: ['ef'], common: true},
- {b: ['ancd'], ab: ['xx']},
- ],
- };
- assert.equal(isFileUnchanged(diff), false);
- diff = {
- ...createDiff(),
- content: [
- {a: ['abcd'], ab: ['ef'], common: true},
- {b: ['ancd'], ab: ['xx'], common: true},
- ],
- };
- assert.equal(isFileUnchanged(diff), true);
+ const threadEl = document.createElement('div');
+ threadEl.className = 'comment-thread';
+ threadEl.setAttribute('diff-side', 'right');
+ threadEl.setAttribute('line-num', '1');
+ threadEl.setAttribute('range', JSON.stringify(range));
+ threadEl.setAttribute('slot', 'right-1');
+ assert.isUndefined(getRange(threadEl));
});
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 3d54690..8c1a687 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -12,7 +12,6 @@
import '../gr-syntax-themes/gr-syntax-theme';
import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
import '../gr-ranged-comment-hint/gr-ranged-comment-hint';
-import {LineNumber} from './gr-diff-line';
import {
getLine,
getLineElByChild,
@@ -25,7 +24,7 @@
rangesEqual,
getResponsiveMode,
isResponsive,
- getDiffLength,
+ isNewDiff,
} from './gr-diff-utils';
import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -50,10 +49,11 @@
import {getContentEditableRange} from '../../../utils/safari-selection-util';
import {AbortStop} from '../../../api/core';
import {
- CreateCommentEventDetail as CreateCommentEventDetailApi,
RenderPreferences,
GrDiff as GrDiffApi,
DisplayLine,
+ LineNumber,
+ LOST,
} from '../../../api/diff';
import {isSafari, toggleClass} from '../../../utils/dom-util';
import {assertIsDefined} from '../../../utils/common-util';
@@ -63,9 +63,9 @@
DELAYED_CANCELLATION,
} from '../../../utils/async-util';
import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {html, LitElement, nothing, PropertyValues} from 'lit';
import {when} from 'lit/directives/when.js';
import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
@@ -74,6 +74,8 @@
import {expandFileMode} from '../../../utils/file-util';
import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
import {provide} from '../../../models/dependency';
+import {grDiffStyles} from './gr-diff-styles';
+import {getDiffLength} from '../../../utils/diff-util';
const NO_NEWLINE_LEFT = 'No newline at end of left file.';
const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -91,11 +93,6 @@
*/
const COMMIT_MSG_LINE_LENGTH = 72;
-export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
- path: string;
-}
-
-@customElement('gr-diff')
export class GrDiff extends LitElement implements GrDiffApi {
/**
* Fired when the user selects a line.
@@ -278,718 +275,7 @@
sharedStyles,
grSyntaxTheme,
grRangedCommentTheme,
- css`
- /**
- This is used to hide all left side of the diff (e.g. diffs besides
- comments in the change log). Since we want to remove the first 4
- cells consistently in all rows except context buttons (.dividerRow).
- */
- :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
- :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
- display: none;
- }
- :host(.disable-context-control-buttons) {
- --context-control-display: none;
- }
- :host(.disable-context-control-buttons) .section {
- border-right: none;
- }
- :host(.hide-line-length-indicator) .full-width td.content .contentText {
- background-image: none;
- }
-
- :host {
- font-family: var(--monospace-font-family, ''), 'Roboto Mono';
- font-size: var(--font-size, var(--font-size-code, 12px));
- /* usually 16px = 12px + 4px */
- line-height: calc(
- var(--font-size, var(--font-size-code, 12px)) +
- var(--spacing-s, 4px)
- );
- }
-
- .thread-group {
- display: block;
- max-width: var(--content-width, 80ch);
- white-space: normal;
- background-color: var(--diff-blank-background-color);
- }
- .diffContainer {
- max-width: var(--diff-max-width, none);
- font-family: var(--monospace-font-family);
- }
- table {
- border-collapse: collapse;
- table-layout: fixed;
- }
- td.lineNum {
- /* Enforces background whenever lines wrap */
- background-color: var(--diff-blank-background-color);
- }
-
- /**
- Provides the option to add side borders (left and right) to the line
- number column.
- */
- td.lineNum,
- td.blankLineNum,
- td.moveControlsLineNumCol,
- td.contextLineNum {
- box-shadow: var(--line-number-box-shadow, unset);
- }
-
- /**
- Context controls break up the table visually, so we set the right
- border on individual sections to leave a gap for the divider.
-
- Also taken into account for max-width calculations in SHRINK_ONLY mode
- (check GrDiff.updatePreferenceStyles).
- */
- .section {
- border-right: 1px solid var(--border-color);
- }
- .section.contextControl {
- /**
- Divider inside this section must not have border; we set borders on
- the padding rows below.
- */
- border-right-width: 0;
- }
- /**
- Padding rows behind context controls. The diff is styled to be cut
- into two halves by the negative space of the divider on which the
- context control buttons are anchored.
- */
- .contextBackground {
- border-right: 1px solid var(--border-color);
- }
- .contextBackground.above {
- border-bottom: 1px solid var(--border-color);
- }
- .contextBackground.below {
- border-top: 1px solid var(--border-color);
- }
-
- .lineNumButton {
- display: block;
- width: 100%;
- height: 100%;
- background-color: var(--diff-blank-background-color);
- box-shadow: var(--line-number-box-shadow, unset);
- }
- td.lineNum {
- vertical-align: top;
- }
-
- /**
- The only way to focus this (clicking) will apply our own focus
- styling, so this default styling is not needed and distracting.
- */
- .lineNumButton:focus {
- outline: none;
- }
- gr-image-viewer {
- width: 100%;
- height: 100%;
- max-width: var(--image-viewer-max-width, 95vw);
- max-height: var(--image-viewer-max-height, 90vh);
- /**
- Defined by paper-styles default-theme and used in various
- components. background-color-secondary is a compromise between
- fairly light in light theme (where we ideally would want
- background-color-primary) yet slightly offset against the app
- background in dark mode, where drop shadows e.g. around paper-card
- are almost invisible.
- */
- --primary-background-color: var(--background-color-secondary);
- }
- .image-diff .gr-diff {
- text-align: center;
- }
- .image-diff img {
- box-shadow: var(--elevation-level-1);
- max-width: 50em;
- }
- .image-diff .right.lineNumButton {
- border-left: 1px solid var(--border-color);
- }
- .image-diff label {
- font-family: var(--font-family);
- font-style: italic;
- }
- tbody.binary-diff td {
- font-family: var(--font-family);
- font-style: italic;
- text-align: center;
- padding: var(--spacing-s) 0;
- }
- .diff-row {
- outline: none;
- user-select: none;
- }
- .diff-row.target-row.target-side-left .lineNumButton.left,
- .diff-row.target-row.target-side-right .lineNumButton.right,
- .diff-row.target-row.unified .lineNumButton {
- color: var(--primary-text-color);
- }
-
- /**
- Preparing selected line cells with position relative so it allows a
- positioned overlay with 'position: absolute'.
- */
- .target-row td {
- position: relative;
- }
-
- /**
- Defines an overlay to the selected line for drawing an outline without
- blocking user interaction (e.g. text selection).
- */
- .target-row td::before {
- border-width: 0;
- border-style: solid;
- border-color: var(--focused-line-outline-color);
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- pointer-events: none;
- user-select: none;
- content: ' ';
- }
-
- /**
- the outline for the selected content cell should be the same in all
- cases.
- */
- .target-row.target-side-left td.left.content::before,
- .target-row.target-side-right td.right.content::before,
- .unified.target-row td.content::before {
- border-width: 1px 1px 1px 0;
- }
-
- /**
- the outline for the sign cell should be always be contiguous
- top/bottom.
- */
- .target-row.target-side-left td.left.sign::before,
- .target-row.target-side-right td.right.sign::before {
- border-width: 1px 0;
- }
-
- /**
- For side-by-side we need to select the correct line number to
- "visually close" the outline.
- */
- .side-by-side.target-row.target-side-left td.left.lineNum::before,
- .side-by-side.target-row.target-side-right td.right.lineNum::before {
- border-width: 1px 0 1px 1px;
- }
-
- /**
- For unified diff we always start the overlay from the left cell
- */
- .unified.target-row td.left:not(.content)::before {
- border-width: 1px 0 1px 1px;
- }
-
- /**
- For unified diff we should continue the top/bottom border in right
- line number column.
- */
- .unified.target-row td.right:not(.content)::before {
- border-width: 1px 0;
- }
-
- .content {
- background-color: var(--diff-blank-background-color);
- }
-
- /**
- Describes two states of semantic tokens: whenever a token has a
- definition that can be navigated to (navigable) and whenever
- the token is actually clickable to perform this navigation.
- */
- .semantic-token.navigable {
- text-decoration-style: dotted;
- text-decoration-line: underline;
- }
- .semantic-token.navigable.clickable {
- text-decoration-style: solid;
- cursor: pointer;
- }
-
- /*
- The file line, which has no contentText, add some margin before the
- first comment. We cannot add padding the container because we only
- want it if there is at least one comment thread, and the slotting
- makes :empty not work as expected.
- */
- .content.file slot:first-child::slotted(.comment-thread) {
- display: block;
- margin-top: var(--spacing-xs);
- }
- .contentText {
- background-color: var(--view-background-color);
- }
- .blank {
- background-color: var(--diff-blank-background-color);
- }
- .image-diff .content {
- background-color: var(--diff-blank-background-color);
- }
- .responsive {
- width: 100%;
- }
- .responsive .contentText {
- white-space: break-spaces;
- word-break: break-all;
- }
- .lineNumButton,
- .content {
- vertical-align: top;
- white-space: pre;
- }
- .contextLineNum,
- .lineNumButton {
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
-
- color: var(--deemphasized-text-color);
- padding: 0 var(--spacing-m);
- text-align: right;
- }
- .canComment .lineNumButton {
- cursor: pointer;
- }
- .sign {
- min-width: 1ch;
- width: 1ch;
- background-color: var(--view-background-color);
- }
- .sign.blank {
- background-color: var(--diff-blank-background-color);
- }
- .content {
- /*
- Set min width since setting width on table cells still allows them
- to shrink. Do not set max width because CJK
- (Chinese-Japanese-Korean) glyphs have variable width
- */
- min-width: var(--content-width, 80ch);
- width: var(--content-width, 80ch);
- }
- .content.add .contentText .intraline,
- /* If there are no intraline info, consider everything changed */
- .content.add.no-intraline-info .contentText,
- .sign.add.no-intraline-info,
- .delta.total .content.add .contentText {
- background-color: var(--dark-add-highlight-color);
- }
- .content.add .contentText,
- .sign.add {
- background-color: var(--light-add-highlight-color);
- }
- .content.remove .contentText .intraline,
- /* If there are no intraline info, consider everything changed */
- .content.remove.no-intraline-info .contentText,
- .delta.total .content.remove .contentText,
- .sign.remove.no-intraline-info {
- background-color: var(--dark-remove-highlight-color);
- }
- .content.remove .contentText,
- .sign.remove {
- background-color: var(--light-remove-highlight-color);
- }
-
- .ignoredWhitespaceOnly .sign.no-intraline-info {
- background-color: var(--view-background-color);
- }
-
- /* dueToRebase */
- .dueToRebase .content.add .contentText .intraline,
- .delta.total.dueToRebase .content.add .contentText {
- background-color: var(--dark-rebased-add-highlight-color);
- }
- .dueToRebase .content.add .contentText {
- background-color: var(--light-rebased-add-highlight-color);
- }
- .dueToRebase .content.remove .contentText .intraline,
- .delta.total.dueToRebase .content.remove .contentText {
- background-color: var(--dark-rebased-remove-highlight-color);
- }
- .dueToRebase .content.remove .contentText {
- background-color: var(--light-rebased-remove-highlight-color);
- }
-
- /* dueToMove */
- .dueToMove .sign.add,
- .dueToMove .content.add .contentText,
- .dueToMove .moveControls.movedIn .sign.right,
- .dueToMove .moveControls.movedIn .moveHeader,
- .delta.total.dueToMove .content.add .contentText {
- background-color: var(--diff-moved-in-background);
- }
-
- .dueToMove.changed .sign.add,
- .dueToMove.changed .content.add .contentText,
- .dueToMove.changed .moveControls.movedIn .sign.right,
- .dueToMove.changed .moveControls.movedIn .moveHeader,
- .delta.total.dueToMove.changed .content.add .contentText {
- background-color: var(--diff-moved-in-changed-background);
- }
-
- .dueToMove .sign.remove,
- .dueToMove .content.remove .contentText,
- .dueToMove .moveControls.movedOut .moveHeader,
- .dueToMove .moveControls.movedOut .sign.left,
- .delta.total.dueToMove .content.remove .contentText {
- background-color: var(--diff-moved-out-background);
- }
-
- .delta.dueToMove .movedIn .moveHeader {
- --gr-range-header-color: var(--diff-moved-in-label-color);
- }
- .delta.dueToMove.changed .movedIn .moveHeader {
- --gr-range-header-color: var(--diff-moved-in-changed-label-color);
- }
- .delta.dueToMove .movedOut .moveHeader {
- --gr-range-header-color: var(--diff-moved-out-label-color);
- }
-
- .moveHeader a {
- color: inherit;
- }
-
- /* ignoredWhitespaceOnly */
- .ignoredWhitespaceOnly .content.add .contentText .intraline,
- .delta.total.ignoredWhitespaceOnly .content.add .contentText,
- .ignoredWhitespaceOnly .content.add .contentText,
- .ignoredWhitespaceOnly .content.remove .contentText .intraline,
- .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
- .ignoredWhitespaceOnly .content.remove .contentText {
- background-color: var(--view-background-color);
- }
-
- .content .contentText gr-diff-text:empty:after,
- .content .contentText gr-legacy-text:empty:after,
- .content .contentText:empty:after {
- /* Newline, to ensure empty lines are one line-height tall. */
- content: '\\A';
- }
-
- /* Context controls */
- .contextControl {
- display: var(--context-control-display, table-row-group);
- background-color: transparent;
- border: none;
- --divider-height: var(--spacing-s);
- --divider-border: 1px;
- }
- /* TODO: Is this still used? */
- .contextControl gr-button gr-icon {
- /* should match line-height of gr-button */
- font-size: var(--line-height-mono, 18px);
- }
- .contextControl td:not(.lineNumButton) {
- text-align: center;
- }
-
- /**
- Padding rows behind context controls. Styled as a continuation of the
- line gutters and code area.
- */
- .contextBackground > .contextLineNum {
- background-color: var(--diff-blank-background-color);
- }
- .contextBackground > td:not(.contextLineNum) {
- background-color: var(--view-background-color);
- }
- .contextBackground {
- /**
- One line of background behind the context expanders which they can
- render on top of, plus some padding.
- */
- height: calc(var(--line-height-normal) + var(--spacing-s));
- }
-
- .dividerCell {
- vertical-align: top;
- }
- .dividerRow.show-both .dividerCell {
- height: var(--divider-height);
- }
- .dividerRow.show-above .dividerCell,
- .dividerRow.show-above .dividerCell {
- height: 0;
- }
-
- .br:after {
- /* Line feed */
- content: '\\A';
- }
- .tab {
- display: inline-block;
- }
- .tab-indicator:before {
- color: var(--diff-tab-indicator-color);
- /* >> character */
- content: '\\00BB';
- position: absolute;
- }
- .special-char-indicator {
- /* spacing so elements don't collide */
- padding-right: var(--spacing-m);
- }
- .special-char-indicator:before {
- color: var(--diff-tab-indicator-color);
- content: '•';
- position: absolute;
- }
- .special-char-warning {
- /* spacing so elements don't collide */
- padding-right: var(--spacing-m);
- }
- .special-char-warning:before {
- color: var(--warning-foreground);
- content: '!';
- position: absolute;
- }
- /**
- Is defined after other background-colors, such that this
- rule wins in case of same specificity.
- */
- .trailing-whitespace,
- .content .contentText .trailing-whitespace,
- .trailing-whitespace .intraline,
- .content .contentText .trailing-whitespace .intraline {
- border-radius: var(--border-radius, 4px);
- background-color: var(--diff-trailing-whitespace-indicator);
- }
- #diffHeader {
- background-color: var(--table-header-background-color);
- border-bottom: 1px solid var(--border-color);
- color: var(--link-color);
- padding: var(--spacing-m) 0 var(--spacing-m) 48px;
- }
- #diffTable {
- /* for gr-selection-action-box positioning */
- position: relative;
- }
- #diffTable:focus {
- outline: none;
- }
- #loadingError,
- #sizeWarning {
- display: block;
- margin: var(--spacing-l) auto;
- max-width: 60em;
- text-align: center;
- }
- #loadingError {
- color: var(--error-text-color);
- }
- #sizeWarning gr-button {
- margin: var(--spacing-l);
- }
- .target-row td.blame {
- background: var(--diff-selection-background-color);
- }
- td.lost div {
- background-color: var(--info-background);
- }
- td.lost div.lost-message {
- font-family: var(--font-family, 'Roboto');
- font-size: var(--font-size-normal, 14px);
- line-height: var(--line-height-normal);
- padding: var(--spacing-s) 0;
- }
- td.lost div.lost-message gr-icon {
- padding: 0 var(--spacing-s) 0 var(--spacing-m);
- color: var(--blue-700);
- }
-
- col.sign,
- td.sign {
- display: none;
- }
-
- /* Sign column should only be shown in high-contrast mode. */
- :host(.with-sign-col) col.sign {
- display: table-column;
- }
- :host(.with-sign-col) td.sign {
- display: table-cell;
- }
- col.blame {
- display: none;
- }
- td.blame {
- display: none;
- padding: 0 var(--spacing-m);
- white-space: pre;
- }
- :host(.showBlame) col.blame {
- display: table-column;
- }
- :host(.showBlame) td.blame {
- display: table-cell;
- }
- td.blame > span {
- opacity: 0.6;
- }
- td.blame > span.startOfRange {
- opacity: 1;
- }
- td.blame .blameDate {
- font-family: var(--monospace-font-family);
- color: var(--link-color);
- text-decoration: none;
- }
- .responsive td.blame {
- overflow: hidden;
- width: 200px;
- }
- /** Support the line length indicator **/
- .responsive td.content .contentText {
- /**
- Same strategy as in
- https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
- */
- background-image: linear-gradient(
- var(--line-length-indicator-color),
- var(--line-length-indicator-color)
- );
- background-size: 1px 100%;
- background-position: var(--line-limit-marker) 0;
- background-repeat: no-repeat;
- }
- .newlineWarning {
- color: var(--deemphasized-text-color);
- text-align: center;
- }
- .newlineWarning.hidden {
- display: none;
- }
- .lineNum.COVERED .lineNumButton {
- color: var(
- --coverage-covered-line-num-color,
- var(--deemphasized-text-color)
- );
- background-color: var(--coverage-covered, #e0f2f1);
- }
- .lineNum.NOT_COVERED .lineNumButton {
- color: var(
- --coverage-covered-line-num-color,
- var(--deemphasized-text-color)
- );
- background-color: var(--coverage-not-covered, #ffd1a4);
- }
- .lineNum.PARTIALLY_COVERED .lineNumButton {
- color: var(
- --coverage-covered-line-num-color,
- var(--deemphasized-text-color)
- );
- background: linear-gradient(
- to right bottom,
- var(--coverage-not-covered, #ffd1a4) 0%,
- var(--coverage-not-covered, #ffd1a4) 50%,
- var(--coverage-covered, #e0f2f1) 50%,
- var(--coverage-covered, #e0f2f1) 100%
- );
- }
-
- // TODO: Investigate whether this CSS is still necessary.
- /** BEGIN: Select and copy for Polymer 2 */
- /**
- Below was copied and modified from the original css in
- gr-diff-selection.html
- */
- .content,
- .contextControl,
- .blame {
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
- }
-
- .selected-left:not(.selected-comment)
- .side-by-side
- .left
- + .content
- .contentText,
- .selected-right:not(.selected-comment)
- .side-by-side
- .right
- + .content
- .contentText,
- .selected-left:not(.selected-comment)
- .unified
- .left.lineNum
- ~ .content:not(.both)
- .contentText,
- .selected-right:not(.selected-comment)
- .unified
- .right.lineNum
- ~ .content
- .contentText,
- .selected-left.selected-comment .side-by-side .left + .content .message,
- .selected-right.selected-comment
- .side-by-side
- .right
- + .content
- .message
- :not(.collapsedContent),
- .selected-comment .unified .message :not(.collapsedContent),
- .selected-blame .blame {
- -webkit-user-select: text;
- -moz-user-select: text;
- -ms-user-select: text;
- user-select: text;
- }
-
- /** Make comments and check results selectable when selected */
- .selected-left.selected-comment
- ::slotted(.comment-thread[diff-side='left']),
- .selected-right.selected-comment
- ::slotted(.comment-thread[diff-side='right']) {
- -webkit-user-select: text;
- -moz-user-select: text;
- -ms-user-select: text;
- user-select: text;
- }
- /** END: Select and copy for Polymer 2 */
-
- .whitespace-change-only-message {
- background-color: var(--diff-context-control-background-color);
- border: 1px solid var(--diff-context-control-border-color);
- text-align: center;
- }
-
- .token-highlight {
- background-color: var(--token-highlighting-color, #fffd54);
- }
-
- gr-selection-action-box {
- /**
- * Needs z-index to appear above wrapped content, since it's inserted
- * into DOM before it.
- */
- z-index: 10;
- }
-
- gr-diff-image-new,
- gr-diff-image-old,
- gr-diff-section,
- gr-context-controls-section,
- gr-diff-row {
- display: contents;
- }
- `,
+ grDiffStyles,
];
}
@@ -1327,7 +613,7 @@
const el = e.target as Element;
if (
- el.getAttribute('data-value') !== 'LOST' &&
+ el.getAttribute('data-value') !== LOST &&
(el.classList.contains('lineNum') ||
el.classList.contains('lineNumButton'))
) {
@@ -1407,9 +693,7 @@
const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
if (!contentEl) throw new Error('content el not found for line el');
side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
- assertIsDefined(this.path, 'path');
fire(this, 'create-comment', {
- path: this.path,
side,
lineNum,
range,
@@ -1690,7 +974,7 @@
}
const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
if (!contentEl) continue;
- if (lineNum === 'LOST') {
+ if (lineNum === LOST) {
this.insertPortedCommentsWithoutRangeMessage(contentEl);
}
@@ -1825,9 +1109,15 @@
return mutations.flatMap(mutation => [...mutation.removedNodes]);
}
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (!isNewDiff()) {
+ customElements.define('gr-diff', GrDiff);
+}
+
declare global {
interface HTMLElementTagNameMap {
- 'gr-diff': GrDiff;
+ // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+ 'gr-diff': LitElement;
}
interface HTMLElementEventMap {
'comment-thread-mouseenter': CustomEvent<{}>;
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 38eecfa..e2837ab 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {strToClassName} from '../../../utils/dom-util';
import {Side} from '../../../constants/constants';
import {CommentRange} from '../../../types/common';
import {DiffLayer, DiffLayerListener} from '../../../types/types';
import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
+import {GrDiffLineType} from '../../../api/diff';
/**
* Enhanced CommentRange by UI state. Interface for incoming ranges set from the
@@ -192,7 +193,7 @@
// visible for testing
getRangesForLine(line: GrDiffLine, side: Side): CommentRangeLineLayer[] {
const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
- if (lineNum === 'FILE' || lineNum === 'LOST') return [];
+ if (typeof lineNum !== 'number') return [];
const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
return ranges.map(range => {
// Make a copy, so that the normalization below does not mess with
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 7feda47..b90d6f7 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -11,8 +11,8 @@
GrRangedCommentLayer,
} from './gr-ranged-comment-layer';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {Side} from '../../../api/diff';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType, Side} from '../../../api/diff';
import {SinonStub} from 'sinon';
import {assert} from '@open-wc/testing';
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index da08a1f..baa2ab4 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
import {DiffLayer, DiffLayerListener} from '../../../types/types';
import {Side} from '../../../constants/constants';
@@ -13,6 +13,8 @@
import {HighlightService} from '../../../services/highlight/highlight-service';
import {Provider} from '../../../models/dependency';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {GrDiffLineType} from '../../../api/diff';
+import {assert} from '../../../utils/common-util';
const LANGUAGE_MAP = new Map<string, string>([
['application/dart', 'dart'],
@@ -183,8 +185,8 @@
annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
if (!this.enabled) return;
- if (line.beforeNumber === FILE || line.afterNumber === FILE) return;
- if (line.beforeNumber === 'LOST' || line.afterNumber === 'LOST') return;
+ if (typeof line.beforeNumber !== 'number') return;
+ if (typeof line.afterNumber !== 'number') return;
let side: Side | undefined;
if (
@@ -203,6 +205,7 @@
const isLeft = side === Side.LEFT;
const lineNumber = isLeft ? line.beforeNumber : line.afterNumber;
+ assert(typeof lineNumber === 'number', 'lineNumber must be a number');
const rangesPerLine = isLeft ? this.leftRanges : this.rightRanges;
const ranges = rangesPerLine[lineNumber - 1]?.ranges ?? [];
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index f5d7fef..6461892 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -411,23 +411,21 @@
private fireShowChange() {
return combineLatest([
- this.viewModel.childView$,
this.change$,
+ this.basePatchNum$,
this.patchNum$,
this.mergeable$,
])
.pipe(
filter(
- ([childView, change, patchNum, mergeable]) =>
- childView === ChangeChildView.OVERVIEW &&
- !!change &&
- !!patchNum &&
- mergeable !== undefined
+ ([change, basePatchNum, patchNum, mergeable]) =>
+ !!change && !!basePatchNum && !!patchNum && mergeable !== undefined
)
)
- .subscribe(([_, change, patchNum, mergeable]) => {
+ .subscribe(([change, basePatchNum, patchNum, mergeable]) => {
this.pluginLoader.jsApiService.handleShowChange({
change,
+ basePatchNum,
patchNum,
// `?? null` is for the TypeScript compiler only. We have a
// `mergeable !== undefined` filter above, so this cannot happen.
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index e5d2e36..db9187a 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -22,6 +22,7 @@
waitUntilObserved,
} from '../../test/test-utils';
import {
+ BasePatchSetNum,
CommitId,
EDIT,
NumericChangeId,
@@ -221,13 +222,38 @@
});
});
- test('fireShowChange', async () => {
+ test('fireShowChange from overview', async () => {
+ await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
const pluginLoader = testResolver(pluginLoaderToken);
const jsApiService = pluginLoader.jsApiService;
const showChangeStub = sinon.stub(jsApiService, 'handleShowChange');
changeViewModel.updateState({
childView: ChangeChildView.OVERVIEW,
+ basePatchNum: 2 as BasePatchSetNum,
+ patchNum: 3 as PatchSetNumber,
+ });
+ changeModel.updateState({
+ change: createParsedChange(),
+ mergeable: true,
+ });
+
+ assert.isTrue(showChangeStub.calledOnce);
+ const detail: ShowChangeDetail = showChangeStub.lastCall.firstArg;
+ assert.equal(detail.change?._number, createParsedChange()._number);
+ assert.equal(detail.patchNum, 3 as PatchSetNumber);
+ assert.equal(detail.basePatchNum, 2 as BasePatchSetNum);
+ assert.equal(detail.info.mergeable, true);
+ });
+
+ test('fireShowChange from diff', async () => {
+ await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+ const pluginLoader = testResolver(pluginLoaderToken);
+ const jsApiService = pluginLoader.jsApiService;
+ const showChangeStub = sinon.stub(jsApiService, 'handleShowChange');
+
+ changeViewModel.updateState({
+ childView: ChangeChildView.DIFF,
patchNum: 1 as PatchSetNumber,
});
changeModel.updateState({
@@ -239,6 +265,7 @@
const detail: ShowChangeDetail = showChangeStub.lastCall.firstArg;
assert.equal(detail.change?._number, createParsedChange()._number);
assert.equal(detail.patchNum, 1 as PatchSetNumber);
+ assert.equal(detail.basePatchNum, PARENT);
assert.equal(detail.info.mergeable, true);
});
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 026e5e5..86c4b49 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -517,3 +517,12 @@
export function secondaryLinks(result?: CheckResultApi): Link[] {
return (result?.links ?? []).filter(link => !link.primary);
}
+
+export function computeIsExpandable(result?: CheckResultApi) {
+ if (!result?.summary) return false;
+ const hasMessage = !!result?.message;
+ const hasMultipleLinks = (result?.links ?? []).length > 1;
+ const hasPointers = (result?.codePointers ?? []).length > 0;
+ const hasFixes = (result?.fixes ?? []).length > 0;
+ return hasMessage || hasMultipleLinks || hasPointers || hasFixes;
+}
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index 822435d..83b4cd6 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -10,6 +10,7 @@
ALL_ATTEMPTS,
AttemptChoice,
LATEST_ATTEMPT,
+ computeIsExpandable,
rectifyFix,
sortAttemptChoices,
stringToAttemptChoice,
@@ -17,6 +18,12 @@
import {Fix, Replacement} from '../../api/checks';
import {PROVIDED_FIX_ID} from '../../utils/comment-util';
import {CommentRange} from '../../api/rest-api';
+import {
+ createCheckFix,
+ createCheckLink,
+ createCheckResult,
+ createRange,
+} from '../../test/test-data-generators';
suite('checks-util tests', () => {
setup(() => {});
@@ -107,4 +114,62 @@
];
assert.deepEqual(unsorted.sort(sortAttemptChoices), sortedExpected);
});
+
+ suite('computeIsExpandable', () => {
+ test('no message', () => {
+ assert.isFalse(computeIsExpandable(createCheckResult()));
+ });
+
+ test('no summary', () => {
+ assert.isFalse(
+ computeIsExpandable({
+ ...createCheckResult(),
+ message: 'asdf',
+ summary: undefined as unknown as string,
+ })
+ );
+ });
+
+ test('has message', () => {
+ assert.isTrue(
+ computeIsExpandable({...createCheckResult(), message: 'asdf'})
+ );
+ });
+
+ test('has just one link', () => {
+ assert.isFalse(
+ computeIsExpandable({
+ ...createCheckResult(),
+ links: [createCheckLink()],
+ })
+ );
+ });
+
+ test('has more than one link', () => {
+ assert.isTrue(
+ computeIsExpandable({
+ ...createCheckResult(),
+ links: [createCheckLink(), createCheckLink()],
+ })
+ );
+ });
+
+ test('has code pointer', () => {
+ assert.isTrue(
+ computeIsExpandable({
+ ...createCheckResult(),
+ codePointers: [{path: 'asdf', range: createRange()}],
+ })
+ );
+ });
+
+ test('has fix', () => {
+ assert.isTrue(
+ computeIsExpandable({
+ ...createCheckResult(),
+ fixes: [createCheckFix()],
+ })
+ );
+ });
+ });
});
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index ac832a1..c9b0bd9 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -41,7 +41,6 @@
import {Interaction, Timing} from '../../constants/reporting';
import {assert, assertIsDefined} from '../../utils/common-util';
import {debounce, DelayedTask} from '../../utils/async-util';
-import {pluralize} from '../../utils/string-util';
import {ReportingService} from '../../services/gr-reporting/gr-reporting';
import {Model} from '../model';
import {Deduping} from '../../api/reporting';
@@ -98,7 +97,7 @@
if (numPending === 0) {
return 'All changes saved';
}
- return `Saving ${pluralize(numPending, 'draft')}...`;
+ return undefined;
}
// Private but used in tests.
@@ -439,6 +438,7 @@
private readonly navigation: NavigationService
) {
super(initialState);
+ console.info('CommentsModel constrcutor');
this.subscriptions.push(
this.savingInProgress$.subscribe(savingInProgress => {
if (savingInProgress) {
@@ -478,6 +478,7 @@
);
this.subscriptions.push(
this.changeViewModel.changeNum$.subscribe(changeNum => {
+ console.info(`CommentsModel reload ${changeNum}`);
this.changeNum = changeNum;
this.setState({...initialState});
this.reloadAllComments();
@@ -519,8 +520,18 @@
this.setState(reducer({...this.getState()}));
}
+ override setState(state: CommentState) {
+ const commentsUndefPrev = this.getState().comments === undefined;
+ const commentsUndefNext = state.comments === undefined;
+ console.info(
+ `CommentsModel setState ${commentsUndefPrev} ${commentsUndefNext} ${this.stateUpdateInProgress}`
+ );
+ super.setState(state);
+ }
+
async reloadComments(changeNum: NumericChangeId): Promise<void> {
const comments = await this.restApiService.getDiffComments(changeNum);
+ console.info(`CommentsModel setComments ${comments === undefined}`);
this.modifyState(s => setComments(s, comments));
}
@@ -759,14 +770,10 @@
this.numPendingDraftRequests,
requestFailed
);
+ if (!message) return;
this.draftToastTask = debounce(
this.draftToastTask,
- () => {
- // Note: the event is fired on the body rather than this element because
- // this element may not be attached by the time this executes, in which
- // case the event would not bubble.
- fireAlert(document.body, message);
- },
+ () => fireAlert(document.body, message),
TOAST_DEBOUNCE_INTERVAL
);
}
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
index 19b52fc..2d0ff42 100644
--- a/polygerrit-ui/app/models/model.ts
+++ b/polygerrit-ui/app/models/model.ts
@@ -27,7 +27,7 @@
* another `next()` call. So make sure that state updates complete before
* starting another one.
*/
- private stateUpdateInProgress = false;
+ protected stateUpdateInProgress = false;
private subject$: BehaviorSubject<T>;
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 365bb16..ed472cc 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -37,7 +37,7 @@
Provider,
} from '../models/dependency';
import * as sinon from 'sinon';
-import '../styles/themes/app-theme.ts';
+import '../styles/themes/app-theme';
import {Creator} from '../services/app-context-init';
import {pluginLoaderToken} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index dce8e4b..4a0f6b8 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -104,7 +104,7 @@
SubmitRequirementStatus,
} from '../api/rest-api';
import {CheckResult, CheckRun, RunResult} from '../models/checks/checks-model';
-import {Category, RunStatus} from '../api/checks';
+import {Category, Fix, Link, LinkIcon, RunStatus} from '../api/checks';
import {DiffInfo} from '../api/diff';
import {SearchViewState} from '../models/views/search';
import {ChangeChildView, ChangeViewState} from '../models/views/change';
@@ -1147,6 +1147,29 @@
};
}
+export function createCheckFix(partial: Partial<Fix> = {}): Fix {
+ return {
+ description: 'this is a test fix',
+ replacements: [
+ {
+ path: 'testpath',
+ range: createRange(),
+ replacement: 'testreplacement',
+ },
+ ],
+ ...partial,
+ };
+}
+
+export function createCheckLink(partial: Partial<Link> = {}): Link {
+ return {
+ url: 'http://test/url',
+ primary: true,
+ icon: LinkIcon.EXTERNAL,
+ ...partial,
+ };
+}
+
export function createDetailedLabelInfo(): DetailedLabelInfo {
return {
values: {
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 6517836..40474e9 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -12,8 +12,10 @@
CommitInfo,
EditPatchSet,
PatchSetNum,
+ PatchSetNumber,
ReviewerUpdateInfo,
RevisionInfo,
+ RevisionPatchSetNum,
Timestamp,
} from './common';
@@ -89,6 +91,17 @@
return !!(x as PatchSetFile).path;
}
+export function isPatchSetNumber(
+ x?:
+ | PatchSetNum
+ | PatchSetNumber
+ | RevisionPatchSetNum
+ | BasePatchSetNum
+ | null
+): x is PatchSetNumber {
+ return !!x && Number.isInteger(x) && (x as number) > 0;
+}
+
export interface FileRange {
basePath?: string;
path: string;
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index ee1a44c..f5649b6 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -36,6 +36,7 @@
import {FormattedReviewerUpdateInfo} from '../types/types';
import {extractMentionedUsers} from './account-util';
import {assertIsDefined, uuid} from './common-util';
+import {FILE} from '../api/diff';
export function isFormattedReviewerUpdate(
message: ChangeMessage
@@ -173,7 +174,7 @@
rootId: id(comment),
};
if (!comment.line && !comment.range) {
- newThread.line = 'FILE';
+ newThread.line = FILE;
}
threads.push(newThread);
if (id(comment)) idThreadMap[id(comment)] = newThread;
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 7bf0c1e..713e6df 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -35,6 +35,7 @@
UrlEncodedCommentId,
} from '../types/common';
import {assert} from '@open-wc/testing';
+import {FILE} from '../api/diff';
suite('comment-util', () => {
test('isUnresolved', () => {
@@ -213,7 +214,7 @@
assert.equal(actualThreads[1].comments.length, 1);
assert.deepEqual(actualThreads[1].comments[0], comments[2]);
assert.equal(actualThreads[1].patchNum, 1 as RevisionPatchSetNum);
- assert.equal(actualThreads[1].line, 'FILE');
+ assert.equal(actualThreads[1].line, FILE);
});
test('derives patchNum and range', () => {
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts
new file mode 100644
index 0000000..da674df
--- /dev/null
+++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Side} from '../constants/constants';
+import {DiffInfo} from '../types/diff';
+
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+export const SYNTAX_MAX_LINE_LENGTH = 500;
+
+export function countLines(diff?: DiffInfo, side?: Side) {
+ if (!diff?.content || !side) return 0;
+ return diff.content.reduce((sum, chunk) => {
+ const sideChunk = side === Side.LEFT ? chunk.a : chunk.b;
+ return sum + (sideChunk?.length ?? chunk.ab?.length ?? chunk.skip ?? 0);
+ }, 0);
+}
+
+export function isFileUnchanged(diff: DiffInfo) {
+ return !diff.content.some(
+ content => (content.a && !content.common) || (content.b && !content.common)
+ );
+}
+
+/**
+ * @return whether any of the lines in diff are longer
+ * than SYNTAX_MAX_LINE_LENGTH.
+ */
+export function anyLineTooLong(diff?: DiffInfo) {
+ if (!diff) return false;
+ return diff.content.some(section => {
+ const lines = section.ab
+ ? section.ab
+ : (section.a || []).concat(section.b || []);
+ return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+ });
+}
+
+/**
+ * Get the approximate length of the diff as the sum of the maximum
+ * length of the chunks.
+ */
+export function getDiffLength(diff?: DiffInfo) {
+ if (!diff) return 0;
+ return diff.content.reduce((sum, sec) => {
+ if (sec.ab) {
+ return sum + sec.ab.length;
+ } else {
+ return sum + Math.max(sec.a?.length ?? 0, sec.b?.length ?? 0);
+ }
+ }, 0);
+}
diff --git a/polygerrit-ui/app/utils/diff-util_test.ts b/polygerrit-ui/app/utils/diff-util_test.ts
new file mode 100644
index 0000000..dbab76d
--- /dev/null
+++ b/polygerrit-ui/app/utils/diff-util_test.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {DiffInfo} from '../api/diff';
+import '../test/common-test-setup';
+import {createDiff} from '../test/test-data-generators';
+import {isFileUnchanged} from './diff-util';
+
+suite('diff-util tests', () => {
+ test('isFileUnchanged', () => {
+ let diff: DiffInfo = {
+ ...createDiff(),
+ content: [
+ {a: ['abcd'], ab: ['ef']},
+ {b: ['ancd'], a: ['xx']},
+ ],
+ };
+ assert.equal(isFileUnchanged(diff), false);
+ diff = {
+ ...createDiff(),
+ content: [{ab: ['abcd']}, {ab: ['ancd']}],
+ };
+ assert.equal(isFileUnchanged(diff), true);
+ diff = {
+ ...createDiff(),
+ content: [
+ {a: ['abcd'], ab: ['ef'], common: true},
+ {b: ['ancd'], ab: ['xx']},
+ ],
+ };
+ assert.equal(isFileUnchanged(diff), false);
+ diff = {
+ ...createDiff(),
+ content: [
+ {a: ['abcd'], ab: ['ef'], common: true},
+ {b: ['ancd'], ab: ['xx'], common: true},
+ ],
+ };
+ assert.equal(isFileUnchanged(diff), true);
+ });
+});
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
index 552e609..01b1a91 100644
--- a/polygerrit-ui/web-test-runner.config.mjs
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -2,15 +2,51 @@
import { defaultReporter, summaryReporter } from "@web/test-runner";
import { visualRegressionPlugin } from "@web/test-runner-visual-regression/plugin";
+function testRunnerHtmlFactory(options) {
+ const setNewDiffExp = `<script type="text/javascript">window.ENABLED_EXPERIMENTS = ['UiFeature__new_diff'];</script>`;
+ return (testFramework) => `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css">
+ <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css">
+ <link
+ rel="stylesheet"
+ href="polygerrit-ui/app/styles/material-icons.css">
+ </head>
+ <body>
+ ${options.newDiff ? setNewDiffExp : ''}
+ <script type="module" src="${testFramework}"></script>
+ </body>
+ </html>
+ `;
+}
+
/** @type {import('@web/test-runner').TestRunnerConfig} */
const config = {
files: [
"app/**/*_test.{ts,js}",
+ "!app/embed/diff-new/**/*_test.{ts,js}",
"!**/node_modules/**/*",
...(process.argv.includes("--run-screenshots")
? []
: ["!app/**/*_screenshot_test.{ts,js}"]),
],
+ // TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+ groups: [
+ {
+ name: "new-diff",
+ files: [
+ "app/embed/diff-new/**/*_test.{ts,js}",
+ "app/elements/change/gr-file-list/gr-file-list_test.{ts,js}",
+ "app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.{ts,js}",
+ "app/elements/diff/gr-diff-host/gr-diff-host_test.{ts,js}",
+ "app/elements/diff/gr-diff-view/gr-diff-view_test.{ts,js}",
+ "app/elements/shared/gr-comment-thread/gr-comment-thread_test.{ts,js}",
+ ],
+ testRunnerHtml: testRunnerHtmlFactory({newDiff: true}),
+ },
+ ],
port: 9876,
nodeResolve: true,
testFramework: { config: { ui: "tdd", timeout: 5000 } },
@@ -42,20 +78,6 @@
await next();
},
],
- testRunnerHtml: (testFramework) => `
- <!DOCTYPE html>
- <html>
- <head>
- <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css">
- <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css">
- <link
- rel="stylesheet"
- href="polygerrit-ui/app/styles/material-icons.css">
- </head>
- <body>
- <script type="module" src="${testFramework}"></script>
- </body>
- </html>
- `,
+ testRunnerHtml: testRunnerHtmlFactory({newDiff: false}),
};
export default config;
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index 0cc3da0..1772eb7 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -166,6 +166,30 @@
fi
}
+function test_preserve_link {
+ cat << EOF > input
+bla bla
+
+Link: https://myhost/id/I1234567890123456789012345678901234567890
+EOF
+
+ git config gerrit.reviewUrl https://myhost/
+ ${hook} input || fail "failed hook execution"
+ git config --unset gerrit.reviewUrl
+ found=$(grep -c '^Change-Id' input) || :
+ if [[ "${found}" != "0" ]]; then
+ fail "got ${found} Change-Ids, want 0"
+ fi
+ found=$(grep -c '^Link: https://myhost/id/I' input) || :
+ if [[ "${found}" != "1" ]]; then
+ fail "got ${found} Link footers, want 1"
+ fi
+ found=$(grep -c '^Link: https://myhost/id/I1234567890123456789012345678901234567890$' input) || :
+ if [[ "${found}" != "1" ]]; then
+ fail "got ${found} Link: https://myhost/id/I123..., want 1"
+ fi
+}
+
# Change-Id goes after existing trailers.
function test_at_end {
cat << EOF > input
diff --git a/resources/com/google/gerrit/server/mail/EmailHtml.soy b/resources/com/google/gerrit/server/mail/EmailHtml.soy
index 5b5ea63..c2c69f8 100644
--- a/resources/com/google/gerrit/server/mail/EmailHtml.soy
+++ b/resources/com/google/gerrit/server/mail/EmailHtml.soy
@@ -21,8 +21,8 @@
*/
{template EmailHtml}
{@param styles: css}
- {@param body: html}
- {@param footer: html}
+ {@param body_sections_html: list<html>}
+ {@param footer_html: html}
<!DOCTYPE html>
<html>
<head>
@@ -31,8 +31,10 @@
</style>
</head>
<body>
- {$body}
- {$footer}
+ {for $section in $body_sections_html}
+ {$section}
+ {/for}
+ {$footer_html}
</body>
</html>
{/template}
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 0154d43..5c7dffa 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -64,7 +64,7 @@
if test -n "${reviewurl}" ; then
token="Link"
value="${reviewurl%/}/id/I$random"
- pattern=".*/id/I[0-9a-f]\{40\}$"
+ pattern=".*/id/I[0-9a-f]\{40\}"
else
token="Change-Id"
value="I$random"
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 4c21ae6..7d4499a 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -20,7 +20,7 @@
# When updating Bouncy Castle, also update it in bazlets.
BC_VERS = "1.72"
HTTPCOMP_VERS = "4.5.2"
-JETTY_VERS = "9.4.49.v20220914"
+JETTY_VERS = "9.4.51.v20230217"
BYTE_BUDDY_VERSION = "1.10.7"
def java_dependencies():
@@ -121,8 +121,8 @@
# When upgrading commons-compress, also upgrade tukaani-xz
maven_jar(
name = "commons-compress",
- artifact = "org.apache.commons:commons-compress:1.20",
- sha1 = "b8df472b31e1f17c232d2ad78ceb1c84e00c641b",
+ artifact = "org.apache.commons:commons-compress:1.22",
+ sha1 = "691a8b4e6cf4248c3bc72c8b719337d5cb7359fa",
)
maven_jar(
@@ -626,50 +626,50 @@
maven_jar(
name = "jetty-servlet",
artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
- sha1 = "53ca0898f02e72b6830551031ee0062430134a05",
+ sha1 = "3ec1be0b1ca49b633dd7de0733d0054bb4763965",
)
maven_jar(
name = "jetty-security",
artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
- sha1 = "057a67eeb12078b620131664b3b7a37ea4c5aefe",
+ sha1 = "a3342214ce480cc5bb8e74fe7589dd0436a5d903",
)
maven_jar(
name = "jetty-server",
artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
- sha1 = "502f99eed028139e71a4afebefa291ace12b9c1c",
+ sha1 = "d0572c8460eb26adf8420e78535d95859c89a936",
)
maven_jar(
name = "jetty-jmx",
artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
- sha1 = "5e24afaedcc746f03fb0f60e6c0bdb2af6e6c9e8",
+ sha1 = "a69e9b0a223a5f661606f6fb36d3b3fcf6216432",
)
maven_jar(
name = "jetty-http",
artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
- sha1 = "ef1e3bde212115eb4bb0740aaf79029b624d4e30",
+ sha1 = "fe37568aded59dd8e437e0f670fe5f809071fe8f",
)
maven_jar(
name = "jetty-io",
artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
- sha1 = "cb33d9a3bdb6e2173b9b9cfc94c0b45f9a21a1af",
+ sha1 = "a11a0713b17334a5b6e694602fbd1a9457cb5fdd",
)
maven_jar(
name = "jetty-util",
artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
- sha1 = "29008dbc6dfac553d209f54193b505d73c253a41",
+ sha1 = "a11df06530a3a28c9af7ff336730a2f8e18e7205",
)
maven_jar(
name = "jetty-util-ajax",
artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
- sha1 = "3b267b5ae59b7b826d5b579f2ee8b8914b286547",
- src_sha1 = "adba851ccfbf5b2bece305d0f0bb9179852fbffb",
+ sha1 = "3b2a998a5ed1f93bc1878fa89d65e307d8b8ebaf",
+ src_sha1 = "027a15819d3fd1f18e1890bd1bf04b7d48cb3da4",
)
maven_jar(