mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-09 15:05:44 +01:00
Filepath search (#1568)
Add search for files to the sources view. The search is only for finding file paths. It does not search any file metadata nor the content. Results get a rating, where file names are rated higher than file paths. The results are sorted by the score and the first 50 results are displayed. Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
BIN
docs/de/user/repo/assets/repository-code-filepathsearch.png
Normal file
BIN
docs/de/user/repo/assets/repository-code-filepathsearch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 634 KiB |
@@ -17,6 +17,15 @@ Es gibt unter dem Aktionsbalken eine Breadcrumbs Navigation, die den Pfad der an
|
||||
Über den Button auf der linken Seite der Breadcrumbs Navigation kann ein permanenter Link
|
||||
zum aktuellen Pfad in die Zwischenablage kopiert werden.
|
||||
|
||||
#### Dateinamen Suche
|
||||
|
||||
Die Dateinamen Suche kann über das Such Icon neben dem Dateipfad geöffnet werden.
|
||||
Die Suche bezieht sich ausschließlich auf den Dateipfad und nicht auf Dateiinhalte.
|
||||
Bei der Suche werden Treffer im Dateinamen höher gewertet als Suchtreffer im Dateipfad.
|
||||
Sobald mehr als ein Zeichen eingegeben wurde, startet die Suche automatisch und zeigt die Ergebnisse unterhalb des Textfeldes an.
|
||||
|
||||

|
||||
|
||||
### Changesets
|
||||
Die Übersicht der Changesets/Commits zeigt die Änderungshistorie je Branch an. Jeder Listeneintrag stellt einen Commit dar.
|
||||
|
||||
|
||||
BIN
docs/en/user/repo/assets/repository-code-filepathsearch.png
Normal file
BIN
docs/en/user/repo/assets/repository-code-filepathsearch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 638 KiB |
@@ -17,6 +17,17 @@ Below the action bar is a breadcrumb navigation that shows the path of the files
|
||||
By clicking the Button on the left-hand side of the breadcrumbs navigation, a permalink to the active path is
|
||||
automatically copied to the user's clipboard.
|
||||
|
||||
#### Search
|
||||
|
||||
To search for a file you can click on the search icon next to the file path.
|
||||
On the file search page you can enter the text you are looking for.
|
||||
The search refers exclusively to the file path and
|
||||
hits in the filename are evaluated higher than hits in the path.
|
||||
The search starts automatically as soon as more than one character have been entered.
|
||||
The results are displayed below the text field.
|
||||
|
||||

|
||||
|
||||
### Changesets
|
||||
The changesets/commits overview shows the change history of the branch. Each entry represents a commit.
|
||||
|
||||
|
||||
2
gradle/changelog/filepath_search.yaml
Normal file
2
gradle/changelog/filepath_search.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Added filepath search ([#1568](https://github.com/scm-manager/scm-manager/issues/1568))
|
||||
@@ -80,6 +80,7 @@ public class VndMediaType {
|
||||
public static final String ME = PREFIX + "me" + SUFFIX;
|
||||
public static final String SOURCE = PREFIX + "source" + SUFFIX;
|
||||
public static final String ANNOTATE = PREFIX + "annotate" + SUFFIX;
|
||||
public static final String REPOSITORY_PATHS = PREFIX + "repositoryPaths" + SUFFIX;
|
||||
public static final String ADMIN_INFO = PREFIX + "adminInfo" + SUFFIX;
|
||||
public static final String ERROR_TYPE = PREFIX + "error" + SUFFIX;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ExportInfo,
|
||||
Link,
|
||||
Namespace,
|
||||
Paths,
|
||||
Repository,
|
||||
RepositoryCollection,
|
||||
RepositoryCreation,
|
||||
@@ -316,3 +317,10 @@ export const useExportRepository = () => {
|
||||
data
|
||||
};
|
||||
};
|
||||
|
||||
export const usePaths = (repository: Repository, revision: string): ApiResult<Paths> => {
|
||||
const link = requiredLink(repository, "paths").replace("{revision}", revision);
|
||||
return useQuery<Paths, Error>(repoQueryKey(repository, "paths", revision), () =>
|
||||
apiClient.get(link).then(response => response.json())
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,10 +41,11 @@ type Props = {
|
||||
path: string;
|
||||
baseUrl: string;
|
||||
sources: File;
|
||||
preButtons?: React.ReactNode;
|
||||
permalink: string | null;
|
||||
};
|
||||
|
||||
const PermaLinkWrapper = styled.div`
|
||||
const PermaLinkWrapper = styled.span`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 13px;
|
||||
@@ -62,6 +63,10 @@ const PermaLinkWrapper = styled.div`
|
||||
|
||||
const BreadcrumbNav = styled.nav`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 1rem 1rem !important;
|
||||
|
||||
width: 100%;
|
||||
|
||||
/* move slash to end */
|
||||
@@ -97,6 +102,7 @@ const ActionBar = styled.div`
|
||||
justify-content: flex-start;
|
||||
|
||||
/* ensure space between action bar items */
|
||||
|
||||
& > * {
|
||||
/*
|
||||
* We have to use important, because plugins could use field or control classes like the editor-plugin does.
|
||||
@@ -107,7 +113,22 @@ const ActionBar = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Breadcrumb: FC<Props> = ({ repository, branch, defaultBranch, revision, path, baseUrl, sources, permalink }) => {
|
||||
const PrefixButton = styled.div`
|
||||
border-right: 1px solid lightgray;
|
||||
margin-right: 0.5rem;
|
||||
`;
|
||||
|
||||
const Breadcrumb: FC<Props> = ({
|
||||
repository,
|
||||
branch,
|
||||
defaultBranch,
|
||||
revision,
|
||||
path,
|
||||
baseUrl,
|
||||
sources,
|
||||
permalink,
|
||||
preButtons
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const [copying, setCopying] = useState(false);
|
||||
@@ -156,9 +177,24 @@ const Breadcrumb: FC<Props> = ({ repository, branch, defaultBranch, revision, pa
|
||||
homeUrl += encodeURIComponent(revision) + "/";
|
||||
}
|
||||
|
||||
let prefixButtons = null;
|
||||
if (preButtons) {
|
||||
prefixButtons = <PrefixButton>{preButtons}</PrefixButton>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="is-flex is-align-items-center ml-5 my-4 mr-3">
|
||||
<div className="is-flex is-align-items-center">
|
||||
<BreadcrumbNav className={classNames("breadcrumb", "sources-breadcrumb", "ml-1", "mb-0")} aria-label="breadcrumbs">
|
||||
{prefixButtons}
|
||||
<ul>
|
||||
<li>
|
||||
<Link to={homeUrl}>
|
||||
<HomeIcon title={t("breadcrumb.home")} name="home" color="inherit" />
|
||||
</Link>
|
||||
</li>
|
||||
{pathSection()}
|
||||
</ul>
|
||||
<PermaLinkWrapper className="ml-1">
|
||||
{copying ? (
|
||||
<Icon name="spinner fa-spin" />
|
||||
@@ -168,18 +204,6 @@ const Breadcrumb: FC<Props> = ({ repository, branch, defaultBranch, revision, pa
|
||||
</Tooltip>
|
||||
)}
|
||||
</PermaLinkWrapper>
|
||||
<BreadcrumbNav
|
||||
className={classNames("breadcrumb", "sources-breadcrumb", "ml-1", "mb-0")}
|
||||
aria-label="breadcrumbs"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to={homeUrl}>
|
||||
<HomeIcon title={t("breadcrumb.home")} name="home" color="inherit" />
|
||||
</Link>
|
||||
</li>
|
||||
{pathSection()}
|
||||
</ul>
|
||||
</BreadcrumbNav>
|
||||
{binder.hasExtension("repos.sources.actionbar") && (
|
||||
<ActionBar>
|
||||
|
||||
@@ -25,19 +25,22 @@ import React, { FC, FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { createAttributesForTesting } from "../devBuild";
|
||||
import classNames from "classnames";
|
||||
|
||||
type Props = {
|
||||
filter: (p: string) => void;
|
||||
value?: string;
|
||||
testId?: string;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const FixedHeightInput = styled.input`
|
||||
height: 2.5rem;
|
||||
`;
|
||||
|
||||
const FilterInput: FC<Props> = ({ filter, value, testId, placeholder }) => {
|
||||
const FilterInput: FC<Props> = ({ filter, value, testId, placeholder, autoFocus, className }) => {
|
||||
const [stateValue, setStateValue] = useState(value || "");
|
||||
const [timeoutId, setTimeoutId] = useState(0);
|
||||
const [t] = useTranslation("commons");
|
||||
@@ -60,7 +63,11 @@ const FilterInput: FC<Props> = ({ filter, value, testId, placeholder }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="input-field" onSubmit={handleSubmit} {...createAttributesForTesting(testId)}>
|
||||
<form
|
||||
className={classNames("input-field", className)}
|
||||
onSubmit={handleSubmit}
|
||||
{...createAttributesForTesting(testId)}
|
||||
>
|
||||
<div className="control has-icons-left">
|
||||
<FixedHeightInput
|
||||
className="input"
|
||||
@@ -68,6 +75,7 @@ const FilterInput: FC<Props> = ({ filter, value, testId, placeholder }) => {
|
||||
placeholder={placeholder || t("filterEntries")}
|
||||
value={stateValue}
|
||||
onChange={event => setStateValue(event.target.value)}
|
||||
autoFocus={autoFocus || false}
|
||||
/>
|
||||
<span className="icon is-small is-left">
|
||||
<i className="fas fa-search" />
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { Links } from "./hal";
|
||||
import { HalRepresentation, Links } from "./hal";
|
||||
|
||||
export type SubRepository = {
|
||||
repositoryUrl: string;
|
||||
@@ -47,3 +47,8 @@ export type File = {
|
||||
children?: File[] | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type Paths = HalRepresentation & {
|
||||
revision: string;
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export { IndexResources } from "./IndexResources";
|
||||
|
||||
export { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions";
|
||||
|
||||
export { SubRepository, File } from "./Sources";
|
||||
export * from "./Sources";
|
||||
|
||||
export { SelectValue, AutocompleteObject } from "./Autocomplete";
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"redux-devtools-extension": "^2.13.5",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"string_score": "^0.1.22",
|
||||
"styled-components": "^5.1.0",
|
||||
"systemjs": "0.21.6"
|
||||
},
|
||||
|
||||
@@ -453,5 +453,19 @@
|
||||
"fileUpload": {
|
||||
"clickHere": "Klicken Sie hier um Ihre Datei hochzuladen.",
|
||||
"dragAndDrop": "Sie können Ihre Datei auch direkt in die Dropzone ziehen."
|
||||
},
|
||||
"filesearch": {
|
||||
"button": {
|
||||
"title": "Dateipfad Suche"
|
||||
},
|
||||
"home": "Zurück zu Sources",
|
||||
"input": {
|
||||
"placeholder": "Dateipfad Suche",
|
||||
"help": "Tippe 2 or mehr Zeichen ein, um die Suche zu starten"
|
||||
},
|
||||
"notifications": {
|
||||
"queryToShort": "Tippe mindestens 2 Zeichen ein, um die Suche zu starten",
|
||||
"emptyResult": "Es wurden keine Ergebnisse für <0>{{query}}</0> gefunden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,5 +460,19 @@
|
||||
"fileUpload": {
|
||||
"clickHere": "Click here to select your file",
|
||||
"dragAndDrop": "Drag 'n' drop some files here"
|
||||
},
|
||||
"filesearch": {
|
||||
"button": {
|
||||
"title": "Search filepath"
|
||||
},
|
||||
"home": "Go back to source root",
|
||||
"input": {
|
||||
"placeholder": "Search filepath",
|
||||
"help": "Type 2 or more letters to search for a filepath in the repository"
|
||||
},
|
||||
"notifications": {
|
||||
"queryToShort": "Type at least two characters to start the search",
|
||||
"emptyResult": "Nothing found for query <0>{{query}}</0>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import React, { FC } from "react";
|
||||
import styled from "styled-components";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Level, BranchSelector } from "@scm-manager/ui-components";
|
||||
import CodeViewSwitcher from "./CodeViewSwitcher";
|
||||
import CodeViewSwitcher, { SwitchViewLink } from "./CodeViewSwitcher";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Branch } from "@scm-manager/ui-types";
|
||||
|
||||
@@ -52,7 +52,7 @@ type Props = {
|
||||
selectedBranch?: string;
|
||||
branches?: Branch[];
|
||||
onSelectBranch: () => void;
|
||||
switchViewLink: string;
|
||||
switchViewLink: SwitchViewLink;
|
||||
};
|
||||
|
||||
const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, switchViewLink }) => {
|
||||
@@ -63,7 +63,8 @@ const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, sw
|
||||
<ActionBar>
|
||||
<FlexShrinkLevel
|
||||
left={
|
||||
branches && branches?.length > 0 && (
|
||||
branches &&
|
||||
branches?.length > 0 && (
|
||||
<BranchSelector
|
||||
label={t("code.branchSelector")}
|
||||
branches={branches}
|
||||
|
||||
@@ -36,9 +36,13 @@ const ButtonAddonsMarginRight = styled(ButtonAddons)`
|
||||
margin-right: -0.2em;
|
||||
`;
|
||||
|
||||
type Type = "sources" | "changesets";
|
||||
|
||||
export type SwitchViewLink = string | ((type: Type) => string)
|
||||
|
||||
type Props = {
|
||||
currentUrl: string;
|
||||
switchViewLink: string;
|
||||
switchViewLink: SwitchViewLink;
|
||||
};
|
||||
|
||||
const CodeViewSwitcher: FC<Props> = ({ currentUrl, switchViewLink }) => {
|
||||
@@ -49,21 +53,30 @@ const CodeViewSwitcher: FC<Props> = ({ currentUrl, switchViewLink }) => {
|
||||
location = "changesets";
|
||||
} else if (currentUrl.includes("/code/sources")) {
|
||||
location = "sources";
|
||||
} else if (currentUrl.includes("/code/search")) {
|
||||
location = "search";
|
||||
}
|
||||
|
||||
const createLink = (type: Type) => {
|
||||
if (typeof switchViewLink === "string") {
|
||||
return switchViewLink;
|
||||
}
|
||||
return switchViewLink(type);
|
||||
};
|
||||
|
||||
return (
|
||||
<ButtonAddonsMarginRight>
|
||||
<SmallButton
|
||||
label={t("code.commits")}
|
||||
icon="fa fa-exchange-alt"
|
||||
color={location === "changesets" ? "link is-selected" : undefined}
|
||||
link={location === "sources" ? switchViewLink : undefined}
|
||||
link={location !== "changesets" ? createLink("changesets") : undefined}
|
||||
/>
|
||||
<SmallButton
|
||||
label={t("code.sources")}
|
||||
icon="fa fa-code"
|
||||
color={location === "sources" ? "link is-selected" : undefined}
|
||||
link={location === "changesets" ? switchViewLink : undefined}
|
||||
color={location === "sources" || location === "search" ? "link is-selected" : undefined}
|
||||
link={location !== "sources" ? createLink("sources") : undefined}
|
||||
/>
|
||||
</ButtonAddonsMarginRight>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon } from "@scm-manager/ui-components";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
revision: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
const SearchIcon = styled(Icon)`
|
||||
line-height: 1.5rem;
|
||||
`;
|
||||
|
||||
const FileSearchButton: FC<Props> = ({ baseUrl, revision }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
return (
|
||||
<Link to={`${baseUrl}/search/${revision}`}>
|
||||
<SearchIcon title={t("filesearch.button.title")} name="search" color="inherit" />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSearchButton;
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 { Icon, Notification, urls } from "@scm-manager/ui-components";
|
||||
import styled from "styled-components";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
paths: string[];
|
||||
query: string;
|
||||
contentBaseUrl: string;
|
||||
};
|
||||
|
||||
const IconColumn = styled.td`
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const LeftOverflowTd = styled.td`
|
||||
overflow: hidden;
|
||||
max-width: 1px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
text-align: left !important;
|
||||
`;
|
||||
|
||||
const ResultNotification = styled(Notification)`
|
||||
margin: 1rem;
|
||||
`;
|
||||
|
||||
type PathResultRowProps = {
|
||||
contentBaseUrl: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const PathResultRow: FC<PathResultRowProps> = ({ contentBaseUrl, path }) => {
|
||||
const link = urls.concat(contentBaseUrl, path);
|
||||
return (
|
||||
<tr>
|
||||
<IconColumn>
|
||||
<Link to={link}>
|
||||
<Icon title="File" name="file" color="inherit" />
|
||||
</Link>
|
||||
</IconColumn>
|
||||
<LeftOverflowTd>
|
||||
<Link title={path} to={link}>
|
||||
{path}
|
||||
</Link>
|
||||
</LeftOverflowTd>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
type ResultTableProps = {
|
||||
contentBaseUrl: string;
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
const ResultTable: FC<ResultTableProps> = ({ contentBaseUrl, paths }) => (
|
||||
<table className="table table-hover table-sm is-fullwidth">
|
||||
<tbody>
|
||||
{paths.map(path => (
|
||||
<PathResultRow contentBaseUrl={contentBaseUrl} path={path} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
const FileSearchResults: FC<Props> = ({ query, contentBaseUrl, paths = [] }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
let body;
|
||||
if (query.length <= 1) {
|
||||
body = <ResultNotification type="info">{t("filesearch.notifications.queryToShort")}</ResultNotification>;
|
||||
} else if (paths.length === 0) {
|
||||
const queryCmp = <strong>{query}</strong>;
|
||||
body = (
|
||||
<ResultNotification type="info">
|
||||
<Trans i18nKey="repos:filesearch.notifications.emptyResult" values={{ query }} components={[queryCmp]} />
|
||||
</ResultNotification>
|
||||
);
|
||||
} else {
|
||||
body = <ResultTable contentBaseUrl={contentBaseUrl} paths={paths} />;
|
||||
}
|
||||
return <div className="panel-block">{body}</div>;
|
||||
};
|
||||
|
||||
export default FileSearchResults;
|
||||
@@ -29,6 +29,7 @@ import { Branch, Repository } from "@scm-manager/ui-types";
|
||||
import { ErrorPage, Loading, Notification } from "@scm-manager/ui-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBranches } from "@scm-manager/ui-api";
|
||||
import FileSearch from "./FileSearch";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
@@ -97,6 +98,9 @@ const CodeRouting: FC<RoutingProps> = ({ repository, baseUrl, branches, selected
|
||||
<Route path={`${baseUrl}/branch/:branch/changesets/`}>
|
||||
<ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} />
|
||||
</Route>
|
||||
<Route path={`${baseUrl}/search/:revision/`}>
|
||||
<FileSearch repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} />
|
||||
</Route>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
139
scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx
Normal file
139
scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 { Branch, Repository } from "@scm-manager/ui-types";
|
||||
import { useHistory, useLocation, useParams } from "react-router-dom";
|
||||
import { urls, usePaths } from "@scm-manager/ui-api";
|
||||
import { ErrorNotification, FilterInput, Help, Icon, Loading } from "@scm-manager/ui-components";
|
||||
import CodeActionBar from "../components/CodeActionBar";
|
||||
import styled from "styled-components";
|
||||
import FileSearchResults from "../components/FileSearchResults";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { filepathSearch } from "../utils/filepathSearch";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
baseUrl: string;
|
||||
branches?: Branch[];
|
||||
selectedBranch?: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
revision: string;
|
||||
};
|
||||
|
||||
const InputContainer = styled.div`
|
||||
padding: 1rem 1.75rem 0 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
`;
|
||||
|
||||
const HomeLink = styled(Link)`
|
||||
border-right: 1px solid lightgray;
|
||||
margin-right: 0.75rem;
|
||||
padding-right: 0.75em;
|
||||
`;
|
||||
|
||||
const HomeIcon = styled(Icon)`
|
||||
line-height: 1.5rem;
|
||||
`;
|
||||
|
||||
const SearchHelp = styled(Help)`
|
||||
margin-left: 0.75rem;
|
||||
`;
|
||||
|
||||
const useRevision = () => {
|
||||
const { revision } = useParams<Params>();
|
||||
return decodeURIComponent(revision);
|
||||
};
|
||||
|
||||
const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }) => {
|
||||
const revision = useRevision();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { isLoading, error, data } = usePaths(repository, revision);
|
||||
const [result, setResult] = useState<string[]>([]);
|
||||
const query = urls.getQueryStringFromLocation(location) || "";
|
||||
const [t] = useTranslation("repos");
|
||||
useEffect(() => {
|
||||
if (query.length > 1 && data) {
|
||||
setResult(filepathSearch(data.paths, query));
|
||||
} else {
|
||||
setResult([]);
|
||||
}
|
||||
}, [data, query]);
|
||||
|
||||
const search = (query: string) => {
|
||||
history.push(`${location.pathname}?q=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
const onSelectBranch = (branch?: Branch) => {
|
||||
if (branch) {
|
||||
history.push(`${baseUrl}/search/${encodeURIComponent(branch.name)}?q=${query}`);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateSwitchViewLink = (type: string) => {
|
||||
if (type === "sources") {
|
||||
return `${baseUrl}/sources/${encodeURIComponent(revision)}/`;
|
||||
}
|
||||
return `${baseUrl}/changesets/${encodeURIComponent(revision)}/`;
|
||||
};
|
||||
|
||||
const contentBaseUrl = `${baseUrl}/sources/${encodeURIComponent(revision)}/`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeActionBar
|
||||
branches={branches}
|
||||
selectedBranch={selectedBranch}
|
||||
onSelectBranch={onSelectBranch}
|
||||
switchViewLink={evaluateSwitchViewLink}
|
||||
/>
|
||||
<div className="panel">
|
||||
<InputContainer>
|
||||
<HomeLink to={contentBaseUrl}>
|
||||
<HomeIcon title={t("filesearch.home")} name="home" color="inherit" />
|
||||
</HomeLink>
|
||||
<FilterInput
|
||||
className="is-full-width"
|
||||
placeholder={t("filesearch.input.placeholder")}
|
||||
value={query}
|
||||
filter={search}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<SearchHelp message={t("filesearch.input.help")} />
|
||||
</InputContainer>
|
||||
<ErrorNotification error={error} />
|
||||
{isLoading ? <Loading /> : <FileSearchResults contentBaseUrl={contentBaseUrl} query={query} paths={result} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSearch;
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 { createMatcher, filepathSearch } from "./filepathSearch";
|
||||
|
||||
describe("filepathSearch tests", () => {
|
||||
it("should match simple char sequence", () => {
|
||||
const matcher = createMatcher("hitch");
|
||||
expect(matcher("hitchhiker").matches).toBe(true);
|
||||
expect(matcher("trillian").matches).toBe(false);
|
||||
});
|
||||
|
||||
it("should ignore case of path", () => {
|
||||
const matcher = createMatcher("hitch");
|
||||
expect(matcher("hiTcHhiker").matches).toBe(true);
|
||||
});
|
||||
|
||||
it("should ignore case of query", () => {
|
||||
const matcher = createMatcher("HiTcH");
|
||||
expect(matcher("hitchhiker").matches).toBe(true);
|
||||
});
|
||||
|
||||
it("should return sorted by score", () => {
|
||||
const paths = [
|
||||
"AccessSomething",
|
||||
"AccessTokenResolver",
|
||||
"SomethingDifferent",
|
||||
"SomeResolver",
|
||||
"SomeTokenResolver",
|
||||
"accesstokenresolver",
|
||||
"ActorExpression"
|
||||
];
|
||||
|
||||
const matches = filepathSearch(paths, "AcToRe");
|
||||
expect(matches).toEqual(["ActorExpression", "AccessTokenResolver", "accesstokenresolver"]);
|
||||
});
|
||||
|
||||
it("should score path if filename not match", () => {
|
||||
const matcher = createMatcher("AcToRe");
|
||||
const match = matcher("src/main/ac/to/re/Main.java");
|
||||
expect(match.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should score higher if the name includes the query", () => {
|
||||
const matcher = createMatcher("Test");
|
||||
const one = matcher("src/main/js/types.ts");
|
||||
const two = matcher("src/test/java/com/cloudogu/scm/landingpage/myevents/PluginTestHelper.java");
|
||||
expect(two.score).toBeGreaterThan(one.score);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 "string_score";
|
||||
|
||||
// typing for string_score
|
||||
declare global {
|
||||
interface String {
|
||||
score(query: string): number;
|
||||
}
|
||||
}
|
||||
|
||||
export const filepathSearch = (paths: string[], query: string): string[] => {
|
||||
return paths
|
||||
.map(createMatcher(query))
|
||||
.filter(m => m.matches)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 50)
|
||||
.map(m => m.path);
|
||||
};
|
||||
|
||||
const includes = (value: string, query: string) => {
|
||||
return value.toLocaleLowerCase("en").includes(query.toLocaleLowerCase("en"));
|
||||
};
|
||||
|
||||
export const createMatcher = (query: string) => {
|
||||
return (path: string) => {
|
||||
const parts = path.split("/");
|
||||
const filename = parts[parts.length - 1];
|
||||
|
||||
let score = filename.score(query);
|
||||
if (score > 0 && includes(filename, query)) {
|
||||
score += 0.5;
|
||||
} else if (score <= 0) {
|
||||
score = path.score(query) * 0.25;
|
||||
}
|
||||
|
||||
return {
|
||||
matches: score > 0,
|
||||
score,
|
||||
path
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -30,6 +30,7 @@ import CodeActionBar from "../../codeSection/components/CodeActionBar";
|
||||
import replaceBranchWithRevision from "../ReplaceBranchWithRevision";
|
||||
import { useSources } from "@scm-manager/ui-api";
|
||||
import { useHistory, useLocation, useParams } from "react-router-dom";
|
||||
import FileSearchButton from "../../codeSection/components/FileSearchButton";
|
||||
import { isEmptyDirectory, isRootFile } from "../utils/files";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -106,8 +107,14 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
|
||||
const renderBreadcrumb = () => {
|
||||
const permalink = file?.revision ? replaceBranchWithRevision(location.pathname, file.revision) : null;
|
||||
|
||||
const buttons = [];
|
||||
if (repository._links.paths) {
|
||||
buttons.push(<FileSearchButton baseUrl={baseUrl} revision={revision || file.revision} />);
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb
|
||||
preButtons={buttons}
|
||||
repository={repository}
|
||||
revision={revision || file.revision}
|
||||
path={path || ""}
|
||||
|
||||
@@ -40,6 +40,7 @@ public class RepositoryBasedResourceProvider {
|
||||
private final Provider<IncomingRootResource> incomingRootResource;
|
||||
private final Provider<AnnotateResource> annotateResource;
|
||||
private final Provider<RepositoryExportResource> repositoryExportResource;
|
||||
private final Provider<RepositoryPathsResource> repositoryPathResource;
|
||||
|
||||
@Inject
|
||||
public RepositoryBasedResourceProvider(
|
||||
@@ -54,7 +55,8 @@ public class RepositoryBasedResourceProvider {
|
||||
Provider<FileHistoryRootResource> fileHistoryRootResource,
|
||||
Provider<IncomingRootResource> incomingRootResource,
|
||||
Provider<AnnotateResource> annotateResource,
|
||||
Provider<RepositoryExportResource> repositoryExportResource) {
|
||||
Provider<RepositoryExportResource> repositoryExportResource,
|
||||
Provider<RepositoryPathsResource> repositoryPathResource) {
|
||||
this.tagRootResource = tagRootResource;
|
||||
this.branchRootResource = branchRootResource;
|
||||
this.changesetRootResource = changesetRootResource;
|
||||
@@ -67,6 +69,7 @@ public class RepositoryBasedResourceProvider {
|
||||
this.incomingRootResource = incomingRootResource;
|
||||
this.annotateResource = annotateResource;
|
||||
this.repositoryExportResource = repositoryExportResource;
|
||||
this.repositoryPathResource = repositoryPathResource;
|
||||
}
|
||||
|
||||
public TagRootResource getTagRootResource() {
|
||||
@@ -116,4 +119,8 @@ public class RepositoryBasedResourceProvider {
|
||||
public RepositoryExportResource getRepositoryExportResource() {
|
||||
return repositoryExportResource.get();
|
||||
}
|
||||
|
||||
public RepositoryPathsResource getRepositoryPathResource() {
|
||||
return repositoryPathResource.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.api.v2.resources;
|
||||
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
import de.otto.edison.hal.Links;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@SuppressWarnings("java:S2160") // we don't need equals
|
||||
public class RepositoryPathsDto extends HalRepresentation {
|
||||
|
||||
private String revision;
|
||||
private Collection<String> paths;
|
||||
|
||||
public RepositoryPathsDto(Links links) {
|
||||
super(links);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.api.v2.resources;
|
||||
|
||||
import de.otto.edison.hal.Links;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.RepositoryPathCollector;
|
||||
import sonia.scm.repository.RepositoryPaths;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.UriInfo;
|
||||
import java.io.IOException;
|
||||
|
||||
public class RepositoryPathsResource {
|
||||
|
||||
private final RepositoryPathCollector collector;
|
||||
|
||||
@Inject
|
||||
public RepositoryPathsResource(RepositoryPathCollector collector) {
|
||||
this.collector = collector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all file paths for the given revision in the repository
|
||||
*
|
||||
* @param namespace the namespace of the repository
|
||||
* @param name the name of the repository
|
||||
* @param revision the revision
|
||||
*/
|
||||
@GET
|
||||
@Path("{revision}")
|
||||
@Produces(VndMediaType.REPOSITORY_PATHS)
|
||||
@Operation(summary = "File paths by revision", description = "Returns all file paths for the given revision in the repository.", tags = "Repository")
|
||||
@ApiResponse(responseCode = "200", description = "success")
|
||||
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||
@ApiResponse(responseCode = "403", description = "not authorized, the current user has no privileges to read the repository")
|
||||
@ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "not found, no repository with the specified name available in the namespace",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
@ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "internal server error",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public RepositoryPathsDto collect(
|
||||
@Context UriInfo uriInfo,
|
||||
@PathParam("namespace") String namespace,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("revision") String revision) throws IOException
|
||||
{
|
||||
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
|
||||
RepositoryPaths paths = collector.collect(namespaceAndName, revision);
|
||||
return map(uriInfo, paths);
|
||||
}
|
||||
|
||||
private RepositoryPathsDto map(UriInfo uriInfo, RepositoryPaths paths) {
|
||||
RepositoryPathsDto dto = new RepositoryPathsDto(createLinks(uriInfo));
|
||||
dto.setRevision(paths.getRevision());
|
||||
dto.setPaths(paths.getPaths());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private Links createLinks(UriInfo uriInfo) {
|
||||
return Links.linkingTo().self(uriInfo.getAbsolutePath().toASCIIString()).build();
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,11 @@ public class RepositoryResource {
|
||||
return resourceProvider.getRepositoryExportResource();
|
||||
}
|
||||
|
||||
@Path("paths/")
|
||||
public RepositoryPathsResource paths() {
|
||||
return resourceProvider.getRepositoryPathResource();
|
||||
}
|
||||
|
||||
private Supplier<Repository> loadBy(String namespace, String name) {
|
||||
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
|
||||
return () -> Optional.ofNullable(manager.get(namespaceAndName)).orElseThrow(() -> notFound(entity(namespaceAndName)));
|
||||
|
||||
@@ -137,6 +137,7 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
|
||||
}
|
||||
linksBuilder.single(link("changesets", resourceLinks.changeset().all(repository.getNamespace(), repository.getName())));
|
||||
linksBuilder.single(link("sources", resourceLinks.source().selfWithoutRevision(repository.getNamespace(), repository.getName())));
|
||||
linksBuilder.single(link("paths", resourceLinks.repository().paths(repository.getNamespace(), repository.getName())));
|
||||
|
||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repository);
|
||||
|
||||
@@ -342,11 +342,13 @@ class ResourceLinks {
|
||||
private final LinkBuilder repositoryLinkBuilder;
|
||||
private final LinkBuilder repositoryImportLinkBuilder;
|
||||
private final LinkBuilder repositoryExportLinkBuilder;
|
||||
private final LinkBuilder repositoryPathsLinkBuilder;
|
||||
|
||||
RepositoryLinks(ScmPathInfo pathInfo) {
|
||||
repositoryLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class);
|
||||
repositoryImportLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryImportResource.class);
|
||||
repositoryExportLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, RepositoryExportResource.class);
|
||||
repositoryPathsLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, RepositoryPathsResource.class);
|
||||
}
|
||||
|
||||
String self(String namespace, String name) {
|
||||
@@ -404,6 +406,10 @@ class ResourceLinks {
|
||||
String exportInfo(String namespace, String name) {
|
||||
return repositoryExportLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("export").parameters().method("getExportInformation").parameters().href();
|
||||
}
|
||||
|
||||
String paths(String namespace, String name) {
|
||||
return repositoryPathsLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("paths").parameters().method("collect").parameters("_REVISION_").href().replace("_REVISION_", "{revision}");
|
||||
}
|
||||
}
|
||||
|
||||
RepositoryCollectionLinks repositoryCollection() {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
|
||||
@Extension
|
||||
public class RepositoryPathCollector {
|
||||
|
||||
private final RepositoryServiceFactory serviceFactory;
|
||||
|
||||
@Inject
|
||||
public RepositoryPathCollector(RepositoryServiceFactory serviceFactory) {
|
||||
this.serviceFactory = serviceFactory;
|
||||
}
|
||||
|
||||
public RepositoryPaths collect(NamespaceAndName repository, String revision) throws IOException {
|
||||
BrowserResult result = browse(repository, revision);
|
||||
Collection<String> paths = new HashSet<>();
|
||||
append(paths, result.getFile());
|
||||
return new RepositoryPaths(result.getRevision(), paths);
|
||||
}
|
||||
|
||||
private BrowserResult browse(NamespaceAndName repository, String revision) throws IOException {
|
||||
try (RepositoryService repositoryService = serviceFactory.create(repository)) {
|
||||
return repositoryService.getBrowseCommand()
|
||||
.setDisableSubRepositoryDetection(true)
|
||||
.setDisableLastCommit(true)
|
||||
.setDisablePreProcessors(true)
|
||||
.setLimit(Integer.MAX_VALUE)
|
||||
.setRecursive(true)
|
||||
.setRevision(revision)
|
||||
.getBrowserResult();
|
||||
}
|
||||
}
|
||||
|
||||
private void append(Collection<String> paths, FileObject file) {
|
||||
if (file.isDirectory()) {
|
||||
for (FileObject child : file.getChildren()) {
|
||||
append(paths, child);
|
||||
}
|
||||
} else {
|
||||
paths.add(file.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
@Value
|
||||
public class RepositoryPaths {
|
||||
String revision;
|
||||
Collection<String> paths;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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.api.v2.resources;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.jboss.resteasy.mock.MockHttpRequest;
|
||||
import org.jboss.resteasy.mock.MockHttpResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.RepositoryPathCollector;
|
||||
import sonia.scm.repository.RepositoryPaths;
|
||||
import sonia.scm.web.RestDispatcher;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RepositoryPathsResourceTest extends RepositoryTestBase {
|
||||
|
||||
private final RestDispatcher dispatcher = new RestDispatcher();
|
||||
private final NamespaceAndName PUZZLE_42 = new NamespaceAndName("hitchhiker", "puzzle-42");
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Mock
|
||||
private RepositoryPathCollector collector;
|
||||
|
||||
@InjectMocks
|
||||
private RepositoryPathsResource resource;
|
||||
|
||||
@BeforeEach
|
||||
public void prepareEnvironment() {
|
||||
super.repositoryPathsResource = resource;
|
||||
dispatcher.addSingletonResource(getRepositoryRootResource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnCollectedPaths() throws IOException, URISyntaxException {
|
||||
mockCollector("21", "a.txt", "b/c.txt");
|
||||
|
||||
MockHttpResponse response = request("21");
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
|
||||
|
||||
RepositoryPathsDto dto = mapper.readValue(response.getContentAsString(), RepositoryPathsDto.class);
|
||||
assertThat(dto.getRevision()).isEqualTo("21");
|
||||
assertThat(dto.getPaths()).containsExactly("a.txt", "b/c.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAppendSelfLink() throws IOException, URISyntaxException {
|
||||
mockCollector("42");
|
||||
|
||||
MockHttpResponse response = request("42");
|
||||
|
||||
RepositoryPathsDto dto = mapper.readValue(response.getContentAsString(), RepositoryPathsDto.class);
|
||||
assertThat(dto.getLinks().getLinkBy("self")).isPresent().hasValueSatisfying(link ->
|
||||
assertThat(link.getHref()).isEqualTo("/v2/repositories/hitchhiker/puzzle-42/paths/42")
|
||||
);
|
||||
}
|
||||
|
||||
private MockHttpResponse request(String revision) throws URISyntaxException {
|
||||
MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "hitchhiker/puzzle-42/paths/" + revision);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private void mockCollector(String revision, String... paths) throws IOException {
|
||||
RepositoryPaths result = new RepositoryPaths(revision, Arrays.asList(paths));
|
||||
when(collector.collect(PUZZLE_42, revision)).thenReturn(result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -47,6 +47,7 @@ abstract class RepositoryTestBase {
|
||||
AnnotateResource annotateResource;
|
||||
RepositoryImportResource repositoryImportResource;
|
||||
RepositoryExportResource repositoryExportResource;
|
||||
RepositoryPathsResource repositoryPathsResource;
|
||||
|
||||
RepositoryRootResource getRepositoryRootResource() {
|
||||
RepositoryBasedResourceProvider repositoryBasedResourceProvider = new RepositoryBasedResourceProvider(
|
||||
@@ -61,7 +62,9 @@ abstract class RepositoryTestBase {
|
||||
of(fileHistoryRootResource),
|
||||
of(incomingRootResource),
|
||||
of(annotateResource),
|
||||
of(repositoryExportResource));
|
||||
of(repositoryExportResource),
|
||||
of(repositoryPathsResource)
|
||||
);
|
||||
return new RepositoryRootResource(
|
||||
of(new RepositoryResource(
|
||||
repositoryToDtoMapper,
|
||||
|
||||
@@ -47,9 +47,9 @@ import sonia.scm.repository.api.ScmProtocol;
|
||||
import java.net.URI;
|
||||
import java.util.Set;
|
||||
|
||||
import static java.util.Collections.singleton;
|
||||
import static java.util.Collections.singletonList;
|
||||
import static java.util.stream.Stream.of;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -300,6 +300,17 @@ public class RepositoryToRepositoryDtoMapperTest {
|
||||
dto.getLinks().getLinkBy("exportInfo").get().getHref());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreatePathsLink() {
|
||||
RepositoryDto dto = mapper.map(createTestRepository());
|
||||
assertThat(dto.getLinks().getLinkBy("paths"))
|
||||
.isPresent()
|
||||
.hasValueSatisfying(link -> {
|
||||
assertThat(link.getHref()).isEqualTo("http://example.com/base/v2/repositories/testspace/test/paths/{revision}");
|
||||
assertThat(link.isTemplated()).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
private ScmProtocol mockProtocol(String type, String protocol) {
|
||||
return new MockScmProtocol(type, protocol);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Answers;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.repository.api.BrowseCommandBuilder;
|
||||
import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RepositoryPathCollectorTest {
|
||||
|
||||
private final NamespaceAndName HEART_OF_GOLD = new NamespaceAndName("hitchhiker", "heart-of-gold");
|
||||
|
||||
@Mock
|
||||
private RepositoryServiceFactory factory;
|
||||
|
||||
@Mock
|
||||
private RepositoryService service;
|
||||
|
||||
@Mock(answer = Answers.RETURNS_SELF)
|
||||
private BrowseCommandBuilder browseCommand;
|
||||
|
||||
@InjectMocks
|
||||
private RepositoryPathCollector collector;
|
||||
|
||||
@BeforeEach
|
||||
void setUpMocks() {
|
||||
when(factory.create(HEART_OF_GOLD)).thenReturn(service);
|
||||
when(service.getBrowseCommand()).thenReturn(browseCommand);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisableComputeHeavySettings() throws IOException {
|
||||
BrowserResult result = new BrowserResult("42", new FileObject());
|
||||
when(browseCommand.getBrowserResult()).thenReturn(result);
|
||||
|
||||
collector.collect(HEART_OF_GOLD, "42");
|
||||
verify(browseCommand).setDisablePreProcessors(true);
|
||||
verify(browseCommand).setDisableSubRepositoryDetection(true);
|
||||
verify(browseCommand).setDisableLastCommit(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCollectFiles() throws IOException {
|
||||
FileObject root = dir(
|
||||
"a",
|
||||
file("a/b.txt"),
|
||||
dir("a/c",
|
||||
file("a/c/d.txt"),
|
||||
file("a/c/e.txt")
|
||||
)
|
||||
);
|
||||
|
||||
BrowserResult result = new BrowserResult("21", root);
|
||||
when(browseCommand.getBrowserResult()).thenReturn(result);
|
||||
|
||||
RepositoryPaths paths = collector.collect(HEART_OF_GOLD, "develop");
|
||||
assertThat(paths.getRevision()).isEqualTo("21");
|
||||
assertThat(paths.getPaths()).containsExactlyInAnyOrder(
|
||||
"a/b.txt",
|
||||
"a/c/d.txt",
|
||||
"a/c/e.txt"
|
||||
);
|
||||
}
|
||||
|
||||
FileObject dir(String path, FileObject... children) {
|
||||
FileObject file = file(path);
|
||||
file.setDirectory(true);
|
||||
file.setChildren(Arrays.asList(children));
|
||||
return file;
|
||||
}
|
||||
|
||||
FileObject file(String path) {
|
||||
FileObject file = new FileObject();
|
||||
file.setPath(path);
|
||||
file.setName(path);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -19887,6 +19887,11 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
string_score@^0.1.22:
|
||||
version "0.1.22"
|
||||
resolved "https://registry.yarnpkg.com/string_score/-/string_score-0.1.22.tgz#80e112223aeef30969d8502f38db72a768eaa8fd"
|
||||
integrity sha1-gOESIjru8wlp2FAvONtyp2jqqP0=
|
||||
|
||||
stringify-object@^3.3.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629"
|
||||
|
||||
Reference in New Issue
Block a user