diff --git a/docs/de/user/repo/assets/repository-code-filepathsearch.png b/docs/de/user/repo/assets/repository-code-filepathsearch.png new file mode 100644 index 0000000000..746e3e843b Binary files /dev/null and b/docs/de/user/repo/assets/repository-code-filepathsearch.png differ diff --git a/docs/de/user/repo/code.md b/docs/de/user/repo/code.md index fcf7e65ba1..60f5960e0c 100644 --- a/docs/de/user/repo/code.md +++ b/docs/de/user/repo/code.md @@ -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. + +![Suche nach Dateien](assets/repository-code-filepathsearch.png) + ### Changesets Die Übersicht der Changesets/Commits zeigt die Änderungshistorie je Branch an. Jeder Listeneintrag stellt einen Commit dar. diff --git a/docs/en/user/repo/assets/repository-code-filepathsearch.png b/docs/en/user/repo/assets/repository-code-filepathsearch.png new file mode 100644 index 0000000000..1575561af2 Binary files /dev/null and b/docs/en/user/repo/assets/repository-code-filepathsearch.png differ diff --git a/docs/en/user/repo/code.md b/docs/en/user/repo/code.md index 310310bd2e..287713fcab 100644 --- a/docs/en/user/repo/code.md +++ b/docs/en/user/repo/code.md @@ -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. + +![Filepath search](assets/repository-code-filepathsearch.png) + ### Changesets The changesets/commits overview shows the change history of the branch. Each entry represents a commit. diff --git a/gradle/changelog/filepath_search.yaml b/gradle/changelog/filepath_search.yaml new file mode 100644 index 0000000000..b881868ec9 --- /dev/null +++ b/gradle/changelog/filepath_search.yaml @@ -0,0 +1,2 @@ +- type: added + description: Added filepath search ([#1568](https://github.com/scm-manager/scm-manager/issues/1568)) diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 710a23f265..1a846d66ba 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -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; diff --git a/scm-ui/ui-api/src/repositories.ts b/scm-ui/ui-api/src/repositories.ts index f3bf19086b..08b18289cc 100644 --- a/scm-ui/ui-api/src/repositories.ts +++ b/scm-ui/ui-api/src/repositories.ts @@ -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 => { + const link = requiredLink(repository, "paths").replace("{revision}", revision); + return useQuery(repoQueryKey(repository, "paths", revision), () => + apiClient.get(link).then(response => response.json()) + ); +}; diff --git a/scm-ui/ui-components/src/Breadcrumb.tsx b/scm-ui/ui-components/src/Breadcrumb.tsx index 587dc6b85c..5cf6ee4b20 100644 --- a/scm-ui/ui-components/src/Breadcrumb.tsx +++ b/scm-ui/ui-components/src/Breadcrumb.tsx @@ -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 = ({ repository, branch, defaultBranch, revision, path, baseUrl, sources, permalink }) => { +const PrefixButton = styled.div` + border-right: 1px solid lightgray; + margin-right: 0.5rem; +`; + +const Breadcrumb: FC = ({ + repository, + branch, + defaultBranch, + revision, + path, + baseUrl, + sources, + permalink, + preButtons +}) => { const location = useLocation(); const history = useHistory(); const [copying, setCopying] = useState(false); @@ -156,22 +177,16 @@ const Breadcrumb: FC = ({ repository, branch, defaultBranch, revision, pa homeUrl += encodeURIComponent(revision) + "/"; } + let prefixButtons = null; + if (preButtons) { + prefixButtons = {preButtons}; + } + return ( <> -
- - {copying ? ( - - ) : ( - - copySource()} /> - - )} - - +
+ + {prefixButtons}
  • @@ -180,6 +195,15 @@ const Breadcrumb: FC = ({ repository, branch, defaultBranch, revision, pa
  • {pathSection()}
+ + {copying ? ( + + ) : ( + + copySource()} /> + + )} +
{binder.hasExtension("repos.sources.actionbar") && ( diff --git a/scm-ui/ui-components/src/forms/FilterInput.tsx b/scm-ui/ui-components/src/forms/FilterInput.tsx index 15dcabb9e1..0d75b7bdbd 100644 --- a/scm-ui/ui-components/src/forms/FilterInput.tsx +++ b/scm-ui/ui-components/src/forms/FilterInput.tsx @@ -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 = ({ filter, value, testId, placeholder }) => { +const FilterInput: FC = ({ 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 = ({ filter, value, testId, placeholder }) => { }; return ( -
+
= ({ filter, value, testId, placeholder }) => { placeholder={placeholder || t("filterEntries")} value={stateValue} onChange={event => setStateValue(event.target.value)} + autoFocus={autoFocus || false} /> diff --git a/scm-ui/ui-types/src/Sources.ts b/scm-ui/ui-types/src/Sources.ts index c5b659b463..986c896e2d 100644 --- a/scm-ui/ui-types/src/Sources.ts +++ b/scm-ui/ui-types/src/Sources.ts @@ -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[]; +}; diff --git a/scm-ui/ui-types/src/index.ts b/scm-ui/ui-types/src/index.ts index 3b3c30152a..1073dc5725 100644 --- a/scm-ui/ui-types/src/index.ts +++ b/scm-ui/ui-types/src/index.ts @@ -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"; diff --git a/scm-ui/ui-webapp/package.json b/scm-ui/ui-webapp/package.json index 5a92bc7797..12391528c8 100644 --- a/scm-ui/ui-webapp/package.json +++ b/scm-ui/ui-webapp/package.json @@ -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" }, diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 9429bfbae0..c30ad58ddf 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -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}} gefunden" + } } } diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 1b497cd187..c29286e353 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -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}}" + } } } diff --git a/scm-ui/ui-webapp/src/repos/codeSection/components/CodeActionBar.tsx b/scm-ui/ui-webapp/src/repos/codeSection/components/CodeActionBar.tsx index b7f79136d3..0dafb693f1 100644 --- a/scm-ui/ui-webapp/src/repos/codeSection/components/CodeActionBar.tsx +++ b/scm-ui/ui-webapp/src/repos/codeSection/components/CodeActionBar.tsx @@ -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 = ({ selectedBranch, branches, onSelectBranch, switchViewLink }) => { @@ -63,7 +63,8 @@ const CodeActionBar: FC = ({ selectedBranch, branches, onSelectBranch, sw 0 && ( + branches && + branches?.length > 0 && ( string) + type Props = { currentUrl: string; - switchViewLink: string; + switchViewLink: SwitchViewLink; }; const CodeViewSwitcher: FC = ({ currentUrl, switchViewLink }) => { @@ -49,21 +53,30 @@ const CodeViewSwitcher: FC = ({ 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 ( ); diff --git a/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchButton.tsx b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchButton.tsx new file mode 100644 index 0000000000..51dc0e348d --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchButton.tsx @@ -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 = ({ baseUrl, revision }) => { + const [t] = useTranslation("repos"); + return ( + + + + ); +}; + +export default FileSearchButton; diff --git a/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchResults.tsx b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchResults.tsx new file mode 100644 index 0000000000..551753d957 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchResults.tsx @@ -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 = ({ contentBaseUrl, path }) => { + const link = urls.concat(contentBaseUrl, path); + return ( + + + + + + + + + {path} + + + + ); +}; + +type ResultTableProps = { + contentBaseUrl: string; + paths: string[]; +}; + +const ResultTable: FC = ({ contentBaseUrl, paths }) => ( + + + {paths.map(path => ( + + ))} + +
+); + +const FileSearchResults: FC = ({ query, contentBaseUrl, paths = [] }) => { + const [t] = useTranslation("repos"); + let body; + if (query.length <= 1) { + body = {t("filesearch.notifications.queryToShort")}; + } else if (paths.length === 0) { + const queryCmp = {query}; + body = ( + + + + ); + } else { + body = ; + } + return
{body}
; +}; + +export default FileSearchResults; diff --git a/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx b/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx index 9834dcc8af..ec1af04964 100644 --- a/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx +++ b/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx @@ -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 = ({ repository, baseUrl, branches, selected + + + ); diff --git a/scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx b/scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx new file mode 100644 index 0000000000..026c6d9113 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx @@ -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(); + return decodeURIComponent(revision); +}; + +const FileSearch: FC = ({ repository, baseUrl, branches, selectedBranch }) => { + const revision = useRevision(); + const location = useLocation(); + const history = useHistory(); + const { isLoading, error, data } = usePaths(repository, revision); + const [result, setResult] = useState([]); + 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 ( + <> + +
+ + + + + + + + + {isLoading ? : } +
+ + ); +}; + +export default FileSearch; diff --git a/scm-ui/ui-webapp/src/repos/codeSection/utils/filepathSearch.test.ts b/scm-ui/ui-webapp/src/repos/codeSection/utils/filepathSearch.test.ts new file mode 100644 index 0000000000..35b5b881ec --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/codeSection/utils/filepathSearch.test.ts @@ -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); + }); +}); diff --git a/scm-ui/ui-webapp/src/repos/codeSection/utils/filepathSearch.ts b/scm-ui/ui-webapp/src/repos/codeSection/utils/filepathSearch.ts new file mode 100644 index 0000000000..7a597b053a --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/codeSection/utils/filepathSearch.ts @@ -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 + }; + }; +}; diff --git a/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx b/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx index 6be23fd736..a4330d7cea 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx @@ -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 = ({ repository, branches, selectedBranch, baseUrl }) = const renderBreadcrumb = () => { const permalink = file?.revision ? replaceBranchWithRevision(location.pathname, file.revision) : null; + const buttons = []; + if (repository._links.paths) { + buttons.push(); + } + return ( incomingRootResource; private final Provider annotateResource; private final Provider repositoryExportResource; + private final Provider repositoryPathResource; @Inject public RepositoryBasedResourceProvider( @@ -54,7 +55,8 @@ public class RepositoryBasedResourceProvider { Provider fileHistoryRootResource, Provider incomingRootResource, Provider annotateResource, - Provider repositoryExportResource) { + Provider repositoryExportResource, + Provider 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(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPathsDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPathsDto.java new file mode 100644 index 0000000000..cca0320392 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPathsDto.java @@ -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 paths; + + public RepositoryPathsDto(Links links) { + super(links); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPathsResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPathsResource.java new file mode 100644 index 0000000000..51083c8959 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryPathsResource.java @@ -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(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java index 27cf95ec23..80f796b82f 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java @@ -337,6 +337,11 @@ public class RepositoryResource { return resourceProvider.getRepositoryExportResource(); } + @Path("paths/") + public RepositoryPathsResource paths() { + return resourceProvider.getRepositoryPathResource(); + } + private Supplier loadBy(String namespace, String name) { NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); return () -> Optional.ofNullable(manager.get(namespaceAndName)).orElseThrow(() -> notFound(entity(namespaceAndName))); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index d7f48f7e9c..711ae524c5 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -137,6 +137,7 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper 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 paths, FileObject file) { + if (file.isDirectory()) { + for (FileObject child : file.getChildren()) { + append(paths, child); + } + } else { + paths.add(file.getPath()); + } + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/RepositoryPaths.java b/scm-webapp/src/main/java/sonia/scm/repository/RepositoryPaths.java new file mode 100644 index 0000000000..3fd2552ec4 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/RepositoryPaths.java @@ -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 paths; +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPathsResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPathsResourceTest.java new file mode 100644 index 0000000000..660fcd63a9 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryPathsResourceTest.java @@ -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); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java index e78b826ce2..2b664bcd34 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java @@ -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, diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java index 718ff647bf..24fb96d84e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java @@ -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); } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/RepositoryPathCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/repository/RepositoryPathCollectorTest.java new file mode 100644 index 0000000000..7f8ca61b6d --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/RepositoryPathCollectorTest.java @@ -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; + } +} diff --git a/yarn.lock b/yarn.lock index 6cd7084c62..21f4e8b979 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"