mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-01-01 13:19:53 +01:00
Merge pull request #1357 from scm-manager/feature/subrepository_link
Add subrepository support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user