diff --git a/docs/de/user/repo/assets/repository-branch-detailView.png b/docs/de/user/repo/assets/repository-branch-detailView.png index f9727d9d4f..498de95d1b 100644 Binary files a/docs/de/user/repo/assets/repository-branch-detailView.png and b/docs/de/user/repo/assets/repository-branch-detailView.png differ diff --git a/docs/de/user/repo/branches.md b/docs/de/user/repo/branches.md index 923deb537e..ad7d095d1a 100644 --- a/docs/de/user/repo/branches.md +++ b/docs/de/user/repo/branches.md @@ -23,6 +23,7 @@ Mit dem "Branch erstellen"-Formular können neue Branches für das Repository er ### Branch Detailseite Hier werden einige Befehle zum Arbeiten mit dem Branch auf einer Kommandozeile aufgeführt. +Die Angaben, wie viele Commits der Branch vor bzw. hinter dem Default Branch liegt, finden sich direkt unterhalb des Namens. Handelt es sich nicht um den Default Branch des Repositories, kann im unteren Bereich der Branch unwiderruflich gelöscht werden. diff --git a/docs/en/user/repo/assets/repository-branch-detailView.png b/docs/en/user/repo/assets/repository-branch-detailView.png index 28f768a6be..a6c10391c2 100644 Binary files a/docs/en/user/repo/assets/repository-branch-detailView.png and b/docs/en/user/repo/assets/repository-branch-detailView.png differ diff --git a/docs/en/user/repo/branches.md b/docs/en/user/repo/branches.md index fba7fc1381..5a8dab4bb4 100644 --- a/docs/en/user/repo/branches.md +++ b/docs/en/user/repo/branches.md @@ -22,7 +22,9 @@ New branches can be created with the "Create Branch" form. There, you have to ch ![Create Branch](assets/repository-create-branch.png) ### Branch Details Page -This page shows some commands to work with the branch on the command line. +This page shows some commands to work with the branch on the command line. The number of commits the branch is +ahead/behind the default branch can be found directly beneath the name of the branch. + If the branch is not the default branch of the repository it can be deleted using the action inside the bottom section. ![Branch Details Page](assets/repository-branch-detailView.png) diff --git a/gradle/changelog/branch_details_prs.yaml b/gradle/changelog/branch_details_prs.yaml index 9ae2349814..208b18bd67 100644 --- a/gradle/changelog/branch_details_prs.yaml +++ b/gradle/changelog/branch_details_prs.yaml @@ -1,2 +1,2 @@ - type: added - description: Show additional branch details information ([#1888](https://github.com/scm-manager/scm-manager/pull/1888)) + description: Show additional branch details information ([#1888](https://github.com/scm-manager/scm-manager/pull/1888) and [#1893](https://github.com/scm-manager/scm-manager/pull/1893)) diff --git a/scm-plugins/scm-git-plugin/src/main/js/index.ts b/scm-plugins/scm-git-plugin/src/main/js/index.ts index c8108c84dd..dc0a82613d 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/index.ts +++ b/scm-plugins/scm-git-plugin/src/main/js/index.ts @@ -41,7 +41,7 @@ export const gitPredicate = (props: any) => { }; binder.bind("repos.repository-details.information", ProtocolInformation, gitPredicate); -binder.bind("repos.branch-details.information", GitBranchInformation, gitPredicate); +binder.bind("repos.branch-details.information", GitBranchInformation, { priority: 100, predicate: gitPredicate }); binder.bind("repos.tag-details.information", GitTagInformation, gitPredicate); binder.bind("repos.repository-merge.information", GitMergeInformation, gitPredicate); binder.bind("repos.repository-avatar", GitAvatar, gitPredicate); diff --git a/scm-plugins/scm-hg-plugin/src/main/js/index.ts b/scm-plugins/scm-hg-plugin/src/main/js/index.ts index 2c37b05881..d961af2327 100644 --- a/scm-plugins/scm-hg-plugin/src/main/js/index.ts +++ b/scm-plugins/scm-hg-plugin/src/main/js/index.ts @@ -36,7 +36,7 @@ const hgPredicate = (props: any) => { }; binder.bind("repos.repository-details.information", ProtocolInformation, hgPredicate); -binder.bind("repos.branch-details.information", HgBranchInformation, hgPredicate); +binder.bind("repos.branch-details.information", HgBranchInformation, { priority: 100, predicate: hgPredicate }); binder.bind("repos.tag-details.information", HgTagInformation, hgPredicate); binder.bind("repos.repository-avatar", HgAvatar, hgPredicate); diff --git a/scm-ui/ui-api/src/branches.ts b/scm-ui/ui-api/src/branches.ts index 389a65237a..a371b4109f 100644 --- a/scm-ui/ui-api/src/branches.ts +++ b/scm-ui/ui-api/src/branches.ts @@ -25,8 +25,10 @@ import { Branch, BranchCollection, BranchCreation, + BranchDetails, BranchDetailsCollection, - Link, NamespaceAndName, + Link, + NamespaceAndName, Repository } from "@scm-manager/ui-types"; import { requiredLink } from "./links"; @@ -79,8 +81,7 @@ function chunkBranches(branches: Branch[]) { const branchDetailsQueryKey = ( repository: NamespaceAndName, - branch: string | undefined = undefined, - ...values: unknown[] + branch: string | undefined = undefined ) => { let branchName; if (!branch) { @@ -88,12 +89,13 @@ const branchDetailsQueryKey = ( } else { branchName = branch; } - return [...repoQueryKey(repository), "branch-details", branchName, ...values]; + return [...repoQueryKey(repository), "branch-details", branchName]; }; export const useBranchDetailsCollection = (repository: Repository, branches: Branch[]) => { const link = requiredLink(repository, "branchDetailsCollection"); const chunks = chunkBranches(branches); + const queryClient = useQueryClient(); const { data, isLoading, error, fetchNextPage } = useInfiniteQuery< BranchDetailsCollection, @@ -111,6 +113,12 @@ export const useBranchDetailsCollection = (repository: Repository, branches: Bra return undefined; } return allPages.length; + }, + onSuccess: newData => { + newData.pages + .flatMap(d => d._embedded?.branchDetails) + .filter(d => !!d) + .forEach(d => queryClient.setQueryData(branchDetailsQueryKey(repository, d!.branchName), () => d)); } } ); @@ -126,6 +134,12 @@ export const useBranchDetailsCollection = (repository: Repository, branches: Bra }; }; +export const useBranchDetails = (repository: Repository, branch: Branch) => { + const link = (branch._links.details as Link).href; + const queryKey = branchDetailsQueryKey(repository, branch.name); + return useQuery(queryKey, () => apiClient.get(link).then(response => response.json())); +}; + const createBranch = (link: string) => { return (branch: BranchCreation) => { return apiClient diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index af52585075..92a5e821e0 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -144,7 +144,9 @@ "defaultTag": "Default", "dangerZone": "Branch löschen", "aheadBehind": { - "tooltip": "{{ahead}} Commit(s) vor, {{behind}} Commit(s) hinter dem Default Branch" + "tooltip": "{{ahead}} Commit(s) vor, {{behind}} Commit(s) hinter dem Default Branch", + "aheadLabel": "{{count}} voraus", + "behindLabel": "{{count}} hinterher" }, "delete": { "button": "Branch löschen", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 84037077a8..e77cbc2987 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -144,7 +144,9 @@ "defaultTag": "Default", "dangerZone": "Delete Branch", "aheadBehind": { - "tooltip": "{{ahead}} commit(s) ahead, {{behind}} commit(s) behind default branch" + "tooltip": "{{ahead}} commit(s) ahead, {{behind}} commit(s) behind default branch", + "aheadLabel": "{{count}} ahead", + "behindLabel": "{{count}} behind" }, "delete": { "button": "Delete Branch", diff --git a/scm-ui/ui-webapp/src/repos/branches/components/AheadBehindTag.tsx b/scm-ui/ui-webapp/src/repos/branches/components/AheadBehindTag.tsx index 0196ac4034..904b09db4d 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/AheadBehindTag.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/AheadBehindTag.tsx @@ -32,6 +32,8 @@ import { calculateBarLength } from "./aheadBehind"; type Props = { branch: Branch; details: BranchDetails; + hiddenMobile?: boolean; + verbose?: boolean; }; type BarProps = { @@ -39,13 +41,13 @@ type BarProps = { direction: "right" | "left"; }; -const Ahead = styled.div` +const Ahead = styled.span` border-left: 1px solid gray; `; -const Behind = styled.div``; +const Behind = styled.span``; -const Count = styled.div` +const Count = styled.span` word-break: keep-all; `; @@ -63,9 +65,10 @@ const Bar = styled.span.attrs(props => ({ const TooltipWithDefaultCursor = styled(Tooltip)` cursor: default !important; + width: 100%; `; -const AheadBehindTag: FC = ({ branch, details }) => { +const AheadBehindTag: FC = ({ branch, details, hiddenMobile, verbose }) => { const [t] = useTranslation("repos"); if ( @@ -76,14 +79,22 @@ const AheadBehindTag: FC = ({ branch, details }) => { return null; } + const behindText = verbose + ? t("branch.aheadBehind.behindLabel", { count: details.changesetsBehind }) + : details.changesetsBehind; + const aheadText = verbose + ? t("branch.aheadBehind.aheadLabel", { count: details.changesetsAhead }) + : details.changesetsAhead; + return ( - -
+
+ - {details.changesetsBehind} + {behindText} = ({ branch, details }) => { /> - {details.changesetsAhead} + {aheadText} -
- + +
); }; diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchDetail.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchDetail.tsx index 9b3383862c..583171b5ce 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchDetail.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchDetail.tsx @@ -25,9 +25,11 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { Branch, Repository } from "@scm-manager/ui-types"; -import { Subtitle, DateFromNow } from "@scm-manager/ui-components"; +import { Subtitle, DateFromNow, SmallLoadingSpinner } from "@scm-manager/ui-components"; import BranchButtonGroup from "./BranchButtonGroup"; import DefaultBranchTag from "./DefaultBranchTag"; +import AheadBehindTag from "./AheadBehindTag"; +import { useBranchDetails } from "@scm-manager/ui-api"; type Props = { repository: Repository; @@ -36,30 +38,43 @@ type Props = { const BranchDetail: FC = ({ repository, branch }) => { const [t] = useTranslation("repos"); + const { data, isLoading } = useBranchDetails(repository, branch); + + let aheadBehind; + if (isLoading) { + aheadBehind = ; + } else if (data) { + aheadBehind = ; + } else { + aheadBehind = null; + } return ( -
-
- {t("branch.name")} {branch.name} - -
- {t("branches.overview.lastCommit")}{" "} - + <> +
+
+ {t("branch.name")} {branch.name} + +
+ {t("branches.overview.lastCommit")}{" "} + +
+
+
+
-
- -
-
+ {aheadBehind} + ); }; diff --git a/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx b/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx index 50d77c31a3..02d97c43a9 100644 --- a/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/components/BranchRow.tsx @@ -62,7 +62,7 @@ const BranchRow: FC = ({ repository, baseUrl, branch, onDelete, details } return ; } if (details) { - return ; + return ; } return ; }; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java index 01b5d45785..e6b5f45fa1 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java @@ -35,6 +35,9 @@ import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Person; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.api.Command; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.web.EdisonHalAppender; import javax.inject.Inject; @@ -50,6 +53,8 @@ public abstract class BranchToBranchDtoMapper extends HalAppenderMapper implemen @Inject private ResourceLinks resourceLinks; + @Inject + private RepositoryServiceFactory serviceFactory; @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes public abstract BranchDto map(Branch branch, @Context Repository repository); @@ -69,6 +74,12 @@ public abstract class BranchToBranchDtoMapper extends HalAppenderMapper implemen linksBuilder.single(linkBuilder("delete", resourceLinks.branch().delete(repository.getNamespace(), repository.getName(), branch.getName())).build()); } + try (RepositoryService service = serviceFactory.create(repository)) { + if (service.isSupported(Command.BRANCH_DETAILS)) { + linksBuilder.single(linkBuilder("details", resourceLinks.branchDetails().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), branch.getName())).build()); + } + } + Embedded.Builder embeddedBuilder = Embedded.embeddedBuilder(); applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), branch, namespaceAndName); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java index c42d363686..03442a664b 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapperTest.java @@ -28,6 +28,7 @@ import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -37,15 +38,20 @@ import sonia.scm.repository.Branch; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.api.Command; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; import java.net.URI; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class BranchToBranchDtoMapperTest { + public static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold(); private final URI baseUri = URI.create("https://hitchhiker.com/api/"); @SuppressWarnings("unused") // Is injected @@ -53,6 +59,10 @@ class BranchToBranchDtoMapperTest { @Mock private Subject subject; + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private RepositoryService service; @InjectMocks private BranchToBranchDtoMapperImpl mapper; @@ -62,6 +72,11 @@ class BranchToBranchDtoMapperTest { ThreadContext.bind(subject); } + @BeforeEach + void initService() { + when(serviceFactory.create(REPOSITORY)).thenReturn(service); + } + @AfterEach void tearDown() { ThreadContext.unbindSubject(); @@ -80,37 +95,69 @@ class BranchToBranchDtoMapperTest { Branch branch = Branch.normalBranch("master", "42"); - BranchDto dto = mapper.map(branch, RepositoryTestData.createHeartOfGold()); + BranchDto dto = mapper.map(branch, REPOSITORY); assertThat(dto.getLinks().getLinkBy("ka").get().getHref()).isEqualTo("http://hitchhiker/HeartOfGold/master"); } - @Test - void shouldAppendDeleteLink() { - Repository repository = RepositoryTestData.createHeartOfGold(); - when(subject.isPermitted("repository:push:" + repository.getId())).thenReturn(true); - Branch branch = Branch.normalBranch("master", "42"); + @Nested + class WithPushPermissions { - BranchDto dto = mapper.map(branch, repository); - assertThat(dto.getLinks().getLinkBy("delete").get().getHref()).isEqualTo("https://hitchhiker.com/api/v2/repositories/hitchhiker/HeartOfGold/branches/master"); - } + @BeforeEach + void setPermission() { + lenient().when(subject.isPermitted("repository:push:" + REPOSITORY.getId())).thenReturn(true); + } - @Test - void shouldNotAppendDeleteLinkIfDefaultBranch() { - Repository repository = RepositoryTestData.createHeartOfGold(); - Branch branch = Branch.defaultBranch("master", "42"); + @Test + void shouldAppendDefaultLink() { + Branch branch = Branch.normalBranch("master", "42"); - BranchDto dto = mapper.map(branch, repository); - assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent(); + BranchDto dto = mapper.map(branch, REPOSITORY); + assertThat(dto.getLinks().getLinkBy("history")).isNotEmpty(); + assertThat(dto.getLinks().getLinkBy("changeset")).isNotEmpty(); + assertThat(dto.getLinks().getLinkBy("source")).isNotEmpty(); + } + + @Test + void shouldAppendDeleteLink() { + Branch branch = Branch.normalBranch("master", "42"); + + BranchDto dto = mapper.map(branch, REPOSITORY); + assertThat(dto.getLinks().getLinkBy("delete").get().getHref()).isEqualTo("https://hitchhiker.com/api/v2/repositories/hitchhiker/HeartOfGold/branches/master"); + } + + @Test + void shouldNotAppendDeleteLinkIfDefaultBranch() { + Branch branch = Branch.defaultBranch("master", "42"); + + BranchDto dto = mapper.map(branch, REPOSITORY); + assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent(); + } + + @Test + void shouldAppendDetailsLinkIfDetailsCommandIsSupported() { + Branch branch = Branch.normalBranch("master", "42"); + when(service.isSupported(Command.BRANCH_DETAILS)).thenReturn(true); + + BranchDto dto = mapper.map(branch, REPOSITORY); + assertThat(dto.getLinks().getLinkBy("details")).isNotEmpty(); + } + + @Test + void shouldNotAppendDetailsLinkIfDetailsCommandIsNotSupported() { + Branch branch = Branch.normalBranch("master", "42"); + when(service.isSupported(Command.BRANCH_DETAILS)).thenReturn(false); + + BranchDto dto = mapper.map(branch, REPOSITORY); + assertThat(dto.getLinks().getLinkBy("details")).isNotPresent(); + } } @Test void shouldNotAppendDeleteLinkIfNotPermitted() { - Repository repository = RepositoryTestData.createHeartOfGold(); - when(subject.isPermitted("repository:push:" + repository.getId())).thenReturn(false); + when(subject.isPermitted("repository:push:" + REPOSITORY.getId())).thenReturn(false); Branch branch = Branch.normalBranch("master", "42"); - BranchDto dto = mapper.map(branch, repository); + BranchDto dto = mapper.map(branch, REPOSITORY); assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent(); } - }