Compare branches, tags and revisions (#1920)

Add branch/tag/revision compare to see diffs and changesets between the source and target revisions. This feature is reachable from the branch/tag detail page and also the source code view.

Co-authored-by: Florian Scholdei <florian.scholdei@cloudogu.com>
Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2022-01-20 11:00:49 +01:00
committed by GitHub
parent 6e555a855a
commit 49844d1595
41 changed files with 1488 additions and 175 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -0,0 +1,12 @@
---
Titel: Repository
Untertitel: Vergleichen
---
### Vergleich von Branches, Tags und Revisionen
In der Vergleichsansicht können Sie die Diffs und Commits zwischen zwei ausgewählten Repository-Zeigern sehen.
Wählen Sie einfach die Quelle und das Ziel aus, worauf Sie den Vergleich durchführen möchten.
Dies ist besonders nützlich, wenn Sie sehen möchten, welche Änderungen auf Ihrem Branch vor oder hinter dem Default Branch liegen.
Sie können auch sehen, was sich zwischen zwei Versionen geändert hat.
![Repository-Compare](assets/repository-compare-view.png)

View File

@@ -8,6 +8,7 @@ Der Bereich Repository umfasst alles auf Basis von Repositories in Namespaces. D
* [Branches](branches/)
* [Tags](tags/)
* [Code](code/)
* [Compare](compare/)
* [Einstellungen](settings/)
<!--- AppendLinkContentEnd -->

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@@ -0,0 +1,12 @@
---
title: Repository
subtitle: Compare
---
### Compare Branches, Tags and Revisions
In the comparison view, you can see the diffs and commits between two selected repository pointers. Simply select the source and target where you want the comparison to take place.
This is especially useful if you want to see what changes are ahead or behind the default branch in your branch.
You can also see what has changed between two versions.
![Repository-Compare](assets/repository-compare-view.png)

View File

@@ -7,6 +7,7 @@ The Repository area includes everything based on repositories in namespaces. Thi
* [Branches](branches/)
* [Tags](tags/)
* [Code](code/)
* [Compare](compare/)
* [Settings](settings/)
### Overview

View File

@@ -0,0 +1,2 @@
- type: added
description: Add compare view to see changes between branches, tags and revisions ([#1920](https://github.com/scm-manager/scm-manager/pull/1920))

View File

@@ -48,7 +48,7 @@ public class RepositoryLinkEnricher implements HalEnricher {
LinkBuilder linkBuilder = new LinkBuilder(scmPathInfoStore.get().get(), GitConfigResource.class, GitRepositoryConfigResource.class);
if (RepositoryPermissions.read(repository).isPermitted()) {
if (RepositoryPermissions.read(repository).isPermitted() && repository.getType().equals("git")) {
appender.appendLink("defaultBranch", getDefaultBranchLink(repository, linkBuilder));
}
}

View File

@@ -627,7 +627,7 @@ public final class GitUtil {
public static ObjectId computeCommonAncestor(org.eclipse.jgit.lib.Repository repository, ObjectId revision1, ObjectId revision2) throws IOException {
try (RevWalk mergeBaseWalk = new RevWalk(repository)) {
mergeBaseWalk.setRevFilter(RevFilter.MERGE_BASE);
mergeBaseWalk.markStart(mergeBaseWalk.lookupCommit(revision1));
mergeBaseWalk.markStart(mergeBaseWalk.parseCommit(revision1));
mergeBaseWalk.markStart(mergeBaseWalk.parseCommit(revision2));
RevCommit ancestor = mergeBaseWalk.next();
if (ancestor == null) {

View File

@@ -136,13 +136,13 @@ public class GitLogComputer {
if (branchId != null) {
if (startId != null) {
revWalk.markStart(revWalk.lookupCommit(startId));
revWalk.markStart(revWalk.parseCommit(startId));
} else {
revWalk.markStart(revWalk.lookupCommit(branchId));
revWalk.markStart(revWalk.parseCommit(branchId));
}
if (ancestorId != null) {
revWalk.markUninteresting(revWalk.lookupCommit(ancestorId));
revWalk.markUninteresting(revWalk.parseCommit(ancestorId));
}
Iterator<RevCommit> iterator = revWalk.iterator();

View File

@@ -50,7 +50,7 @@ import static org.mockito.Mockito.verify;
)
class RepositoryLinkEnricherTest {
private static final Repository REPOSITORY = RepositoryTestData.create42Puzzle();
private static final Repository REPOSITORY = RepositoryTestData.create42Puzzle("git");
private RepositoryLinkEnricher repositoryLinkEnricher;

View File

@@ -0,0 +1,74 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import static org.junit.Assert.assertEquals;
public class GitDiffCommandWithTagsTest extends AbstractGitCommandTestBase {
@Test
public void diffBetweenTwoTagsShouldCreateDiff() throws IOException {
GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext());
DiffCommandRequest diffCommandRequest = new DiffCommandRequest();
diffCommandRequest.setRevision("1.0.0");
diffCommandRequest.setAncestorChangeset("test-tag");
ByteArrayOutputStream output = new ByteArrayOutputStream();
gitDiffCommand.getDiffResult(diffCommandRequest).accept(output);
assertEquals("diff --git a/a.txt b/a.txt\n" +
"index 7898192..2f8bc28 100644\n" +
"--- a/a.txt\n" +
"+++ b/a.txt\n" +
"@@ -1 +1,2 @@\n" +
" a\n" +
"+line for blame\n", output.toString());
}
@Test
public void diffBetweenTagAndBranchShouldCreateDiff() throws IOException {
GitDiffCommand gitDiffCommand = new GitDiffCommand(createContext());
DiffCommandRequest diffCommandRequest = new DiffCommandRequest();
diffCommandRequest.setRevision("master");
diffCommandRequest.setAncestorChangeset("test-tag");
ByteArrayOutputStream output = new ByteArrayOutputStream();
gitDiffCommand.getDiffResult(diffCommandRequest).accept(output);
assertEquals("diff --git a/a.txt b/a.txt\n" +
"index 7898192..2f8bc28 100644\n" +
"--- a/a.txt\n" +
"+++ b/a.txt\n" +
"@@ -1 +1,2 @@\n" +
" a\n" +
"+line for blame\n", output.toString());
}
@Override
protected String getZippedRepositoryResource() {
return "sonia/scm/repository/spi/scm-git-spi-test-tags.zip";
}
}

View File

@@ -38,7 +38,6 @@ import java.io.InputStream;
import java.io.OutputStream;
/**
*
* @author Sebastian Sdorra
*/
public class HgDiffCommand extends AbstractCommand implements DiffCommand {
@@ -74,7 +73,13 @@ public class HgDiffCommand extends AbstractCommand implements DiffCommand {
if (format == DiffFormat.GIT) {
cmd.git();
}
cmd.change(HgUtil.getRevision(request.getRevision()));
String revision = HgUtil.getRevision(request.getRevision());
if (request.getAncestorChangeset() != null) {
String ancestor = HgUtil.getRevision(request.getAncestorChangeset());
cmd.cmdAppend(String.format("-r ancestor(%s,%s):%s", ancestor, revision, revision));
} else {
cmd.change(revision);
}
return cmd;
}

View File

@@ -24,38 +24,22 @@
package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult;
import sonia.scm.repository.spi.javahg.HgLogChangesetCommand;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
//~--- JDK imports ------------------------------------------------------------
public class HgLogCommand extends AbstractCommand implements LogCommand {
/**
*
* @author Sebastian Sdorra
*/
public class HgLogCommand extends AbstractCommand implements LogCommand
{
/**
* Constructs ...
*
* @param context
*
*/
HgLogCommand(HgCommandContext context)
{
HgLogCommand(HgCommandContext context) {
super(context);
}
//~--- get methods ----------------------------------------------------------
@Override
public Changeset getChangeset(String id, LogCommandRequest request) {
org.javahg.Repository repository = open();
@@ -66,17 +50,15 @@ public class HgLogCommand extends AbstractCommand implements LogCommand
@Override
public ChangesetPagingResult getChangesets(LogCommandRequest request) {
ChangesetPagingResult result = null;
ChangesetPagingResult result;
org.javahg.Repository repository = open();
if (!Strings.isNullOrEmpty(request.getPath())
||!Strings.isNullOrEmpty(request.getBranch()))
{
|| !Strings.isNullOrEmpty(request.getBranch())
|| !Strings.isNullOrEmpty(request.getAncestorChangeset())) {
result = collectSafely(repository, request);
}
else
{
} else {
int start = -1;
int end = 0;
@@ -84,133 +66,107 @@ public class HgLogCommand extends AbstractCommand implements LogCommand
String startChangeset = request.getStartChangeset();
String endChangeset = request.getEndChangeset();
if (!Strings.isNullOrEmpty(startChangeset))
{
if (!Strings.isNullOrEmpty(startChangeset)) {
start = on(repository).rev(startChangeset).singleRevision();
}
else if (!Strings.isNullOrEmpty(endChangeset))
{
} else if (!Strings.isNullOrEmpty(endChangeset)) {
end = on(repository).rev(endChangeset).singleRevision();
}
if (start < 0)
{
if (start < 0) {
start = on(repository).rev("tip").singleRevision();
}
if (start >= 0)
{
if (start >= 0) {
int total = start - end + 1;
if (request.getPagingStart() > 0)
{
if (request.getPagingStart() > 0) {
start -= request.getPagingStart();
}
if (request.getPagingLimit() > 0)
{
if (request.getPagingLimit() > 0) {
end = start - request.getPagingLimit() + 1;
}
if (end < 0)
{
if (end < 0) {
end = 0;
}
List<Changeset> changesets = on(repository).rev(start + ":"
+ end).execute();
+ end).execute();
if (request.getBranch() == null) {
result = new ChangesetPagingResult(total, changesets);
} else {
result = new ChangesetPagingResult(total, changesets, request.getBranch());
}
}
else
{
} else {
// empty repository
result = new ChangesetPagingResult(0, new ArrayList<Changeset>());
result = new ChangesetPagingResult(0, new ArrayList<>());
}
}
return result;
}
//~--- methods --------------------------------------------------------------
/**
* Method description
*
*
* @param repository
* @param request
*
* @return
*/
private ChangesetPagingResult collectSafely(
org.javahg.Repository repository, LogCommandRequest request)
{
org.javahg.Repository repository, LogCommandRequest request) {
HgLogChangesetCommand cmd = on(repository);
String startChangeset = request.getStartChangeset();
String endChangeset = request.getEndChangeset();
String ancestorChangeset = request.getAncestorChangeset();
if (!Strings.isNullOrEmpty(startChangeset)
&&!Strings.isNullOrEmpty(endChangeset))
{
if (!Strings.isNullOrEmpty(startChangeset) && !Strings.isNullOrEmpty(endChangeset)) {
cmd.rev(startChangeset.concat(":").concat(endChangeset));
}
else if (!Strings.isNullOrEmpty(endChangeset))
{
} else if (!Strings.isNullOrEmpty(startChangeset) && !Strings.isNullOrEmpty(ancestorChangeset)) {
int start = on(repository).rev(startChangeset).singleRevision();
int ancestor = on(repository).rev(ancestorChangeset).singleRevision();
cmd.rev(String.format("only(%s,%s)", start, ancestor));
} else if (!Strings.isNullOrEmpty(endChangeset)) {
cmd.rev("tip:".concat(endChangeset));
}
else if (!Strings.isNullOrEmpty(startChangeset))
{
} else if (!Strings.isNullOrEmpty(startChangeset)) {
cmd.rev(startChangeset.concat(":0"));
}
if (!Strings.isNullOrEmpty(request.getBranch()))
{
if (!Strings.isNullOrEmpty(request.getBranch())) {
cmd.branch(request.getBranch());
}
int start = request.getPagingStart();
int limit = request.getPagingLimit();
List<Changeset> changesets = null;
int total = 0;
List<Changeset> changesets;
int total;
if ((start == 0) && (limit < 0))
{
if (!Strings.isNullOrEmpty(request.getPath()))
{
if ((start == 0) && (limit < 0)) {
if (!Strings.isNullOrEmpty(request.getPath())) {
changesets = cmd.execute(request.getPath());
}
else
{
} else {
changesets = cmd.execute();
}
total = changesets.size();
}
else
{
} else {
limit = limit + start;
List<Integer> revisionList = null;
List<Integer> revisionList;
if (!Strings.isNullOrEmpty(request.getPath()))
{
if (!Strings.isNullOrEmpty(request.getPath())) {
revisionList = cmd.loadRevisions(request.getPath());
}
else
{
} else {
revisionList = cmd.loadRevisions();
}
if ((limit > revisionList.size()) || (limit < 0))
{
if (!Strings.isNullOrEmpty(request.getAncestorChangeset())) {
revisionList = Lists.reverse(revisionList);
}
if (revisionList.isEmpty()) {
return new ChangesetPagingResult(0, Collections.emptyList());
}
if ((limit > revisionList.size()) || (limit < 0)) {
limit = revisionList.size();
}
@@ -220,8 +176,7 @@ public class HgLogCommand extends AbstractCommand implements LogCommand
String[] revs = new String[sublist.size()];
for (int i = 0; i < sublist.size(); i++)
{
for (int i = 0; i < sublist.size(); i++) {
revs[i] = sublist.get(i).toString();
}
@@ -231,16 +186,7 @@ public class HgLogCommand extends AbstractCommand implements LogCommand
return new ChangesetPagingResult(total, changesets);
}
/**
* Method description
*
*
* @param repository
*
* @return
*/
private HgLogChangesetCommand on(org.javahg.Repository repository)
{
private HgLogChangesetCommand on(org.javahg.Repository repository) {
return HgLogChangesetCommand.on(repository, getContext().getConfig());
}
}

View File

@@ -62,7 +62,8 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider {
public static final Set<Feature> FEATURES = EnumSet.of(
Feature.COMBINED_DEFAULT_BRANCH,
Feature.MODIFICATIONS_BETWEEN_REVISIONS
Feature.MODIFICATIONS_BETWEEN_REVISIONS,
Feature.INCOMING_REVISION
);
private final Injector commandInjector;

View File

@@ -53,6 +53,28 @@ public class HgDiffCommandTest extends AbstractHgCommandTestBase {
assertThat(content).contains("git");
}
@Test
public void shouldCreateDiffComparedToAncestor() throws IOException {
DiffCommandRequest request = new DiffCommandRequest();
request.setRevision("3049df33fdbbded08b707bac3eccd0f7b453c58b");
request.setAncestorChangeset("a9bacaf1b7fa0cebfca71fed4e59ed69a6319427");
String content = diff(cmdContext, request);
assertThat(content)
.contains("+++ b/c/d.txt")
.contains("+++ b/c/e.txt");
}
@Test
public void shouldNotCreateDiffWithAncestorIfNoChangesExists() throws IOException {
DiffCommandRequest request = new DiffCommandRequest();
request.setRevision("a9bacaf1b7fa0cebfca71fed4e59ed69a6319427");
request.setAncestorChangeset("3049df33fdbbded08b707bac3eccd0f7b453c58b");
String content = diff(cmdContext, request);
assertThat(content).isEmpty();
}
@Test
public void shouldCloseContent() throws IOException {
HgCommandContext context = spy(cmdContext);

View File

@@ -0,0 +1,130 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import org.junit.Test;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
public class HgLogCommandAncestorTest extends AbstractHgCommandTestBase {
@Test
public void testAncestorRange() {
LogCommandRequest request = new LogCommandRequest();
request.setStartChangeset("default");
request.setAncestorChangeset("testbranch");
ChangesetPagingResult result = new HgLogCommand(cmdContext).getChangesets(request);
assertNotNull(result);
assertEquals(3, result.getTotal());
assertEquals(3, result.getChangesets().size());
Changeset c1 = result.getChangesets().get(0);
Changeset c2 = result.getChangesets().get(1);
Changeset c3 = result.getChangesets().get(2);
assertNotNull(c1);
assertEquals("94dd2a4ebc27d30f811d7ac02fbd1cc382386bf3", c1.getId());
assertNotNull(c2);
assertEquals("aed7afe001a4d5d3111a5916a5656b3032eb4dc2", c2.getId());
assertNotNull(c3);
assertEquals("03a757fea8b21879d33944b710347c46aa4cfde1", c3.getId());
}
@Test
public void testAncestorReverseRange() {
LogCommandRequest request = new LogCommandRequest();
request.setStartChangeset("testbranch");
request.setAncestorChangeset("default");
ChangesetPagingResult result = new HgLogCommand(cmdContext).getChangesets(request);
assertNotNull(result);
assertEquals(1, result.getTotal());
assertEquals(1, result.getChangesets().size());
Changeset c1 = result.getChangesets().get(0);
assertNotNull(c1);
assertEquals("d2bb8ada6ae68405627d2c757d9d656a4c21799f", c1.getId());
}
@Test
public void testAncestorRangeWithPagination() {
LogCommandRequest request = new LogCommandRequest();
request.setPagingStart(1);
request.setPagingLimit(2);
request.setStartChangeset("default");
request.setAncestorChangeset("testbranch");
ChangesetPagingResult result = new HgLogCommand(cmdContext).getChangesets(request);
assertNotNull(result);
assertEquals(3, result.getTotal());
assertEquals(2, result.getChangesets().size());
Changeset c1 = result.getChangesets().get(0);
Changeset c2 = result.getChangesets().get(1);
assertNotNull(c1);
assertEquals("aed7afe001a4d5d3111a5916a5656b3032eb4dc2", c1.getId());
assertNotNull(c2);
assertEquals("03a757fea8b21879d33944b710347c46aa4cfde1", c2.getId());
}
@Test
public void testAncestorReverseRangeWithPagination() {
LogCommandRequest request = new LogCommandRequest();
request.setPagingStart(0);
request.setPagingLimit(2);
request.setStartChangeset("testbranch");
request.setAncestorChangeset("default");
ChangesetPagingResult result = new HgLogCommand(cmdContext).getChangesets(request);
assertNotNull(result);
assertEquals(1, result.getTotal());
assertEquals(1, result.getChangesets().size());
Changeset c1 = result.getChangesets().get(0);
assertNotNull(c1);
assertEquals("d2bb8ada6ae68405627d2c757d9d656a4c21799f", c1.getId());
}
@Override
protected String getZippedRepositoryResource() {
return "sonia/scm/repository/spi/scm-hg-ahead-behind-test.zip";
}
}

View File

@@ -51,7 +51,7 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
@Test
public void testGetAll() {
ChangesetPagingResult result =
createComamnd().getChangesets(new LogCommandRequest());
createCommand().getChangesets(new LogCommandRequest());
assertNotNull(result);
assertEquals(5, result.getTotal());
@@ -64,7 +64,7 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
request.setPath("a.txt");
ChangesetPagingResult result = createComamnd().getChangesets(request);
ChangesetPagingResult result = createCommand().getChangesets(request);
assertNotNull(result);
assertEquals(3, result.getTotal());
@@ -83,7 +83,7 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
request.setPath("a.txt");
ChangesetPagingResult result = createComamnd().getChangesets(request);
ChangesetPagingResult result = createCommand().getChangesets(request);
assertNotNull(result);
assertEquals(1,
@@ -98,7 +98,7 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
request.setPagingLimit(2);
ChangesetPagingResult result = createComamnd().getChangesets(request);
ChangesetPagingResult result = createCommand().getChangesets(request);
assertNotNull(result);
assertEquals(5, result.getTotal());
@@ -122,7 +122,7 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
request.setPagingStart(1);
request.setPagingLimit(2);
ChangesetPagingResult result = createComamnd().getChangesets(request);
ChangesetPagingResult result = createCommand().getChangesets(request);
assertNotNull(result);
assertEquals(5, result.getTotal());
@@ -141,7 +141,7 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
@Test
public void testGetCommit() throws IOException {
HgLogCommand command = createComamnd();
HgLogCommand command = createCommand();
String revision = "a9bacaf1b7fa0cebfca71fed4e59ed69a6319427";
Changeset c =
command.getChangeset(revision, null);
@@ -173,7 +173,7 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
request.setStartChangeset("3049df33fdbb");
request.setEndChangeset("a9bacaf1b7fa");
ChangesetPagingResult result = createComamnd().getChangesets(request);
ChangesetPagingResult result = createCommand().getChangesets(request);
assertNotNull(result);
assertEquals(2, result.getTotal());
@@ -194,7 +194,7 @@ public class HgLogCommandTest extends AbstractHgCommandTestBase
*
* @return
*/
private HgLogCommand createComamnd()
private HgLogCommand createCommand()
{
return new HgLogCommand(cmdContext);
}

View File

@@ -0,0 +1,90 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { ChangesetCollection, Link, Repository } from "@scm-manager/ui-types";
import { useQuery, useQueryClient } from "react-query";
import { ApiResultWithFetching } from "./base";
import { apiClient } from "./apiclient";
import { changesetQueryKey } from "./changesets";
function createIncomingUrl(repository: Repository, linkName: string, source: string, target: string) {
const link = repository._links[linkName];
if ((link as Link)?.templated) {
return (link as Link).href
.replace("{source}", encodeURIComponent(source))
.replace("{target}", encodeURIComponent(target));
} else {
return (link as Link).href;
}
}
export function createChangesetUrl(repository: Repository, source: string, target: string) {
return createIncomingUrl(repository, "incomingChangesets", source, target);
}
export function createDiffUrl(repository: Repository, source: string, target: string) {
if (repository._links.incomingDiffParsed) {
return createIncomingUrl(repository, "incomingDiffParsed", source, target);
} else {
return createIncomingUrl(repository, "incomingDiff", source, target);
}
}
type UseIncomingChangesetsRequest = {
page?: string | number;
limit?: number;
};
export const useIncomingChangesets = (
repository: Repository,
source: string,
target: string,
request?: UseIncomingChangesetsRequest
): ApiResultWithFetching<ChangesetCollection> => {
const queryClient = useQueryClient();
let link = createChangesetUrl(repository, source, target);
if (request?.page || request?.limit) {
if (request?.page && request?.limit) {
link = `${link}?page=${request.page}&pageSize=${request.limit}`;
} else if (request.page) {
link = `${link}?page=${request.page}`;
} else if (request.limit) {
link = `${link}?pageSize=${request.limit}`;
}
}
return useQuery<ChangesetCollection, Error>(
["repository", repository.namespace, repository.name, "compare", source, target, "changesets", request?.page || 0],
() => apiClient.get(link).then(response => response.json()),
{
onSuccess: changesetCollection => {
changesetCollection._embedded?.changesets.forEach(changeset => {
queryClient.setQueryData(changesetQueryKey(repository, changeset.id), changeset);
});
}
}
);
};

View File

@@ -62,6 +62,7 @@ export * from "./annotations";
export * from "./search";
export * from "./loginInfo";
export * from "./usePluginCenterAuthInfo";
export * from "./compare";
export { default as ApiProvider } from "./ApiProvider";
export * from "./ApiProvider";

View File

@@ -957,6 +957,39 @@
<td><code>sync-alt</code></td>
<td>Update</td>
</tr>
<tr>
<td>
<a class="level-item">
<span class="tooltip has-tooltip-top" data-tooltip="fas fa-bell">
<i class="fas fa-lg fa-bell"></i>
</span>
</a>
</td>
<td><code>bell</code></td>
<td>Notifications</td>
</tr>
<tr>
<td>
<a class="level-item">
<span class="tooltip has-tooltip-top" data-tooltip="fas fa-shield-alt">
<i class="fas fa-lg fa-shield-alt"></i>
</span>
</a>
</td>
<td><code>shield-alt</code></td>
<td>Alert</td>
</tr>
<tr>
<td>
<a class="level-item">
<span class="tooltip has-tooltip-top" data-tooltip="fas fa-retweet">
<i class="fas fa-lg fa-retweet"></i>
</span>
</a>
</td>
<td><code>retweet</code></td>
<td>Compare</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -70,6 +70,8 @@ $tooltip-color: $scheme-main;
--scm-secondary-background: #{$scheme-main};
--scm-secondary-text: #{$white};
--scm-border: 2px solid #{$white-ter};
--scm-info-color: #{$info};
--scm-hover-color: #{$grey};
--scm-column-selection: #{$link-dark};
--sh-base-color: #fff;

View File

@@ -34,6 +34,8 @@ $button-disabled-opacity: 0.25;
--scm-secondary-background: #{$white};
--scm-secondary-text: #{$black};
--scm-border: 1px solid #dbdbdb;
--scm-info-color: #{$info};
--scm-hover-color: #{$black-ter};
--scm-column-selection: #{$link-25};
--sh-base-color: #363636;

View File

@@ -161,6 +161,31 @@
}
}
},
"compare": {
"title": "Vergleiche Änderungen",
"linkTitle": "Vergleichen mit...",
"selector": {
"title": "Branch, Tag oder Revision auswählen",
"source": "Source",
"target": "Target",
"with": "Vergleiche Änderungen mit...",
"filter": "Auswahl filtern...",
"emptyResult": "Es wurden keine dem Filter entsprechenden Ergebnisse gefunden.",
"tabs": {
"b": "Branches",
"t": "Tags",
"r": "Revision"
},
"revision": {
"input": "Revision eingeben",
"submit": "Auswählen"
}
},
"tabs": {
"diff": "Diff",
"commits": "Commits"
}
},
"tags": {
"overview": {
"title": "Übersicht aller verfügbaren Tags",

View File

@@ -161,6 +161,31 @@
}
}
},
"compare": {
"title": "Compare Changes",
"linkTitle": "Compare with...",
"selector": {
"title": "Select branch, tag or revision",
"source": "Source",
"target": "Target",
"with": "Compare changes with...",
"filter": "Filter selection...",
"emptyResult": "No results matching the filter were found.",
"tabs": {
"b": "Branches",
"t": "Tags",
"r": "Revision"
},
"revision": {
"input": "Enter revision",
"submit": "Select"
}
},
"tabs": {
"diff": "Diff",
"commits": "Commits"
}
},
"tags": {
"overview": {
"title": "Overview of All Tags",

View File

@@ -25,12 +25,13 @@ import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { Branch, Repository } from "@scm-manager/ui-types";
import { Subtitle, SmallLoadingSpinner } from "@scm-manager/ui-components";
import { SmallLoadingSpinner, Subtitle } from "@scm-manager/ui-components";
import BranchButtonGroup from "./BranchButtonGroup";
import DefaultBranchTag from "./DefaultBranchTag";
import AheadBehindTag from "./AheadBehindTag";
import { useBranchDetails } from "@scm-manager/ui-api";
import BranchCommitDateCommitter from "./BranchCommitDateCommitter";
import CompareLink from "../../compare/CompareLink";
type Props = {
repository: Repository;
@@ -50,6 +51,8 @@ const BranchDetail: FC<Props> = ({ repository, branch }) => {
aheadBehind = null;
}
const encodedBranch = encodeURIComponent(branch.name);
return (
<>
<div className="media is-align-items-center">
@@ -69,6 +72,7 @@ const BranchDetail: FC<Props> = ({ repository, branch }) => {
<BranchCommitDateCommitter branch={branch} />
</div>
</div>
<CompareLink repository={repository} source={encodedBranch} target={encodedBranch} />
<div className="media-right">
<BranchButtonGroup repository={repository} branch={branch} />
</div>

View File

@@ -21,10 +21,10 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import React, { FC, ReactNode } from "react";
import styled from "styled-components";
import { useLocation } from "react-router-dom";
import { Level, BranchSelector } from "@scm-manager/ui-components";
import { BranchSelector, Level } from "@scm-manager/ui-components";
import CodeViewSwitcher, { SwitchViewLink } from "./CodeViewSwitcher";
import { useTranslation } from "react-i18next";
import { Branch } from "@scm-manager/ui-types";
@@ -44,6 +44,9 @@ const FlexShrinkLevel = styled(Level)`
flex-shrink: 1;
margin-right: 0.75rem;
}
.level-item {
justify-content: flex-end;
}
`;
type Props = {
@@ -51,9 +54,10 @@ type Props = {
branches?: Branch[];
onSelectBranch: () => void;
switchViewLink: SwitchViewLink;
actions?: ReactNode;
};
const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, switchViewLink }) => {
const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, switchViewLink, actions }) => {
const { t } = useTranslation("repos");
const location = useLocation();
@@ -71,6 +75,7 @@ const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, sw
/>
)
}
children={actions}
right={<CodeViewSwitcher currentUrl={location.pathname} switchViewLink={switchViewLink} />}
/>
</ActionBar>

View File

@@ -0,0 +1,62 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { Icon } from "@scm-manager/ui-components";
import { CompareTypes } from "./CompareSelectBar";
type Props = {
repository: Repository;
source: string;
sourceType?: CompareTypes;
target?: string;
targetType?: CompareTypes;
};
const CompareLink: FC<Props> = ({ repository, source, sourceType = "b", target, targetType = "b" }) => {
const [t] = useTranslation("repos");
const icon = <Icon name="retweet" title={t("compare.linkTitle")} color="inherit" className="mr-2" />;
if (!target) {
return (
<Link to={`/repo/${repository.namespace}/${repository.name}/compare/${sourceType}/${source}`} className="px-1">
{icon}
</Link>
);
}
return (
<Link
to={`/repo/${repository.namespace}/${repository.name}/compare/${sourceType}/${source}/${targetType}/${target}`}
>
{icon}
</Link>
);
};
export default CompareLink;

View File

@@ -0,0 +1,61 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Redirect, Route, Switch, useRouteMatch } from "react-router-dom";
import { Repository } from "@scm-manager/ui-types";
import { useBranches } from "@scm-manager/ui-api";
import { ErrorNotification, Loading, urls } from "@scm-manager/ui-components";
import CompareView, { CompareBranchesParams } from "./CompareView";
type Props = {
repository: Repository;
baseUrl: string;
};
const CompareRoot: FC<Props> = ({ repository, baseUrl }) => {
const match = useRouteMatch<CompareBranchesParams>();
const { data, isLoading, error } = useBranches(repository);
const url = urls.matchedUrlFromMatch(match);
if (isLoading || !data) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
return (
<Switch>
<Route path={`${baseUrl}/:sourceType/:sourceName/:targetType/:targetName`}>
<CompareView repository={repository} baseUrl={baseUrl} />
</Route>
{data._embedded && (
<Redirect from={url} to={`${url}/b/${data._embedded.branches.filter(b => b.defaultBranch)[0].name}`} />
)}
</Switch>
);
};
export default CompareRoot;

View File

@@ -0,0 +1,117 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useEffect, useState } from "react";
import { useHistory, useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Repository } from "@scm-manager/ui-types";
import { devices, Icon } from "@scm-manager/ui-components";
import CompareSelector from "./CompareSelector";
import { CompareBranchesParams } from "./CompareView";
type Props = {
repository: Repository;
baseUrl: string;
};
export type CompareTypes = "b" | "t" | "r";
export type CompareFunction = (type: CompareTypes, name: string) => void;
export type CompareProps = {
type: CompareTypes;
name: string;
};
const ResponsiveIcon = styled(Icon)`
margin: 1rem 0.5rem;
transform: rotate(90deg);
@media screen and (min-width: ${devices.tablet.width}px) {
margin: 1.5rem 1rem 0;
transform: rotate(0);
}
`;
const ResponsiveBar = styled.div`
@media screen and (min-width: ${devices.tablet.width}px) {
display: flex;
justify-content: space-between;
flex-direction: row;
}
`;
const CompareSelectBar: FC<Props> = ({ repository, baseUrl }) => {
const [t] = useTranslation("repos");
const params = useParams<CompareBranchesParams>();
const location = useLocation();
const history = useHistory();
const [source, setSource] = useState<CompareProps>({
type: params?.sourceType,
name: decodeURIComponent(params?.sourceName)
});
const [target, setTarget] = useState<CompareProps>({
type: params?.targetType,
name: decodeURIComponent(params?.targetName)
});
useEffect(() => {
const tabUriComponent = location.pathname.split("/")[9];
if (source && target && tabUriComponent) {
history.push(
baseUrl +
"/" +
source.type +
"/" +
encodeURIComponent(source.name) +
"/" +
target.type +
"/" +
encodeURIComponent(target.name) +
"/" +
tabUriComponent +
"/" +
location.pathname.split("/")[10] || ""
);
}
}, [history, baseUrl, source, target, location.pathname]);
return (
<ResponsiveBar className="is-align-items-center">
<CompareSelector
repository={repository}
label={t("compare.selector.source")}
onSelect={(type, name) => setSource({ type, name })}
selected={source}
/>
<ResponsiveIcon name="arrow-right" className="fa-lg" title={t("compare.selector.with")} />
<CompareSelector
repository={repository}
label={t("compare.selector.target")}
onSelect={(type, name) => setTarget({ type, name })}
selected={target}
/>
</ResponsiveBar>
);
};
export default CompareSelectBar;

View File

@@ -0,0 +1,149 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import styled from "styled-components";
import { Repository } from "@scm-manager/ui-types";
import { devices, Icon } from "@scm-manager/ui-components";
import CompareSelectorList from "./CompareSelectorList";
import { CompareFunction, CompareProps, CompareTypes } from "./CompareSelectBar";
type Props = {
onSelect: CompareFunction;
selected: CompareProps;
label: string;
repository: Repository;
};
const ResponsiveWrapper = styled.div`
width: 100%;
justify-content: flex-start;
@media screen and (min-width: ${devices.tablet.width}px) {
justify-content: space-between;
}
`;
const BorderedMenu = styled.div`
border: var(--scm-border);
`;
const MaxWidthDiv = styled.div`
width: 100%;
`;
const CompareSelector: FC<Props> = ({ onSelect, selected, label, repository }) => {
const [t] = useTranslation("repos");
const [showDropdown, setShowDropdown] = useState(false);
const [filter, setFilter] = useState("");
const [selection, setSelection] = useState<CompareProps>(selected);
const ref = useRef<HTMLInputElement>(null);
const onSelectEntry = (type: CompareTypes, name: string) => {
setSelection({ type, name });
setShowDropdown(false);
onSelect(type, name);
};
const onMousedown = (e: Event) => {
if (ref.current && !ref.current.contains(e.target as HTMLElement)) {
setShowDropdown(false);
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.which === 27) {
// escape
setShowDropdown(false);
}
};
useEffect(() => {
window.addEventListener("mousedown", onMousedown);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("mousedown", onMousedown);
window.removeEventListener("keyup", onKeyUp);
};
});
const getActionTypeName = (type: CompareTypes) => {
switch (type) {
case "b":
return "Branch";
case "t":
return "Tag";
case "r":
return "Revision";
}
};
return (
<ResponsiveWrapper className="field mb-0 is-flex is-flex-direction-column is-fullwidth">
<label className="label">{label}</label>
<MaxWidthDiv className="control">
<MaxWidthDiv className="dropdown is-active" ref={ref}>
<MaxWidthDiv className="dropdown-trigger">
<button
className="button has-text-weight-normal has-text-secondary-more px-4 is-flex is-justify-content-space-between is-fullwidth"
onClick={() => setShowDropdown(!showDropdown)}
>
<span className="is-ellipsis-overflow">
<strong>{getActionTypeName(selection.type)}:</strong> {selection.name}
</span>
<span className="icon is-small">
<Icon name="angle-down" color="inherit" />
</span>
</button>
</MaxWidthDiv>
<div className={classNames("dropdown-menu", { "is-hidden": !showDropdown })} role="menu">
<BorderedMenu className="dropdown-content">
<div className="dropdown-item">
<h3 className="has-text-weight-bold">{t("compare.selector.title")}</h3>
</div>
<hr className="dropdown-divider my-1" />
<div className="dropdown-item px-2">
<input
className="input is-small"
placeholder={t("compare.selector.filter")}
onChange={e => setFilter(e.target.value)}
type="search"
/>
<CompareSelectorList
onSelect={onSelectEntry}
selected={selected}
repository={repository}
filter={filter}
/>
</div>
</BorderedMenu>
</div>
</MaxWidthDiv>
</MaxWidthDiv>
</ResponsiveWrapper>
);
};
export default CompareSelector;

View File

@@ -0,0 +1,272 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, KeyboardEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import styled from "styled-components";
import { Branch, Repository, Tag } from "@scm-manager/ui-types";
import { useBranches, useTags } from "@scm-manager/ui-api";
import { Button, ErrorNotification, Loading, NoStyleButton, Notification } from "@scm-manager/ui-components";
import DefaultBranchTag from "../branches/components/DefaultBranchTag";
import CompareSelectorListEntry from "./CompareSelectorListEntry";
import { CompareFunction, CompareProps, CompareTypes } from "./CompareSelectBar";
type Props = {
onSelect: CompareFunction;
selected: CompareProps;
repository: Repository;
filter: string;
};
const TabStyleButton = styled(NoStyleButton)`
align-items: center;
border-bottom: var(--scm-border);
color: var(--scm-secondary-text);
display: flex;
justify-content: center;
margin-bottom: -1px;
padding: 0.5rem 1rem;
vertical-align: top;
&:hover {
border-bottom-color: var(--scm-hover-color);
color: var(--scm-hover-color);
}
&.is-active {
border-bottom-color: var(--scm-info-color);
color: var(--scm-info-color);
}
&:focus-visible {
background-color: var(--scm-column-selection);
}
`;
const ScrollableUl = styled.ul`
max-height: 15.65rem;
width: 18.5rem;
overflow-x: hidden;
overflow-y: scroll;
`;
const SizedDiv = styled.div`
width: 18.5rem;
`;
const SmallButton = styled(Button)`
height: 1.875rem;
`;
type BranchTabContentProps = {
elements: Branch[];
selection: CompareProps;
onSelectEntry: CompareFunction;
};
const EmptyResultNotification: FC = () => {
const [t] = useTranslation("repos");
return <Notification type="info">{t("compare.selector.emptyResult")}</Notification>;
};
const BranchTabContent: FC<BranchTabContentProps> = ({ elements, selection, onSelectEntry }) => {
if (elements.length === 0) {
return <EmptyResultNotification />;
}
return (
<>
{elements.map(branch => {
return (
<CompareSelectorListEntry
isSelected={selection.type === "b" && selection.name === branch.name}
onClick={() => onSelectEntry("b", branch.name)}
key={branch.name}
>
<span className="is-ellipsis-overflow">{branch.name}</span>
<DefaultBranchTag className="ml-2" defaultBranch={branch.defaultBranch} />
</CompareSelectorListEntry>
);
})}
</>
);
};
type TagTabContentProps = {
elements: Tag[];
selection: CompareProps;
onSelectEntry: CompareFunction;
};
const TagTabContent: FC<TagTabContentProps> = ({ elements, selection, onSelectEntry }) => {
if (elements.length === 0) {
return <EmptyResultNotification />;
}
return (
<>
{elements.map(tag => (
<CompareSelectorListEntry
isSelected={selection.type === "t" && selection.name === tag.name}
onClick={() => onSelectEntry("t", tag.name)}
key={tag.name}
>
<span className="is-ellipsis-overflow">{tag.name}</span>
</CompareSelectorListEntry>
))}
</>
);
};
type RevisionTabContentProps = {
selected: CompareProps;
onSelect: CompareFunction;
};
const RevisionTabContent: FC<RevisionTabContentProps> = ({ selected, onSelect }) => {
const [t] = useTranslation("repos");
const defaultValue = selected.type === "r" ? selected.name : "";
const [revision, setRevision] = useState(defaultValue);
const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
handleSubmit();
}
};
const handleSubmit = () => {
if (revision) {
onSelect("r", revision);
}
};
return (
<SizedDiv className="mt-2">
<div className="field has-addons is-justify-content-center">
<div className="control">
<input
className="input is-small"
placeholder={t("compare.selector.revision.input")}
onChange={e => setRevision(e.target.value.trim())}
onKeyPress={handleKeyPress}
value={revision.trim()}
/>
</div>
<div className="control">
<SmallButton className="is-info is-small" action={handleSubmit} disabled={!revision}>
{t("compare.selector.revision.submit")}
</SmallButton>
</div>
</div>
</SizedDiv>
);
};
const ScrollableList: FC<{ selectedTab: CompareTypes } & Props> = ({
selectedTab,
onSelect,
selected,
repository,
filter
}) => {
const { isLoading: branchesIsLoading, error: branchesError, data: branchesData } = useBranches(repository);
const branches: Branch[] = (branchesData?._embedded?.branches as Branch[]) || [];
const { isLoading: tagsIsLoading, error: tagsError, data: tagsData } = useTags(repository);
const tags: Tag[] = (tagsData?._embedded?.tags as Tag[]) || [];
const [selection, setSelection] = useState(selected);
const onSelectEntry = (type: CompareTypes, name: string) => {
setSelection({ type, name });
onSelect(type, name);
};
if (branchesIsLoading || tagsIsLoading) {
return <Loading />;
}
if (branchesError || tagsError) {
return <ErrorNotification error={branchesError || tagsError} />;
}
if (selectedTab !== "r") {
return (
<ScrollableUl className="py-2 pr-2" aria-expanded="true" role="listbox">
{selectedTab === "b" && (
<BranchTabContent
elements={branches.filter(branch => branch.name.includes(filter))}
selection={selection}
onSelectEntry={onSelectEntry}
/>
)}
{selectedTab === "t" && (
<TagTabContent
elements={tags.filter(tag => tag.name.includes(filter))}
selection={selection}
onSelectEntry={onSelectEntry}
/>
)}
</ScrollableUl>
);
}
return null;
};
const CompareSelectorList: FC<Props> = ({ onSelect, selected, repository, filter }) => {
const [t] = useTranslation("repos");
const [selectedTab, setSelectedTab] = useState<CompareTypes>(selected.type);
const tabs: CompareTypes[] = ["b", "t", "r"];
return (
<>
<div className="tabs is-small mt-3 mb-0">
<ul>
{tabs.map(tab => {
return (
<li key={tab}>
<TabStyleButton
className={classNames({ "is-active": selectedTab === tab })}
onClick={() => setSelectedTab(tab)}
>
{t("compare.selector.tabs." + tab)}
</TabStyleButton>
</li>
);
})}
</ul>
</div>
<ScrollableList
selectedTab={selectedTab}
onSelect={onSelect}
selected={selected}
repository={repository}
filter={filter}
/>
{selectedTab === "r" && <RevisionTabContent onSelect={onSelect} selected={selected} />}
</>
);
};
export default CompareSelectorList;

View File

@@ -0,0 +1,57 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, ReactNode } from "react";
import classNames from "classnames";
import styled from "styled-components";
import { NoStyleButton } from "@scm-manager/ui-components";
type Props = {
children: ReactNode;
isSelected?: boolean;
onClick?: (event: React.MouseEvent) => void;
};
const FocusButton = styled(NoStyleButton)`
border-radius: 0.25rem;
&:focus:not(.is-active) {
background-color: var(--scm-column-selection) !important;
}
`;
const CompareSelectorListEntry: FC<Props> = ({ children, isSelected = false, onClick }) => (
<li role="option" aria-selected={isSelected}>
<FocusButton
className={classNames("dropdown-item", "is-flex", "has-text-weight-medium", "px-4", "py-2", {
"is-active": isSelected
})}
onClick={onClick}
>
{children}
</FocusButton>
</li>
);
export default CompareSelectorListEntry;

View File

@@ -0,0 +1,60 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Link, useLocation, useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { CompareBranchesParams } from "./CompareView";
type Props = {
baseUrl: string;
};
const CompareTabs: FC<Props> = ({ baseUrl }) => {
const [t] = useTranslation("repos");
const location = useLocation();
const match = useRouteMatch<CompareBranchesParams>();
const url = `${baseUrl}/${match.params.sourceType}/${match.params.sourceName}/${match.params.targetType}/${match.params.targetName}`;
const setIsActiveClassName = (path: string) => {
const regex = new RegExp(url + path);
return location.pathname.match(regex) ? "is-active" : "";
};
return (
<div className="tabs mt-5">
<ul>
<li className={setIsActiveClassName("/diff/")}>
<Link to={`${url}/diff/`}>{t("compare.tabs.diff")}</Link>
</li>
<li className={setIsActiveClassName("/changesets/")}>
<Link to={`${url}/changesets/`}>{t("compare.tabs.commits")}</Link>
</li>
</ul>
</div>
);
};
export default CompareTabs;

View File

@@ -0,0 +1,86 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Redirect, Route, Switch, useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Repository } from "@scm-manager/ui-types";
import { createDiffUrl } from "@scm-manager/ui-api";
import { LoadingDiff, Subtitle, urls } from "@scm-manager/ui-components";
import CompareSelectBar, { CompareTypes } from "./CompareSelectBar";
import CompareTabs from "./CompareTabs";
import IncomingChangesets from "./IncomingChangesets";
type Props = {
repository: Repository;
baseUrl: string;
};
export type CompareBranchesParams = {
sourceType: CompareTypes;
sourceName: string;
targetType: CompareTypes;
targetName: string;
};
const CompareRoutes: FC<Props> = ({ repository, baseUrl }) => {
const match = useRouteMatch<CompareBranchesParams>();
const url = urls.matchedUrlFromMatch(match);
const source = decodeURIComponent(match.params.sourceName);
const target = decodeURIComponent(match.params.targetName);
return (
<Switch>
<Redirect exact from={url} to={`${url}/diff/`} />
<Route path={`${baseUrl}/:sourceType/:sourceName/:targetType/:targetName/diff/`}>
<LoadingDiff url={createDiffUrl(repository, source, target) + "?format=GIT"} />
</Route>
<Route path={`${baseUrl}/:sourceType/:sourceName/:targetType/:targetName/changesets/`} exact>
<IncomingChangesets repository={repository} source={source} target={target} />
</Route>
<Route path={`${baseUrl}/:sourceType/:sourceName/:targetType/:targetName/changesets/:page`} exact>
<IncomingChangesets repository={repository} source={source} target={target} />
</Route>
</Switch>
);
};
const CompareView: FC<Props> = ({ repository, baseUrl }) => {
const [t] = useTranslation("repos");
if (!repository._links.incomingDiff) {
return null;
}
return (
<>
<Subtitle subtitle={t("compare.title")} />
<CompareSelectBar repository={repository} baseUrl={baseUrl} />
<CompareTabs baseUrl={baseUrl} />
<CompareRoutes repository={repository} baseUrl={baseUrl} />
</>
);
};
export default CompareView;

View File

@@ -0,0 +1,43 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import { Repository } from "@scm-manager/ui-types";
import { useIncomingChangesets } from "@scm-manager/ui-api";
import { ChangesetsPanel, usePage } from "../containers/Changesets";
type Props = {
repository: Repository;
source: string;
target: string;
};
const IncomingChangesets: FC<Props> = ({ repository, source, target }) => {
const page = usePage();
const { data, error, isLoading } = useIncomingChangesets(repository, source, target, { page: page - 1, limit: 25 });
return <ChangesetsPanel repository={repository} error={error} isLoading={isLoading} data={data} />;
};
export default IncomingChangesets;

View File

@@ -24,14 +24,14 @@
import React, { FC } from "react";
import { useRouteMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Branch, Repository } from "@scm-manager/ui-types";
import { Branch, ChangesetCollection, Repository } from "@scm-manager/ui-types";
import {
ChangesetList,
ErrorNotification,
urls,
LinkPaginator,
Loading,
Notification
Notification,
urls
} from "@scm-manager/ui-components";
import { useChangesets } from "@scm-manager/ui-api";
@@ -40,16 +40,29 @@ type Props = {
branch?: Branch;
};
const usePage = () => {
type ChangesetProps = Props & {
error: Error | null;
isLoading: boolean;
data?: ChangesetCollection;
};
export const usePage = () => {
const match = useRouteMatch();
return urls.getPageFromMatch(match);
};
const Changesets: FC<Props> = ({ repository, branch }) => {
const page = usePage();
const { isLoading, error, data } = useChangesets(repository, { branch, page: page - 1 });
return <ChangesetsPanel repository={repository} branch={branch} error={error} isLoading={isLoading} data={data} />;
};
export const ChangesetsPanel: FC<ChangesetProps> = ({ repository, error, isLoading, data }) => {
const [t] = useTranslation("repos");
const changesets = data?._embedded.changesets;
const page = usePage();
const changesets = data?._embedded?.changesets;
if (error) {
return <ErrorNotification error={error} />;
@@ -59,23 +72,19 @@ const Changesets: FC<Props> = ({ repository, branch }) => {
return <Loading />;
}
if (!data || !changesets || changesets.length === 0) {
return (
<div className="panel-block">
<Notification type="info">{t("changesets.noChangesets")}</Notification>
</div>
);
if (!changesets || changesets.length === 0) {
return <Notification type="info">{t("changesets.noChangesets")}</Notification>;
}
return (
<>
<div className="panel">
<div className="panel-block">
<ChangesetList repository={repository} changesets={changesets} />
</div>
<div className="panel-footer">
<LinkPaginator page={page} collection={data} />
</div>
</>
</div>
);
};

View File

@@ -25,9 +25,9 @@
import React, { FC } from "react";
import { Route, useRouteMatch, useHistory } from "react-router-dom";
import { Repository, Branch } from "@scm-manager/ui-types";
import Changesets from "./Changesets";
import CodeActionBar from "../codeSection/components/CodeActionBar";
import { urls } from "@scm-manager/ui-components";
import Changesets from "./Changesets";
type Props = {
repository: Repository;
@@ -74,11 +74,9 @@ const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranc
onSelectBranch={onSelectBranch}
switchViewLink={evaluateSwitchViewLink()}
/>
<div className="panel">
<Route path={`${url}/:page?`}>
<Changesets repository={repository} branch={branches?.filter(b => b.name === selectedBranch)[0]} />
</Route>
</div>
<Route path={`${url}/:page?`}>
<Changesets repository={repository} branch={branches?.filter(b => b.name === selectedBranch)[0]} />
</Route>
</>
);
};

View File

@@ -57,6 +57,7 @@ import CodeOverview from "../codeSection/containers/CodeOverview";
import ChangesetView from "./ChangesetView";
import SourceExtensions from "../sources/containers/SourceExtensions";
import TagsOverview from "../tags/container/TagsOverview";
import CompareRoot from "../compare/CompareRoot";
import TagRoot from "../tags/container/TagRoot";
import { useIndexLinks, useRepository } from "@scm-manager/ui-api";
import styled from "styled-components";
@@ -257,41 +258,36 @@ const RepositoryRoot = () => {
<Route path={`${url}/settings/permissions`}>
<Permissions namespaceOrRepository={repository} />
</Route>
<Route
exact
path={`${url}/code/changeset/:id`}
render={() => (
<ChangesetView repository={repository} fileControlFactoryFactory={fileControlFactoryFactory} />
)}
/>
<Route
path={`${url}/code/sourceext/:extension`}
exact={true}
render={() => <SourceExtensions repository={repository} />}
/>
<Route
path={`${url}/code/sourceext/:extension/:revision/:path*`}
render={() => <SourceExtensions repository={repository} baseUrl={`${url}/code/sources`} />}
/>
<Route exact path={`${url}/code/changeset/:id`}>
<ChangesetView repository={repository} fileControlFactoryFactory={fileControlFactoryFactory} />
</Route>
<Route path={`${url}/code/sourceext/:extension`} exact={true}>
<SourceExtensions repository={repository} />
</Route>
<Route path={`${url}/code/sourceext/:extension/:revision/:path*`}>
<SourceExtensions repository={repository} baseUrl={`${url}/code/sources`} />
</Route>
<Route path={`${url}/code`}>
<CodeOverview baseUrl={`${url}/code`} repository={repository} />
</Route>
<Route path={`${url}/branch/:branch`} render={() => <BranchRoot repository={repository} />} />
<Route
path={`${url}/branches`}
exact={true}
render={() => <BranchesOverview repository={repository} baseUrl={`${url}/branch`} />}
/>
<Route path={`${url}/branches/create`} render={() => <CreateBranch repository={repository} />} />
<Route
path={`${url}/tag/:tag`}
render={() => <TagRoot repository={repository} baseUrl={`${url}/tag`} />}
/>
<Route
path={`${url}/tags`}
exact={true}
render={() => <TagsOverview repository={repository} baseUrl={`${url}/tag`} />}
/>
<Route path={`${url}/branch/:branch`}>
<BranchRoot repository={repository} />
</Route>
<Route path={`${url}/branches`} exact={true}>
<BranchesOverview repository={repository} baseUrl={`${url}/branch`} />
</Route>
<Route path={`${url}/branches/create`}>
<CreateBranch repository={repository} />
</Route>
<Route path={`${url}/tag/:tag`}>
<TagRoot repository={repository} baseUrl={`${url}/tag`} />
</Route>
<Route path={`${url}/tags`} exact={true}>
<TagsOverview repository={repository} baseUrl={`${url}/tag`} />
</Route>
<Route path={`${url}/compare/:sourceType/:sourceName`}>
<CompareRoot repository={repository} baseUrl={`${url}/compare`} />
</Route>
<ExtensionPoint name="repository.route" props={extensionProps} renderAll={true} />
</Switch>
</PrimaryContentColumn>

View File

@@ -33,6 +33,7 @@ import CodeActionBar from "../../codeSection/components/CodeActionBar";
import replaceBranchWithRevision from "../ReplaceBranchWithRevision";
import FileSearchButton from "../../codeSection/components/FileSearchButton";
import { isEmptyDirectory, isRootFile } from "../utils/files";
import CompareLink from "../../compare/CompareLink";
type Props = {
repository: Repository;
@@ -59,7 +60,7 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
const history = useHistory();
const location = useLocation();
const [t] = useTranslation("repos");
// redirect to default branch is non branch selected
// redirect to default branch if no branch selected
useEffect(() => {
if (branches && branches.length > 0 && !selectedBranch) {
const defaultBranch = branches?.filter(b => b.defaultBranch === true)[0];
@@ -184,6 +185,11 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
branches={branches}
onSelectBranch={onSelectBranch}
switchViewLink={evaluateSwitchViewLink()}
actions={
branches && selectedBranch ? (
<CompareLink repository={repository} source={encodeURIComponent(selectedBranch)} />
) : null
}
/>
)}
{renderPanelContent()}

View File

@@ -27,6 +27,7 @@ import classNames from "classnames";
import { Repository, Tag } from "@scm-manager/ui-types";
import { Subtitle, DateFromNow, SignatureIcon } from "@scm-manager/ui-components";
import TagButtonGroup from "./TagButtonGroup";
import CompareLink from "../../compare/CompareLink";
type Props = {
repository: Repository;
@@ -36,6 +37,8 @@ type Props = {
const TagDetail: FC<Props> = ({ repository, tag }) => {
const [t] = useTranslation("repos");
const encodedTag = encodeURIComponent(tag.name);
return (
<div className="media is-align-items-center">
<div
@@ -55,6 +58,7 @@ const TagDetail: FC<Props> = ({ repository, tag }) => {
<DateFromNow className={classNames("is-size-7", "has-text-secondary")} date={tag.date} />
</div>
</div>
<CompareLink repository={repository} source={encodedTag} sourceType="t" target={encodedTag} targetType="t" />
<div className="media-right">
<TagButtonGroup repository={repository} tag={tag} />
</div>