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:
Sebastian Sdorra
2021-03-04 10:39:58 +01:00
committed by GitHub
parent bafe84b79a
commit 89548d45bd
36 changed files with 1112 additions and 30 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

View File

@@ -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 Über den Button auf der linken Seite der Breadcrumbs Navigation kann ein permanenter Link
zum aktuellen Pfad in die Zwischenablage kopiert werden. 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.
![Suche nach Dateien](assets/repository-code-filepathsearch.png)
### Changesets ### Changesets
Die Übersicht der Changesets/Commits zeigt die Änderungshistorie je Branch an. Jeder Listeneintrag stellt einen Commit dar. Die Übersicht der Changesets/Commits zeigt die Änderungshistorie je Branch an. Jeder Listeneintrag stellt einen Commit dar.

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

View File

@@ -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 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. 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.
![Filepath search](assets/repository-code-filepathsearch.png)
### Changesets ### Changesets
The changesets/commits overview shows the change history of the branch. Each entry represents a commit. The changesets/commits overview shows the change history of the branch. Each entry represents a commit.

View File

@@ -0,0 +1,2 @@
- type: added
description: Added filepath search ([#1568](https://github.com/scm-manager/scm-manager/issues/1568))

View File

@@ -80,6 +80,7 @@ public class VndMediaType {
public static final String ME = PREFIX + "me" + SUFFIX; public static final String ME = PREFIX + "me" + SUFFIX;
public static final String SOURCE = PREFIX + "source" + SUFFIX; public static final String SOURCE = PREFIX + "source" + SUFFIX;
public static final String ANNOTATE = PREFIX + "annotate" + 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 ADMIN_INFO = PREFIX + "adminInfo" + SUFFIX;
public static final String ERROR_TYPE = PREFIX + "error" + SUFFIX; public static final String ERROR_TYPE = PREFIX + "error" + SUFFIX;

View File

@@ -26,6 +26,7 @@ import {
ExportInfo, ExportInfo,
Link, Link,
Namespace, Namespace,
Paths,
Repository, Repository,
RepositoryCollection, RepositoryCollection,
RepositoryCreation, RepositoryCreation,
@@ -316,3 +317,10 @@ export const useExportRepository = () => {
data 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())
);
};

View File

@@ -41,10 +41,11 @@ type Props = {
path: string; path: string;
baseUrl: string; baseUrl: string;
sources: File; sources: File;
preButtons?: React.ReactNode;
permalink: string | null; permalink: string | null;
}; };
const PermaLinkWrapper = styled.div` const PermaLinkWrapper = styled.span`
width: 16px; width: 16px;
height: 16px; height: 16px;
font-size: 13px; font-size: 13px;
@@ -62,6 +63,10 @@ const PermaLinkWrapper = styled.div`
const BreadcrumbNav = styled.nav` const BreadcrumbNav = styled.nav`
flex: 1; flex: 1;
display: flex;
align-items: center;
margin: 1rem 1rem !important;
width: 100%; width: 100%;
/* move slash to end */ /* move slash to end */
@@ -97,6 +102,7 @@ const ActionBar = styled.div`
justify-content: flex-start; justify-content: flex-start;
/* ensure space between action bar items */ /* ensure space between action bar items */
& > * { & > * {
/* /*
* We have to use important, because plugins could use field or control classes like the editor-plugin does. * 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 location = useLocation();
const history = useHistory(); const history = useHistory();
const [copying, setCopying] = useState(false); const [copying, setCopying] = useState(false);
@@ -156,9 +177,24 @@ const Breadcrumb: FC<Props> = ({ repository, branch, defaultBranch, revision, pa
homeUrl += encodeURIComponent(revision) + "/"; homeUrl += encodeURIComponent(revision) + "/";
} }
let prefixButtons = null;
if (preButtons) {
prefixButtons = <PrefixButton>{preButtons}</PrefixButton>;
}
return ( 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"> <PermaLinkWrapper className="ml-1">
{copying ? ( {copying ? (
<Icon name="spinner fa-spin" /> <Icon name="spinner fa-spin" />
@@ -168,18 +204,6 @@ const Breadcrumb: FC<Props> = ({ repository, branch, defaultBranch, revision, pa
</Tooltip> </Tooltip>
)} )}
</PermaLinkWrapper> </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> </BreadcrumbNav>
{binder.hasExtension("repos.sources.actionbar") && ( {binder.hasExtension("repos.sources.actionbar") && (
<ActionBar> <ActionBar>

View File

@@ -25,19 +25,22 @@ import React, { FC, FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { createAttributesForTesting } from "../devBuild"; import { createAttributesForTesting } from "../devBuild";
import classNames from "classnames";
type Props = { type Props = {
filter: (p: string) => void; filter: (p: string) => void;
value?: string; value?: string;
testId?: string; testId?: string;
placeholder?: string; placeholder?: string;
autoFocus?: boolean;
className?: string;
}; };
const FixedHeightInput = styled.input` const FixedHeightInput = styled.input`
height: 2.5rem; 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 [stateValue, setStateValue] = useState(value || "");
const [timeoutId, setTimeoutId] = useState(0); const [timeoutId, setTimeoutId] = useState(0);
const [t] = useTranslation("commons"); const [t] = useTranslation("commons");
@@ -60,7 +63,11 @@ const FilterInput: FC<Props> = ({ filter, value, testId, placeholder }) => {
}; };
return ( 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"> <div className="control has-icons-left">
<FixedHeightInput <FixedHeightInput
className="input" className="input"
@@ -68,6 +75,7 @@ const FilterInput: FC<Props> = ({ filter, value, testId, placeholder }) => {
placeholder={placeholder || t("filterEntries")} placeholder={placeholder || t("filterEntries")}
value={stateValue} value={stateValue}
onChange={event => setStateValue(event.target.value)} onChange={event => setStateValue(event.target.value)}
autoFocus={autoFocus || false}
/> />
<span className="icon is-small is-left"> <span className="icon is-small is-left">
<i className="fas fa-search" /> <i className="fas fa-search" />

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import { Links } from "./hal"; import { HalRepresentation, Links } from "./hal";
export type SubRepository = { export type SubRepository = {
repositoryUrl: string; repositoryUrl: string;
@@ -47,3 +47,8 @@ export type File = {
children?: File[] | null; children?: File[] | null;
}; };
}; };
export type Paths = HalRepresentation & {
revision: string;
paths: string[];
};

View File

@@ -50,7 +50,7 @@ export { IndexResources } from "./IndexResources";
export { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; export { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions";
export { SubRepository, File } from "./Sources"; export * from "./Sources";
export { SelectValue, AutocompleteObject } from "./Autocomplete"; export { SelectValue, AutocompleteObject } from "./Autocomplete";

View File

@@ -23,6 +23,7 @@
"redux-devtools-extension": "^2.13.5", "redux-devtools-extension": "^2.13.5",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"string_score": "^0.1.22",
"styled-components": "^5.1.0", "styled-components": "^5.1.0",
"systemjs": "0.21.6" "systemjs": "0.21.6"
}, },

View File

@@ -453,5 +453,19 @@
"fileUpload": { "fileUpload": {
"clickHere": "Klicken Sie hier um Ihre Datei hochzuladen.", "clickHere": "Klicken Sie hier um Ihre Datei hochzuladen.",
"dragAndDrop": "Sie können Ihre Datei auch direkt in die Dropzone ziehen." "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"
}
} }
} }

View File

@@ -460,5 +460,19 @@
"fileUpload": { "fileUpload": {
"clickHere": "Click here to select your file", "clickHere": "Click here to select your file",
"dragAndDrop": "Drag 'n' drop some files here" "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>"
}
} }
} }

View File

@@ -25,7 +25,7 @@ import React, { FC } from "react";
import styled from "styled-components"; import styled from "styled-components";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Level, BranchSelector } from "@scm-manager/ui-components"; import { Level, BranchSelector } from "@scm-manager/ui-components";
import CodeViewSwitcher from "./CodeViewSwitcher"; import CodeViewSwitcher, { SwitchViewLink } from "./CodeViewSwitcher";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Branch } from "@scm-manager/ui-types"; import { Branch } from "@scm-manager/ui-types";
@@ -52,7 +52,7 @@ type Props = {
selectedBranch?: string; selectedBranch?: string;
branches?: Branch[]; branches?: Branch[];
onSelectBranch: () => void; onSelectBranch: () => void;
switchViewLink: string; switchViewLink: SwitchViewLink;
}; };
const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, switchViewLink }) => { const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, switchViewLink }) => {
@@ -63,7 +63,8 @@ const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, sw
<ActionBar> <ActionBar>
<FlexShrinkLevel <FlexShrinkLevel
left={ left={
branches && branches?.length > 0 && ( branches &&
branches?.length > 0 && (
<BranchSelector <BranchSelector
label={t("code.branchSelector")} label={t("code.branchSelector")}
branches={branches} branches={branches}

View File

@@ -36,9 +36,13 @@ const ButtonAddonsMarginRight = styled(ButtonAddons)`
margin-right: -0.2em; margin-right: -0.2em;
`; `;
type Type = "sources" | "changesets";
export type SwitchViewLink = string | ((type: Type) => string)
type Props = { type Props = {
currentUrl: string; currentUrl: string;
switchViewLink: string; switchViewLink: SwitchViewLink;
}; };
const CodeViewSwitcher: FC<Props> = ({ currentUrl, switchViewLink }) => { const CodeViewSwitcher: FC<Props> = ({ currentUrl, switchViewLink }) => {
@@ -49,21 +53,30 @@ const CodeViewSwitcher: FC<Props> = ({ currentUrl, switchViewLink }) => {
location = "changesets"; location = "changesets";
} else if (currentUrl.includes("/code/sources")) { } else if (currentUrl.includes("/code/sources")) {
location = "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 ( return (
<ButtonAddonsMarginRight> <ButtonAddonsMarginRight>
<SmallButton <SmallButton
label={t("code.commits")} label={t("code.commits")}
icon="fa fa-exchange-alt" icon="fa fa-exchange-alt"
color={location === "changesets" ? "link is-selected" : undefined} color={location === "changesets" ? "link is-selected" : undefined}
link={location === "sources" ? switchViewLink : undefined} link={location !== "changesets" ? createLink("changesets") : undefined}
/> />
<SmallButton <SmallButton
label={t("code.sources")} label={t("code.sources")}
icon="fa fa-code" icon="fa fa-code"
color={location === "sources" ? "link is-selected" : undefined} color={location === "sources" || location === "search" ? "link is-selected" : undefined}
link={location === "changesets" ? switchViewLink : undefined} link={location !== "sources" ? createLink("sources") : undefined}
/> />
</ButtonAddonsMarginRight> </ButtonAddonsMarginRight>
); );

View File

@@ -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;

View File

@@ -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;

View File

@@ -29,6 +29,7 @@ import { Branch, Repository } from "@scm-manager/ui-types";
import { ErrorPage, Loading, Notification } from "@scm-manager/ui-components"; import { ErrorPage, Loading, Notification } from "@scm-manager/ui-components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useBranches } from "@scm-manager/ui-api"; import { useBranches } from "@scm-manager/ui-api";
import FileSearch from "./FileSearch";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -97,6 +98,9 @@ const CodeRouting: FC<RoutingProps> = ({ repository, baseUrl, branches, selected
<Route path={`${baseUrl}/branch/:branch/changesets/`}> <Route path={`${baseUrl}/branch/:branch/changesets/`}>
<ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} /> <ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} />
</Route> </Route>
<Route path={`${baseUrl}/search/:revision/`}>
<FileSearch repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} />
</Route>
</> </>
); );

View 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;

View File

@@ -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);
});
});

View File

@@ -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
};
};
};

View File

@@ -30,6 +30,7 @@ import CodeActionBar from "../../codeSection/components/CodeActionBar";
import replaceBranchWithRevision from "../ReplaceBranchWithRevision"; import replaceBranchWithRevision from "../ReplaceBranchWithRevision";
import { useSources } from "@scm-manager/ui-api"; import { useSources } from "@scm-manager/ui-api";
import { useHistory, useLocation, useParams } from "react-router-dom"; import { useHistory, useLocation, useParams } from "react-router-dom";
import FileSearchButton from "../../codeSection/components/FileSearchButton";
import { isEmptyDirectory, isRootFile } from "../utils/files"; import { isEmptyDirectory, isRootFile } from "../utils/files";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -106,8 +107,14 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
const renderBreadcrumb = () => { const renderBreadcrumb = () => {
const permalink = file?.revision ? replaceBranchWithRevision(location.pathname, file.revision) : null; 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 ( return (
<Breadcrumb <Breadcrumb
preButtons={buttons}
repository={repository} repository={repository}
revision={revision || file.revision} revision={revision || file.revision}
path={path || ""} path={path || ""}

View File

@@ -40,6 +40,7 @@ public class RepositoryBasedResourceProvider {
private final Provider<IncomingRootResource> incomingRootResource; private final Provider<IncomingRootResource> incomingRootResource;
private final Provider<AnnotateResource> annotateResource; private final Provider<AnnotateResource> annotateResource;
private final Provider<RepositoryExportResource> repositoryExportResource; private final Provider<RepositoryExportResource> repositoryExportResource;
private final Provider<RepositoryPathsResource> repositoryPathResource;
@Inject @Inject
public RepositoryBasedResourceProvider( public RepositoryBasedResourceProvider(
@@ -54,7 +55,8 @@ public class RepositoryBasedResourceProvider {
Provider<FileHistoryRootResource> fileHistoryRootResource, Provider<FileHistoryRootResource> fileHistoryRootResource,
Provider<IncomingRootResource> incomingRootResource, Provider<IncomingRootResource> incomingRootResource,
Provider<AnnotateResource> annotateResource, Provider<AnnotateResource> annotateResource,
Provider<RepositoryExportResource> repositoryExportResource) { Provider<RepositoryExportResource> repositoryExportResource,
Provider<RepositoryPathsResource> repositoryPathResource) {
this.tagRootResource = tagRootResource; this.tagRootResource = tagRootResource;
this.branchRootResource = branchRootResource; this.branchRootResource = branchRootResource;
this.changesetRootResource = changesetRootResource; this.changesetRootResource = changesetRootResource;
@@ -67,6 +69,7 @@ public class RepositoryBasedResourceProvider {
this.incomingRootResource = incomingRootResource; this.incomingRootResource = incomingRootResource;
this.annotateResource = annotateResource; this.annotateResource = annotateResource;
this.repositoryExportResource = repositoryExportResource; this.repositoryExportResource = repositoryExportResource;
this.repositoryPathResource = repositoryPathResource;
} }
public TagRootResource getTagRootResource() { public TagRootResource getTagRootResource() {
@@ -116,4 +119,8 @@ public class RepositoryBasedResourceProvider {
public RepositoryExportResource getRepositoryExportResource() { public RepositoryExportResource getRepositoryExportResource() {
return repositoryExportResource.get(); return repositoryExportResource.get();
} }
public RepositoryPathsResource getRepositoryPathResource() {
return repositoryPathResource.get();
}
} }

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -337,6 +337,11 @@ public class RepositoryResource {
return resourceProvider.getRepositoryExportResource(); return resourceProvider.getRepositoryExportResource();
} }
@Path("paths/")
public RepositoryPathsResource paths() {
return resourceProvider.getRepositoryPathResource();
}
private Supplier<Repository> loadBy(String namespace, String name) { private Supplier<Repository> loadBy(String namespace, String name) {
NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name);
return () -> Optional.ofNullable(manager.get(namespaceAndName)).orElseThrow(() -> notFound(entity(namespaceAndName))); return () -> Optional.ofNullable(manager.get(namespaceAndName)).orElseThrow(() -> notFound(entity(namespaceAndName)));

View File

@@ -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("changesets", resourceLinks.changeset().all(repository.getNamespace(), repository.getName())));
linksBuilder.single(link("sources", resourceLinks.source().selfWithoutRevision(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(); Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repository); applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repository);

View File

@@ -342,11 +342,13 @@ class ResourceLinks {
private final LinkBuilder repositoryLinkBuilder; private final LinkBuilder repositoryLinkBuilder;
private final LinkBuilder repositoryImportLinkBuilder; private final LinkBuilder repositoryImportLinkBuilder;
private final LinkBuilder repositoryExportLinkBuilder; private final LinkBuilder repositoryExportLinkBuilder;
private final LinkBuilder repositoryPathsLinkBuilder;
RepositoryLinks(ScmPathInfo pathInfo) { RepositoryLinks(ScmPathInfo pathInfo) {
repositoryLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class); repositoryLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class);
repositoryImportLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryImportResource.class); repositoryImportLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryImportResource.class);
repositoryExportLinkBuilder = new LinkBuilder(pathInfo, RepositoryRootResource.class, RepositoryResource.class, RepositoryExportResource.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) { String self(String namespace, String name) {
@@ -404,6 +406,10 @@ class ResourceLinks {
String exportInfo(String namespace, String name) { String exportInfo(String namespace, String name) {
return repositoryExportLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("export").parameters().method("getExportInformation").parameters().href(); 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() { RepositoryCollectionLinks repositoryCollection() {

View File

@@ -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());
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -47,6 +47,7 @@ abstract class RepositoryTestBase {
AnnotateResource annotateResource; AnnotateResource annotateResource;
RepositoryImportResource repositoryImportResource; RepositoryImportResource repositoryImportResource;
RepositoryExportResource repositoryExportResource; RepositoryExportResource repositoryExportResource;
RepositoryPathsResource repositoryPathsResource;
RepositoryRootResource getRepositoryRootResource() { RepositoryRootResource getRepositoryRootResource() {
RepositoryBasedResourceProvider repositoryBasedResourceProvider = new RepositoryBasedResourceProvider( RepositoryBasedResourceProvider repositoryBasedResourceProvider = new RepositoryBasedResourceProvider(
@@ -61,7 +62,9 @@ abstract class RepositoryTestBase {
of(fileHistoryRootResource), of(fileHistoryRootResource),
of(incomingRootResource), of(incomingRootResource),
of(annotateResource), of(annotateResource),
of(repositoryExportResource)); of(repositoryExportResource),
of(repositoryPathsResource)
);
return new RepositoryRootResource( return new RepositoryRootResource(
of(new RepositoryResource( of(new RepositoryResource(
repositoryToDtoMapper, repositoryToDtoMapper,

View File

@@ -47,9 +47,9 @@ import sonia.scm.repository.api.ScmProtocol;
import java.net.URI; import java.net.URI;
import java.util.Set; import java.util.Set;
import static java.util.Collections.singleton;
import static java.util.Collections.singletonList; import static java.util.Collections.singletonList;
import static java.util.stream.Stream.of; 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.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -300,6 +300,17 @@ public class RepositoryToRepositoryDtoMapperTest {
dto.getLinks().getLinkBy("exportInfo").get().getHref()); 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) { private ScmProtocol mockProtocol(String type, String protocol) {
return new MockScmProtocol(type, protocol); return new MockScmProtocol(type, protocol);
} }

View File

@@ -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;
}
}

View File

@@ -19887,6 +19887,11 @@ string_decoder@~1.1.1:
dependencies: dependencies:
safe-buffer "~5.1.0" 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: stringify-object@^3.3.0:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629"