Merge pull request #1357 from scm-manager/feature/subrepository_link

Add subrepository support
This commit is contained in:
eheimbuch
2020-10-08 09:24:40 +02:00
committed by GitHub
16 changed files with 4774 additions and 3915 deletions

View File

@@ -5,7 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## unreleased
## Unreleased
### Added
- SubRepository support ([#1357](https://github.com/scm-manager/scm-manager/pull/1357))
### Fixed
- Align actionbar item horizontal and enforce correct margin between them ([#1358](https://github.com/scm-manager/scm-manager/pull/1358))
@@ -342,3 +345,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[2.4.1]: https://www.scm-manager.org/download/2.4.1
[2.5.0]: https://www.scm-manager.org/download/2.5.0
[2.6.0]: https://www.scm-manager.org/download/2.6.0
[2.6.1]: https://www.scm-manager.org/download/2.6.1

View File

@@ -244,6 +244,7 @@ class File_Printer:
self.writer.write( format % (file.path(), file.size(), date, description) )
def print_sub_repository(self, path, subrepo):
self.result_count += 1
if self.shouldPrintResult():
format = b'%s/ %s %s\n'
if self.transport:

View File

@@ -46,7 +46,6 @@ import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import static java.util.Comparator.comparing;
import static org.tmatesoft.svn.core.SVNErrorCode.FS_NO_SUCH_REVISION;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound;
@@ -54,12 +53,10 @@ import static sonia.scm.NotFoundException.notFound;
//~--- JDK imports ------------------------------------------------------------
/**
*
* @author Sebastian Sdorra
*/
public class SvnBrowseCommand extends AbstractSvnCommand
implements BrowseCommand
{
implements BrowseCommand {
/**
* the logger for SvnBrowseCommand
@@ -69,8 +66,7 @@ public class SvnBrowseCommand extends AbstractSvnCommand
private int resultCount = 0;
SvnBrowseCommand(SvnContext context)
{
SvnBrowseCommand(SvnContext context) {
super(context);
}
@@ -86,8 +82,7 @@ public class SvnBrowseCommand extends AbstractSvnCommand
BrowserResult result = null;
try
{
try {
SVNRepository svnRepository = open();
if (revisionNumber == -1) {
@@ -104,9 +99,7 @@ public class SvnBrowseCommand extends AbstractSvnCommand
result = new BrowserResult(String.valueOf(revisionNumber), root);
}
catch (SVNException ex)
{
} catch (SVNException ex) {
if (FS_NO_SUCH_REVISION.equals(ex.getErrorMessage().getErrorCode())) {
throw notFound(entity("Revision", Long.toString(revisionNumber)).in(this.repository));
}
@@ -120,9 +113,8 @@ public class SvnBrowseCommand extends AbstractSvnCommand
@SuppressWarnings("unchecked")
private void traverse(SVNRepository svnRepository, long revisionNumber, BrowseCommandRequest request,
FileObject parent, String basePath)
throws SVNException
{
FileObject parent, String basePath)
throws SVNException {
List<SVNDirEntry> entries = new ArrayList<>(svnRepository.getDir(parent.getPath(), revisionNumber, null, (Collection) null));
sort(entries, entry -> entry.getKind() == SVNNodeKind.DIR, SVNDirEntry::getName);
for (Iterator<SVNDirEntry> iterator = entries.iterator(); resultCount < request.getLimit() + request.getOffset() && iterator.hasNext(); ) {
@@ -146,16 +138,13 @@ public class SvnBrowseCommand extends AbstractSvnCommand
}
}
private String createBasePath(String path)
{
private String createBasePath(String path) {
String basePath = Util.EMPTY_STRING;
if (Util.isNotEmpty(path))
{
if (Util.isNotEmpty(path)) {
basePath = path;
if (!basePath.endsWith("/"))
{
if (!basePath.endsWith("/")) {
basePath = basePath.concat("/");
}
}
@@ -164,8 +153,7 @@ public class SvnBrowseCommand extends AbstractSvnCommand
}
private FileObject createFileObject(BrowseCommandRequest request,
SVNRepository repository, long revision, SVNDirEntry entry, String path)
{
SVNRepository repository, long revision, SVNDirEntry entry, String path) {
if (entry == null) {
throw notFound(entity("Path", path).in("Revision", Long.toString(revision)).in(this.repository));
}
@@ -175,10 +163,8 @@ public class SvnBrowseCommand extends AbstractSvnCommand
fileObject.setPath(path.concat(entry.getRelativePath()));
fileObject.setDirectory(entry.getKind() == SVNNodeKind.DIR);
if (!request.isDisableLastCommit())
{
if (entry.getDate() != null)
{
if (!request.isDisableLastCommit()) {
if (entry.getDate() != null) {
fileObject.setCommitDate(entry.getDate().getTime());
}
@@ -188,35 +174,53 @@ public class SvnBrowseCommand extends AbstractSvnCommand
fileObject.setLength(entry.getSize());
if (!request.isDisableSubRepositoryDetection() && fileObject.isDirectory()
&& entry.hasProperties())
{
&& entry.hasProperties()) {
fetchExternalsProperty(repository, revision, entry, fileObject);
}
return fileObject;
}
private boolean shouldSetExternal(String external) {
return (external.startsWith("http://") || external.startsWith("https://") || external.startsWith("../")
|| external.startsWith("^/") || external.startsWith("/"));
}
private void fetchExternalsProperty(SVNRepository repository, long revision,
SVNDirEntry entry, FileObject fileObject)
{
try
{
SVNDirEntry entry, FileObject fileObject) {
try {
SVNProperties properties = new SVNProperties();
repository.getFile(entry.getRelativePath(), revision, properties, null);
repository.getDir(entry.getRelativePath(), revision, properties, (Collection) null);
String externals = properties.getStringValue(SVNProperty.EXTERNALS);
String[] externals = properties.getStringValue(SVNProperty.EXTERNALS).split("\\r?\\n");
for (String external : externals) {
String subRepoUrl = "";
String subRepoPath = "";
for (String externalPart : external.split(" ")) {
if (shouldSetExternal(externalPart)) {
subRepoUrl = externalPart;
} else if (!externalPart.contains("-r")) {
subRepoPath = externalPart;
}
}
if (Util.isNotEmpty(externals))
{
SubRepository subRepository = new SubRepository(externals);
fileObject.setSubRepository(subRepository);
if (Util.isNotEmpty(external)) {
SubRepository subRepository = new SubRepository(subRepoUrl);
fileObject.addChild(createSubRepoDirectory(subRepository, subRepoPath));
}
}
}
catch (SVNException ex)
{
} catch (SVNException ex) {
logger.error("could not fetch file properties", ex);
}
}
private FileObject createSubRepoDirectory(SubRepository subRepository, String subRepoPath) {
FileObject subRepositoryDirectory = new FileObject();
subRepositoryDirectory.setPath(subRepoPath);
subRepositoryDirectory.setName(subRepoPath);
subRepositoryDirectory.setDirectory(true);
subRepositoryDirectory.setSubRepository(subRepository);
return subRepositoryDirectory;
}
}

View File

@@ -50,11 +50,11 @@ const HomeIcon = styled(Icon)`
const ActionBar = styled.div`
align-self: center;
/* order actionbar items horizontal */
display: flex;
justify-content: flex-start;
/* ensure space between action bar items */
& > * {
/*

File diff suppressed because it is too large Load Diff

View File

@@ -21,12 +21,12 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, {FC} from "react";
import {useTranslation} from "react-i18next";
import {Signature} from "@scm-manager/ui-types";
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Signature } from "@scm-manager/ui-types";
import styled from "styled-components";
import Icon from "../../Icon";
import {usePopover} from "../../popover";
import { usePopover } from "../../popover";
import Popover from "../../popover/Popover";
import classNames from "classnames";
@@ -45,13 +45,13 @@ const StyledIcon = styled(Icon)`
const StyledDiv = styled.div`
> *:not(:last-child) {
margin-bottom: 24px;
margin-bottom: 24px;
}
`;
const SignatureIcon: FC<Props> = ({signatures, className}) => {
const SignatureIcon: FC<Props> = ({ signatures, className }) => {
const [t] = useTranslation("repos");
const {popoverProps, triggerProps} = usePopover();
const { popoverProps, triggerProps } = usePopover();
if (!signatures.length) {
return null;
@@ -80,37 +80,60 @@ const SignatureIcon: FC<Props> = ({signatures, className}) => {
}
if (signature.status === "NOT_FOUND") {
return <p>
<div>{t("changeset.keyId")}: {signature.keyId}</div>
<div>{t("changeset.signatureStatus")}: {status}</div>
</p>;
return (
<p>
<div>
{t("changeset.keyId")}: {signature.keyId}
</div>
<div>
{t("changeset.signatureStatus")}: {status}
</div>
</p>
);
}
return <p>
<div>{t("changeset.keyId")}: {
signature._links?.rawKey ? <a href={signature._links.rawKey.href}>{signature.keyId}</a> : signature.keyId
}</div>
<div>{t("changeset.signatureStatus")}: <span className={classNames(`has-text-${getColor([signature])}`)}>{status}</span></div>
<div>{t("changeset.keyOwner")}: {signature.owner || t("changeset.noOwner")}</div>
{signature.contacts && signature.contacts.length > 0 && <>
<div>{t("changeset.keyContacts")}:</div>
{signature.contacts && signature.contacts.map(contact =>
<div>- {contact.name}{contact.mail && ` <${contact.mail}>`}</div>)}
</>}
</p>;
return (
<p>
<div>
{t("changeset.keyId")}:{" "}
{signature._links?.rawKey ? <a href={signature._links.rawKey.href}>{signature.keyId}</a> : signature.keyId}
</div>
<div>
{t("changeset.signatureStatus")}:{" "}
<span className={classNames(`has-text-${getColor([signature])}`)}>{status}</span>
</div>
<div>
{t("changeset.keyOwner")}: {signature.owner || t("changeset.noOwner")}
</div>
{signature.contacts && signature.contacts.length > 0 && (
<>
<div>{t("changeset.keyContacts")}:</div>
{signature.contacts &&
signature.contacts.map(contact => (
<div>
- {contact.name}
{contact.mail && ` <${contact.mail}>`}
</div>
))}
</>
)}
</p>
);
};
const signatureElements = signatures.map(signature => createSignatureBlock(signature));
return (
<>
<Popover title={<h1 className="has-text-weight-bold is-size-5">{t("changeset.signatures")}</h1>} width={500} {...popoverProps}>
<StyledDiv>
{signatureElements}
</StyledDiv>
<Popover
title={<h1 className="has-text-weight-bold is-size-5">{t("changeset.signatures")}</h1>}
width={500}
{...popoverProps}
>
<StyledDiv>{signatureElements}</StyledDiv>
</Popover>
<div {...triggerProps}>
<StyledIcon name="key" className={className} color={getColor(signatures)}/>
<StyledIcon name="key" className={className} color={getColor(signatures)} />
</div>
</>
);

View File

@@ -24,7 +24,6 @@
import { Links } from "./hal";
// TODO ?? check ?? links
export type SubRepository = {
repositoryUrl: string;
browserUrl: string;
@@ -39,7 +38,7 @@ export type File = {
revision: string;
length?: number;
commitDate?: string;
subRepository?: SubRepository; // TODO
subRepository?: SubRepository;
partialResult?: boolean;
computationAborted?: boolean;
truncated?: boolean;

View File

@@ -142,14 +142,16 @@
"dangerZone": "Gefahrenzone"
},
"sources": {
"file-tree": {
"fileTree": {
"name": "Name",
"length": "Größe",
"commitDate": "Commitdatum",
"description": "Beschreibung",
"branch": "Branch",
"notYetComputed": "Noch nicht berechnet; Der Wert wird in Kürze aktualisiert",
"computationAborted": "Die Berechnung dauert zu lange und wurde abgebrochen"
"computationAborted": "Die Berechnung dauert zu lange und wurde abgebrochen",
"subRepository": "Subrepository",
"folder": "Ordner",
"file": "Datei"
},
"content": {
"historyButton": "History",

View File

@@ -142,14 +142,16 @@
"dangerZone": "Danger Zone"
},
"sources": {
"file-tree": {
"fileTree": {
"name": "Name",
"length": "Length",
"commitDate": "Commit date",
"description": "Description",
"branch": "Branch",
"notYetComputed": "Not yet computed, will be updated in a short while",
"computationAborted": "The computation took too long and was aborted"
"computationAborted": "The computation took too long and was aborted",
"subRepository": "Subrepository",
"folder": "Folder",
"file": "File"
},
"content": {
"historyButton": "History",

View File

@@ -103,14 +103,16 @@
"initializeRepository": "Initialize repository"
},
"sources": {
"file-tree": {
"fileTree": {
"name": "Nombre",
"length": "Longitud",
"commitDate": "Fecha de cometer",
"description": "Descripción",
"branch": "Rama",
"notYetComputed": "Aún no calculado, se actualizará en poco tiempo",
"computationAborted": "El cálculo tomó demasiado tiempo y fue abortado"
"computationAborted": "El cálculo tomó demasiado tiempo y fue abortado",
"subRepository": "Subrepositorio",
"folder": "Carpeta",
"file": "Ficha"
},
"content": {
"historyButton": "Historia",

View File

@@ -21,24 +21,23 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC } from "react";
import { File } from "@scm-manager/ui-types";
import { Icon } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next";
type Props = {
file: File;
};
class FileIcon extends React.Component<Props> {
render() {
const { file } = this.props;
let icon = "file";
if (file.subRepository) {
icon = "folder-plus";
} else if (file.directory) {
icon = "folder";
}
return <i className={`fa fa-${icon}`} />;
const FileIcon: FC<Props> = ({ file }) => {
const [t] = useTranslation("repos");
if (file.subRepository) {
return <Icon title={t("sources.fileTree.subRepository")} iconStyle="far" name="folder" color="inherit" />;
} else if (file.directory) {
return <Icon title={t("sources.fileTree.folder")} name="folder" color="inherit" />;
}
}
return <Icon title={t("sources.fileTree.file")} name="file" color="inherit" />;
};
export default FileIcon;

View File

@@ -197,10 +197,10 @@ class FileTree extends React.Component<Props, State> {
<thead>
<tr>
<FixedWidthTh />
<th>{t("sources.file-tree.name")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.length")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.commitDate")}</th>
<th className="is-hidden-touch">{t("sources.file-tree.description")}</th>
<th>{t("sources.fileTree.name")}</th>
<th className="is-hidden-mobile">{t("sources.fileTree.length")}</th>
<th className="is-hidden-mobile">{t("sources.fileTree.commitDate")}</th>
<th className="is-hidden-touch">{t("sources.fileTree.description")}</th>
{binder.hasExtension("repos.sources.tree.row.right") && <th className="is-hidden-mobile" />}
</tr>
</thead>

View File

@@ -22,15 +22,14 @@
* SOFTWARE.
*/
import * as React from "react";
import { Link } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import classNames from "classnames";
import styled from "styled-components";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import { File } from "@scm-manager/ui-types";
import { DateFromNow, FileSize, Tooltip } from "@scm-manager/ui-components";
import { DateFromNow, FileSize, Tooltip, Icon } from "@scm-manager/ui-components";
import FileIcon from "./FileIcon";
import { Icon } from "@scm-manager/ui-components";
import { WithTranslation, withTranslation } from "react-i18next";
import FileLink from "./content/FileLink";
type Props = WithTranslation & {
file: File;
@@ -45,46 +44,21 @@ const NoWrapTd = styled.td`
white-space: nowrap;
`;
export function createLink(base: string, file: File) {
let link = base;
if (file.path) {
let path = file.path;
if (path.startsWith("/")) {
path = path.substring(1);
}
link += "/" + path;
}
if (!link.endsWith("/")) {
link += "/";
}
return link;
}
class FileTreeLeaf extends React.Component<Props> {
createLink = (file: File) => {
return createLink(this.props.baseUrl, file);
};
createFileIcon = (file: File) => {
if (file.directory) {
return (
<Link to={this.createLink(file)}>
<FileIcon file={file} />
</Link>
);
}
return (
<Link to={this.createLink(file)}>
<FileLink baseUrl={this.props.baseUrl} file={file}>
<FileIcon file={file} />
</Link>
</FileLink>
);
};
createFileName = (file: File) => {
if (file.directory) {
return <Link to={this.createLink(file)}>{file.name}</Link>;
}
return <Link to={this.createLink(file)}>{file.name}</Link>;
return (
<FileLink baseUrl={this.props.baseUrl} file={file}>
{file.name}
</FileLink>
);
};
contentIfPresent = (file: File, attribute: string, content: (file: File) => any) => {
@@ -93,14 +67,14 @@ class FileTreeLeaf extends React.Component<Props> {
return content(file);
} else if (file.computationAborted) {
return (
<Tooltip location="top" message={t("sources.file-tree.computationAborted")}>
<Icon name={"question-circle"} />
<Tooltip location="top" message={t("sources.fileTree.computationAborted")}>
<Icon name="question-circle" />
</Tooltip>
);
} else if (file.partialResult) {
return (
<Tooltip location="top" message={t("sources.file-tree.notYetComputed")}>
<Icon name={"hourglass"} />
<Tooltip location="top" message={t("sources.fileTree.notYetComputed")}>
<Icon name="hourglass" />
</Tooltip>
);
} else {

View File

@@ -22,10 +22,22 @@
* SOFTWARE.
*/
import { createLink } from "./FileTreeLeaf";
import { createRelativeLink, createFolderLink } from "./FileLink";
import { File } from "@scm-manager/ui-types";
describe("create link tests", () => {
describe("create relative link tests", () => {
it("should create relative link", () => {
expect(createRelativeLink("http://localhost:8081/scm/repo/scmadmin/scm-manager")).toBe(
"/scm/repo/scmadmin/scm-manager"
);
expect(createRelativeLink("ssh://_anonymous@repo.scm-manager.org:1234/repo/public/anonymous-access")).toBe(
"/repo/public/anonymous-access"
);
expect(createRelativeLink("ssh://server.local/project.git")).toBe("/project.git");
});
});
describe("create folder link tests", () => {
function dir(path: string): File {
return {
name: "dir",
@@ -40,13 +52,13 @@ describe("create link tests", () => {
};
}
it("should create link", () => {
expect(createLink("src", dir("main"))).toBe("src/main/");
expect(createLink("src", dir("/main"))).toBe("src/main/");
expect(createLink("src", dir("/main/"))).toBe("src/main/");
it("should create folder link", () => {
expect(createFolderLink("src", dir("main"))).toBe("src/main/");
expect(createFolderLink("src", dir("/main"))).toBe("src/main/");
expect(createFolderLink("src", dir("/main/"))).toBe("src/main/");
});
it("should return base url if the directory path is empty", () => {
expect(createLink("src", dir(""))).toBe("src/");
expect(createFolderLink("src", dir(""))).toBe("src/");
});
});

View File

@@ -0,0 +1,102 @@
/*
* 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 { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { File } from "@scm-manager/ui-types";
import { Tooltip } from "@scm-manager/ui-components";
type Props = {
baseUrl: string;
file: File;
children: ReactNode;
};
const isLocalRepository = (repositoryUrl: string) => {
let host = repositoryUrl.split("/")[2];
if (host.includes("@")) {
// remove prefix
host = host.split("@")[1];
}
// remove port
host = host.split(":")[0];
// remove query
host = host.split("?")[0];
return host === window.location.hostname;
};
export const createRelativeLink = (repositoryUrl: string) => {
const paths = repositoryUrl.split("/");
return "/" + paths.slice(3).join("/");
};
export const createFolderLink = (base: string, file: File) => {
let link = base;
if (file.path) {
let path = file.path;
if (path.startsWith("/")) {
path = path.substring(1);
}
link += "/" + path;
}
if (!link.endsWith("/")) {
link += "/";
}
return link;
};
const FileLink: FC<Props> = ({ baseUrl, file, children }) => {
const [t] = useTranslation("repos");
if (file?.subRepository?.repositoryUrl) {
// file link represents a subRepository
let link = file.subRepository.repositoryUrl;
if (file.subRepository.browserUrl) {
// replace upstream url with public browser url
link = file.subRepository.browserUrl;
}
if (link.startsWith("http://") || link.startsWith("https://")) {
if (file.subRepository.revision && isLocalRepository(link)) {
link += "/code/sources/" + file.subRepository.revision;
}
return <a href={link}>{children}</a>;
} else if (link.startsWith("ssh://") && isLocalRepository(link)) {
link = createRelativeLink(link);
if (file.subRepository.revision) {
link += "/code/sources/" + file.subRepository.revision;
}
return <Link to={link}>{children}</Link>;
} else {
// subRepository url cannot be linked
return (
<Tooltip location="top" message={t("sources.fileTree.subRepository") + ": \n" + link}>
{children}
</Tooltip>
);
}
}
// normal file or folder
return <Link to={createFolderLink(baseUrl, file)}>{children}</Link>;
};
export default FileLink;

5851
yarn.lock

File diff suppressed because it is too large Load Diff