From 1fea8429b1141f7d49ed1740a7c0896f503a162c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till-Andr=C3=A9=20Diegeler?= Date: Mon, 24 Mar 2025 15:34:06 +0100 Subject: [PATCH] Accessible File Search - keyboard combination g+f in code view - navigatable file search list with j+k - remove helptext tooltip --- docs/de/user/shortcuts/index.md | 43 +++-- docs/en/user/shortcuts/index.md | 6 + gradle/changelog/file_search.yaml | 4 + .../repository_code_filesearch_spec.ts | 2 +- scm-ui/ui-core/src/base/forms/index.ts | 1 + .../base/forms/input/ControlledInputField.tsx | 3 + .../base/forms/input/InputField.stories.mdx | 4 + .../src/base/forms/input/InputField.tsx | 15 +- .../src/base/notifications/Notification.tsx | 2 +- scm-ui/ui-webapp/public/locales/de/repos.json | 20 ++- scm-ui/ui-webapp/public/locales/en/repos.json | 8 +- .../components/FileSearchButton.tsx | 13 +- .../codeSection/components/FileSearchHit.tsx | 58 ++++++ .../components/FileSearchResults.tsx | 89 ++-------- .../codeSection/containers/FileSearch.tsx | 107 +++++++---- .../codeSection/utils/fileSearchLink.test.ts | 168 ++++++++++++++++++ .../repos/codeSection/utils/fileSearchLink.ts | 41 +++++ .../src/repos/sources/containers/Sources.tsx | 21 ++- 18 files changed, 451 insertions(+), 154 deletions(-) create mode 100644 gradle/changelog/file_search.yaml create mode 100644 scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchHit.tsx create mode 100644 scm-ui/ui-webapp/src/repos/codeSection/utils/fileSearchLink.test.ts create mode 100644 scm-ui/ui-webapp/src/repos/codeSection/utils/fileSearchLink.ts diff --git a/docs/de/user/shortcuts/index.md b/docs/de/user/shortcuts/index.md index 5e3a8eef3f..c377e7a39e 100644 --- a/docs/de/user/shortcuts/index.md +++ b/docs/de/user/shortcuts/index.md @@ -10,14 +10,14 @@ verfügbaren Tastenkürzel mittels der `?`-Taste aufrufen. ### Globale Tastenkürzel -| Key Combination | Description | -|-----------------|-------------------------------------| -| ? | Öffne die Tastaturkürzelübersicht | -| / | Fokussiere die globale Schnellsuche | -| alt r | Navigiere zur Repositoryübersicht | -| alt u | Navigiere zur Benutzerübersicht | -| alt g | Navigiere zur Gruppenübersicht | -| alt a | Navigiere zur Administration | +| Tastenkürzel | Beschreibung | +|--------------|-------------------------------------| +| ? | Öffne die Tastaturkürzelübersicht | +| / | Fokussiere die globale Schnellsuche | +| alt r | Navigiere zur Repositoryübersicht | +| alt u | Navigiere zur Benutzerübersicht | +| alt g | Navigiere zur Gruppenübersicht | +| alt a | Navigiere zur Administration | ### Navigation von Listen @@ -25,20 +25,25 @@ Einige Seiten mit Listen erlauben die Navigation per Tastatur. Wenn die Seite dieses unterstützt, tauchen die Tastaturkürzel in der Übersicht im SCM-Manager auf (`?`). -| Key Combination | Description | -|-----------------|---------------------------------------------------| -| j | Bewege den Fokus auf den nächsten Listeneintrag | -| k | Bewege den Fokus auf den vorherigen Listeneintrag | +| Tastenkürzel | Beschreibung | +|--------------|---------------------------------------------------| +| j | Bewege den Fokus auf den nächsten Listeneintrag | +| k | Bewege den Fokus auf den vorherigen Listeneintrag | ### Repositoryspezifische Tastenkürzel -| Key Combination | Description | -|-----------------|------------------------------| -| g i | Wechsel zur Repository-Info | -| g b | Wechsel zu den Branches | -| g t | Wechsel zu den Tags | -| g c | Wechsel zum Code | -| g s | Wechsel zu den Einstellungen | +| Tastenkürzel | Beschreibung | +|--------------|------------------------------| +| g i | Wechsel zur Repository-Info | +| g b | Wechsel zu den Branches | +| g t | Wechsel zu den Tags | +| g c | Wechsel zum Code | +| g s | Wechsel zu den Einstellungen | + +### Codespezifische Tastenkürzel +| Tastenkürzel | Beschreibung | +|--------------|------------------------| +| g f | Wechsel zur Dateisuche | ### Tastenkürzel aus Plugin diff --git a/docs/en/user/shortcuts/index.md b/docs/en/user/shortcuts/index.md index 4e092827b6..aa691b9f29 100644 --- a/docs/en/user/shortcuts/index.md +++ b/docs/en/user/shortcuts/index.md @@ -39,6 +39,12 @@ If the page supports this feature, the shortcuts show up in the shortcut overvie | g c | Switch to code | | g s | Switch to settings | +### Code-specific Shortcuts + +| Key Combination | Description | +|-----------------|---------------------------| +| g f | Switch to file search | + ### Plugin Shortcuts Plugins can introduce new shortcuts. diff --git a/gradle/changelog/file_search.yaml b/gradle/changelog/file_search.yaml new file mode 100644 index 0000000000..8c9c0255b3 --- /dev/null +++ b/gradle/changelog/file_search.yaml @@ -0,0 +1,4 @@ +- type: added + description: Keyboard shortcut (g+f) within code view for file search +- type: changed + description: File search page design; in particular with regard to accessibility diff --git a/scm-ui/e2e-tests/cypress/integration/repository_code_filesearch_spec.ts b/scm-ui/e2e-tests/cypress/integration/repository_code_filesearch_spec.ts index b4d574b44f..ee33b9d97e 100644 --- a/scm-ui/e2e-tests/cypress/integration/repository_code_filesearch_spec.ts +++ b/scm-ui/e2e-tests/cypress/integration/repository_code_filesearch_spec.ts @@ -43,7 +43,7 @@ describe("Repository File Search", () => { // Act cy.visit(`/repo/${namespace}/${name}/code/sources`); cy.byTestId("file_search_button").click(); - cy.url().should("include", `/repo/${namespace}/${name}/code/search/main?q=`); + cy.url().should("include", `/repo/${namespace}/${name}/code/search/main`); cy.byTestId("file_search_filter_input").type("README"); // Assert diff --git a/scm-ui/ui-core/src/base/forms/index.ts b/scm-ui/ui-core/src/base/forms/index.ts index 0ee36d79a2..7c5ae5a252 100644 --- a/scm-ui/ui-core/src/base/forms/index.ts +++ b/scm-ui/ui-core/src/base/forms/index.ts @@ -43,6 +43,7 @@ export { default as ConfigurationForm } from "./ConfigurationForm"; export { default as SelectField } from "./select/SelectField"; export { default as ComboboxField } from "./combobox/ComboboxField"; export { default as Input } from "./input/Input"; +export { default as InputField } from "./input/InputField"; export { default as Textarea } from "./input/Textarea"; export { default as Select } from "./select/Select"; export * from "./resourceHooks"; diff --git a/scm-ui/ui-core/src/base/forms/input/ControlledInputField.tsx b/scm-ui/ui-core/src/base/forms/input/ControlledInputField.tsx index f2fe5df2a2..6c7157a3e4 100644 --- a/scm-ui/ui-core/src/base/forms/input/ControlledInputField.tsx +++ b/scm-ui/ui-core/src/base/forms/input/ControlledInputField.tsx @@ -29,6 +29,7 @@ type Props> = Omit< rules?: ComponentProps["rules"]; name: Path; label?: string; + icon?: string; }; function ControlledInputField>({ @@ -41,6 +42,7 @@ function ControlledInputField>({ defaultValue, readOnly, className, + icon, ...props }: Props) { const { control, t, readOnly: formReadonly, formId } = useScmFormContext(); @@ -66,6 +68,7 @@ function ControlledInputField>({ {...field} form={formId} label={labelTranslation} + icon={icon} helpText={helpTextTranslation} descriptionText={descriptionTextTranslation} error={ diff --git a/scm-ui/ui-core/src/base/forms/input/InputField.stories.mdx b/scm-ui/ui-core/src/base/forms/input/InputField.stories.mdx index 761470af7e..16c8cad121 100644 --- a/scm-ui/ui-core/src/base/forms/input/InputField.stories.mdx +++ b/scm-ui/ui-core/src/base/forms/input/InputField.stories.mdx @@ -20,3 +20,7 @@ This will be our first form field molecule + + + + diff --git a/scm-ui/ui-core/src/base/forms/input/InputField.tsx b/scm-ui/ui-core/src/base/forms/input/InputField.tsx index 1bfbfa1a9f..df7c708f5a 100644 --- a/scm-ui/ui-core/src/base/forms/input/InputField.tsx +++ b/scm-ui/ui-core/src/base/forms/input/InputField.tsx @@ -23,24 +23,26 @@ import Input from "./Input"; import Help from "../base/help/Help"; import { useAriaId } from "../../helpers"; -type InputFieldProps = { +export type InputFieldProps = { label: string; + labelClassName?: string; helpText?: string; descriptionText?: string; error?: string; + icon?: string; } & React.ComponentProps; /** * @see https://bulma.io/documentation/form/input/ */ const InputField = React.forwardRef( - ({ name, label, helpText, descriptionText, error, className, id, ...props }, ref) => { + ({ name, label, helpText, descriptionText, error, icon, className, labelClassName, id, ...props }, ref) => { const inputId = useAriaId(id ?? props.testId); const descriptionId = descriptionText ? `input-description-${name}` : undefined; const variant = error ? "danger" : undefined; return ( - diff --git a/scm-ui/ui-core/src/base/notifications/Notification.tsx b/scm-ui/ui-core/src/base/notifications/Notification.tsx index 195919c722..91b4c32a4e 100644 --- a/scm-ui/ui-core/src/base/notifications/Notification.tsx +++ b/scm-ui/ui-core/src/base/notifications/Notification.tsx @@ -30,7 +30,7 @@ const Notification = React.forwardRef +
{onClose ?
diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index e5a28265ee..be2125461a 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -24,7 +24,7 @@ "help": { "namespaceHelpText": "Der Namespace des Repository. Dieser wird Teil der URL des Repository sein.", "nameHelpText": "Der Name des Repository. Dieser wird Teil der URL des Repository sein.", - "contactHelpText": "E-Mail Adresse der Person, die für das Repository verantwortlich ist.", + "contactHelpText": "E-Mail-Adresse der Person, die für das Repository verantwortlich ist.", "descriptionHelpText": "Eine kurze Beschreibung des Repository.", "initializeRepository": "Erstellt einen ersten Branch und committet eine README.md.", "importUrlHelpText": "Importiert das gesamte Repository inkl. aller Branches und Tags über die Remote URL.", @@ -311,7 +311,7 @@ "shortSummary": "Committet <0/> <1/>", "tags": "Tags", "diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt", - "keyOwner": "Schlüssel Besitzer", + "keyOwner": "Schlüsselbesitzer", "signatureStatus": "Status", "keyId": "Schlüssel-ID", "keyContacts": "Kontakte", @@ -527,8 +527,8 @@ "modal": { "title": "Repository umbenennen", "label": { - "repoName": "Repository Name", - "repoNamespace": "Repository Namespace" + "repoName": "Repository-Name", + "repoNamespace": "Repository-Namespace" }, "button": { "rename": "Umbenennen", @@ -618,23 +618,25 @@ }, "fileSearch": { "button": { - "title": "Dateipfad Suche" + "title": "Dateipfadsuche" }, "file": "Datei", "home": "Zurück zu Sources", "input": { - "placeholder": "Dateipfad Suche", - "help": "Tippe 2 oder mehr Zeichen ein, um die Suche zu starten" + "placeholder": "Dateipfadsuche", + "help": "Tippen Sie mindestens zwei Zeichen ein, um die Suche zu starten." }, + "results_one": "{{count}} Ergebnis gefunden:", + "results_other": "{{count}} Ergebnisse gefunden:", "notifications": { - "queryToShort": "Tippe mindestens 2 Zeichen ein, um die Suche zu starten", - "emptyResult": "Es wurden keine Ergebnisse für <0>{{query}} gefunden" + "emptyResult": "Keine Ergebnisse gefunden für: <0>{{query}}." }, "searchWithRevisionAndNamespaceName": "Suche auf {{revision}} in {{namespace}}/{{name}}" }, "shortcuts": { "info": "Wechsel zur Repository-Info", "branches": "Wechsel zu den Branches", + "fileSearch": "Wechsel zur Dateisuche", "tags": "Wechsel zu den Tags", "code": "Wechsel zum Code", "settings": "Wechsel zu den Einstellungen" diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index e6b7727b47..13e7983fd6 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -624,17 +624,19 @@ "home": "Go back to source root", "input": { "placeholder": "Search filepath", - "help": "Type 2 or more letters to search for a filepath in the repository" + "help": "Type at least two letters to start the search." }, + "results_one": "{{count}} result found:", + "results_other": "{{count}} results found:", "notifications": { - "queryToShort": "Type at least two characters to start the search", - "emptyResult": "Nothing found for query <0>{{query}}" + "emptyResult": "Nothing found for: <0>{{query}}." }, "searchWithRevisionAndNamespaceName": "Search on {{revision}} in {{namespace}}/{{name}}" }, "shortcuts": { "info": "Switch to repository info", "branches": "Switch to branches", + "fileSearch": "Switch to file search", "tags": "Switch to tags", "code": "Switch to code", "settings": "Switch to settings" diff --git a/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchButton.tsx b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchButton.tsx index b2e2fc7ae2..4a5d1e6335 100644 --- a/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchButton.tsx +++ b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchButton.tsx @@ -18,9 +18,9 @@ import React, { FC } from "react"; import { useTranslation } from "react-i18next"; import { File, Repository } from "@scm-manager/ui-types"; import { Link } from "react-router-dom"; -import { Icon } from "@scm-manager/ui-components"; import styled from "styled-components"; -import { urls } from "@scm-manager/ui-api"; +import { getFileSearchLink } from "../utils/fileSearchLink"; +import { Icon } from "@scm-manager/ui-core"; type Props = { repository: Repository; @@ -35,18 +35,13 @@ const SearchIcon = styled(Icon)` const FileSearchButton: FC = ({ baseUrl, revision, currentSource, repository }) => { const [t] = useTranslation("repos"); - const currentSourcePath = - repository.type === "svn" - ? urls.createPrevSourcePathQuery(`${revision}/${currentSource.path}`) - : urls.createPrevSourcePathQuery(currentSource.path); - return ( - + search ); }; diff --git a/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchHit.tsx b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchHit.tsx new file mode 100644 index 0000000000..8fbb3c127c --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchHit.tsx @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import React from "react"; +import { useTranslation } from "react-i18next"; +import { urls } from "@scm-manager/ui-api"; +import { Icon, useKeyboardIteratorTarget } from "@scm-manager/ui-core"; +import { Link } from "react-router-dom"; +import styled from "styled-components"; + +type FileSearchHitProps = { + contentBaseUrl: string; + path: 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; +`; + +export function FileSearchHit({ contentBaseUrl, path }: FileSearchHitProps) { + const [t] = useTranslation("repos"); + const link = urls.concat(contentBaseUrl, path); + const ref = useKeyboardIteratorTarget(); + return ( + + + + + {path} + + + + ); +} diff --git a/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchResults.tsx b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchResults.tsx index 40567d86fa..9a13648ace 100644 --- a/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchResults.tsx +++ b/scm-ui/ui-webapp/src/repos/codeSection/components/FileSearchResults.tsx @@ -15,89 +15,34 @@ */ 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"; - +import { FileSearchHit } from "./FileSearchHit"; +import { KeyboardIterator } from "@scm-manager/ui-core"; 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; -`; - -type PathResultRowProps = { - contentBaseUrl: string; - path: string; -}; - -const PathResultRow: FC = ({ contentBaseUrl, path }) => { - const [t] = useTranslation("repos"); - const link = urls.concat(contentBaseUrl, path); - return ( - - - - - - - - - {path} - - - - ); -}; - type ResultTableProps = { contentBaseUrl: string; paths: string[]; }; -const ResultTable: FC = ({ contentBaseUrl, paths }) => ( - - - {paths.map(path => ( - - ))} - -
-); +const ResultTable: FC = ({ contentBaseUrl, paths }) => { + return ( + + + + {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}
; +const FileSearchResults: FC = ({ contentBaseUrl, paths = [] }) => { + return ; }; export default FileSearchResults; diff --git a/scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx b/scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx index 1863a963f6..822b34219c 100644 --- a/scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx +++ b/scm-ui/ui-webapp/src/repos/codeSection/containers/FileSearch.tsx @@ -14,15 +14,15 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import React, { FC, useEffect, useState } from "react"; +import React, { FC, useState } from "react"; import { Link, useHistory, useLocation, useParams } from "react-router-dom"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import classNames from "classnames"; import styled from "styled-components"; import { Branch, Repository } from "@scm-manager/ui-types"; import { urls, usePaths } from "@scm-manager/ui-api"; -import { createA11yId, ErrorNotification, FilterInput, Help, Icon, Loading } from "@scm-manager/ui-components"; -import { useDocumentTitle } from "@scm-manager/ui-core"; +import { createA11yId } from "@scm-manager/ui-components"; +import { ErrorNotification, Icon, Loading, Notification, InputField, useDocumentTitle } from "@scm-manager/ui-core"; import CodeActionBar from "../components/CodeActionBar"; import FileSearchResults from "../components/FileSearchResults"; import { filepathSearch } from "../utils/filepathSearch"; @@ -57,10 +57,10 @@ const FileSearch: FC = ({ repository, baseUrl, branches, selectedBranch } const location = useLocation(); const history = useHistory(); const { isLoading, error, data } = usePaths(repository, revision); - const [result, setResult] = useState([]); const query = urls.getQueryStringFromLocation(location) || ""; const prevSourcePath = urls.getPrevSourcePathFromLocation(location) || ""; const [t] = useTranslation("repos"); + useDocumentTitle( t("fileSearch.searchWithRevisionAndNamespaceName", { revision: decodeURIComponent(revision), @@ -70,25 +70,32 @@ const FileSearch: FC = ({ repository, baseUrl, branches, selectedBranch } ); const [firstSelectedBranch] = useState(selectedBranch); - useEffect(() => { - if (query.length > 1 && data) { - setResult(filepathSearch(data.paths, query)); - } else { - setResult([]); - } - }, [data, query]); + let result: string[]; - const search = (query: string) => { + if (data && query && query.length > 1) { + result = filepathSearch(data.paths, query); + } else { + result = []; + } + + const onSearch = (e: React.ChangeEvent) => { + const query = e.currentTarget.value; const prevSourceQuery = urls.createPrevSourcePathQuery(prevSourcePath); - history.push(`${location.pathname}?q=${encodeURIComponent(query)}${prevSourceQuery ? `&${prevSourceQuery}` : ""}`); + if (prevSourceQuery) { + history.push(`${location.pathname}?q=${encodeURIComponent(query)}`); + } else { + history.push(`${location.pathname}?q=${encodeURIComponent(query)}&${prevSourceQuery}`); + } }; const onSelectBranch = (branch?: Branch) => { if (branch) { const prevSourceQuery = urls.createPrevSourcePathQuery(prevSourcePath); - history.push( - `${baseUrl}/search/${encodeURIComponent(branch.name)}?q=${query}${prevSourceQuery ? `&${prevSourceQuery}` : ""}` - ); + if (prevSourceQuery) { + history.push(`${baseUrl}/search/${encodeURIComponent(branch.name)}?q=${query}&${prevSourceQuery}`); + } else { + history.push(`${baseUrl}/search/${encodeURIComponent(branch.name)}?q=${query}`); + } } }; @@ -107,11 +114,46 @@ const FileSearch: FC = ({ repository, baseUrl, branches, selectedBranch } }`; } - return `${baseUrl}/changesets/${prevSourcePath ? `?${urls.createPrevSourcePathQuery(prevSourcePath)}` : ""}`; + if (prevSourcePath) { + return `${baseUrl}/changesets/${urls.createPrevSourcePathQuery(prevSourcePath)}`; + } else { + return `${baseUrl}/changesets/${prevSourcePath}`; + } }; const contentBaseUrl = `${baseUrl}/sources/${revision}/`; - const id = createA11yId("file-search"); + + const fileSearchDescriptionId = createA11yId("fileSearchDescription"); + let body; + + if (query.length <= 1) { + body = ( + + ); + } else if (!isLoading && result.length === 0) { + const queryCmp = {query}; + body = ( +

+ +

+ ); + } else { + body = ( + <> + + + + ); + } return ( <> @@ -135,27 +177,28 @@ const FileSearch: FC = ({ repository, baseUrl, branches, selectedBranch } > evaluateSwitchViewLink("sources")} > - + + {firstSelectedBranch !== selectedBranch ? "home" : "arrow-left"} + - - - {isLoading ? : } +
{isLoading ? : body}
); diff --git a/scm-ui/ui-webapp/src/repos/codeSection/utils/fileSearchLink.test.ts b/scm-ui/ui-webapp/src/repos/codeSection/utils/fileSearchLink.test.ts new file mode 100644 index 0000000000..4aa13f74ae --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/codeSection/utils/fileSearchLink.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { File, Repository } from "@scm-manager/ui-types"; +import { getFileSearchLink } from "./fileSearchLink"; + +describe("getFileSearchLink", () => { + describe("git", () => { + it("shouldCreateCorrectLinkFromGitRootDirectory", () => { + const link = getFileSearchLink(repository, "main", baseUrl, rootDirectory); + expect(link).toBe("repo/captain/kirk/code/search/main"); + }); + + it("shouldCreateCorrectLinkFromGitFirstLevelDirectory", () => { + const link = getFileSearchLink(repository, "main", baseUrl, firstLevelDirectory); + expect(link).toBe("repo/captain/kirk/code/search/main?prevSourcePath=dir"); + }); + + it("shouldCreateCorrectLinkFromGitSecondLevelDirectory", () => { + const link = getFileSearchLink(repository, "main", baseUrl, secondLevelDirectory); + expect(link).toBe("repo/captain/kirk/code/search/main?prevSourcePath=dir%2FsubDir"); + }); + + const repository: Repository = { + _links: {}, + name: "kirk", + namespace: "captain", + type: "git", + }; + + const firstLevelDirectory: File = { + _links: {}, + directory: true, + name: "dir", + path: "dir", + revision: "main", + }; + + const secondLevelDirectory: File = { + _links: {}, + directory: true, + name: "subDir", + path: "dir/subDir", + revision: "main", + }; + + const rootDirectory: File = { + _links: {}, + directory: true, + name: "", + path: "", + revision: "main", + }; + + const baseUrl = "repo/captain/kirk/code"; + }); + describe("svn", () => { + it("shouldCreateCorrectLinkFromGitRootDirectory", () => { + const link = getFileSearchLink(repository, "42", baseUrl, rootDirectory); + expect(link).toBe("repo/subversion/space/code/search/42?prevSourcePath=42"); + }); + + it("shouldCreateCorrectLinkFromGitFirstLevelDirectory", () => { + const link = getFileSearchLink(repository, "0", baseUrl, firstLevelDirectory); + expect(link).toBe("repo/subversion/space/code/search/0?prevSourcePath=0%2Ftrunk"); + }); + + it("shouldCreateCorrectLinkFromGitSecondLevelDirectory", () => { + const link = getFileSearchLink(repository, "42", baseUrl, secondLevelDirectory); + expect(link).toBe("repo/subversion/space/code/search/42?prevSourcePath=42%2Ftrunk%2FsubDir"); + }); + + const repository: Repository = { + _links: {}, + name: "space", + namespace: "subversion", + type: "svn", + }; + + const firstLevelDirectory: File = { + _links: {}, + directory: true, + name: "trunk", + path: "trunk", + revision: "0", + }; + + const secondLevelDirectory: File = { + _links: {}, + directory: true, + name: "subDir", + path: "trunk/subDir", + revision: "42", + }; + + const rootDirectory: File = { + _links: {}, + directory: true, + name: "", + path: "", + revision: "42", + }; + + const baseUrl = "repo/subversion/space/code"; + }); + describe("hg", () => { + it("shouldCreateCorrectLinkFromHgRootDirectory", () => { + const link = getFileSearchLink(repository, "default", baseUrl, rootDirectory); + expect(link).toBe("repo/heavy/metal/code/search/default"); + }); + + it("shouldCreateCorrectLinkFromGitFirstLevelDirectory", () => { + const link = getFileSearchLink(repository, "default", baseUrl, firstLevelDirectory); + expect(link).toBe("repo/heavy/metal/code/search/default?prevSourcePath=dir"); + }); + + it("shouldCreateCorrectLinkFromGitSecondLevelDirectory", () => { + const link = getFileSearchLink(repository, "default", baseUrl, secondLevelDirectory); + expect(link).toBe("repo/heavy/metal/code/search/default?prevSourcePath=dir%2FsubDir"); + }); + + const repository: Repository = { + _links: {}, + name: "metal", + namespace: "heavy", + type: "hg", + }; + + const firstLevelDirectory: File = { + _links: {}, + directory: true, + name: "dir", + path: "dir", + revision: "default", + }; + + const secondLevelDirectory: File = { + _links: {}, + directory: true, + name: "subDir", + path: "dir/subDir", + revision: "default", + }; + + const rootDirectory: File = { + _links: {}, + directory: true, + name: "", + path: "", + revision: "default", + }; + + const baseUrl = "repo/heavy/metal/code"; + }); +}); diff --git a/scm-ui/ui-webapp/src/repos/codeSection/utils/fileSearchLink.ts b/scm-ui/ui-webapp/src/repos/codeSection/utils/fileSearchLink.ts new file mode 100644 index 0000000000..4e2d1659c9 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/codeSection/utils/fileSearchLink.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { File, Repository } from "@scm-manager/ui-types"; +import { urls } from "@scm-manager/ui-api"; + +export function getFileSearchLink( + repository: Repository, + revision: string, + baseUrl: string, + currentSource: File +): string { + let currentSourcePath = null; + + if (repository.type === "svn" && currentSource.path) { + currentSourcePath = urls.createPrevSourcePathQuery(`${revision}/${currentSource.path}`); + } else if (repository.type === "svn") { + currentSourcePath = urls.createPrevSourcePathQuery(`${revision}`); + } else { + currentSourcePath = urls.createPrevSourcePathQuery(currentSource.path); + } + + if (currentSourcePath) { + return `${baseUrl}/search/${encodeURIComponent(revision)}?${currentSourcePath}`; + } else { + return `${baseUrl}/search/${encodeURIComponent(revision)}`; + } +} 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 f25009fcfa..3ea4ec1074 100644 --- a/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/containers/Sources.tsx @@ -19,8 +19,8 @@ import { useTranslation } from "react-i18next"; import { useHistory, useLocation, useParams } from "react-router-dom"; import { RepositoryRevisionContextProvider, urls, useSources } from "@scm-manager/ui-api"; import { Branch, Repository } from "@scm-manager/ui-types"; -import { Breadcrumb } from "@scm-manager/ui-components"; -import { Notification, ErrorNotification, Loading, useDocumentTitle } from "@scm-manager/ui-core"; +import { Breadcrumb, useScrollToElement, useShortcut } from "@scm-manager/ui-components"; +import { ErrorNotification, Loading, Notification, useDocumentTitle } from "@scm-manager/ui-core"; import FileTree from "../components/FileTree"; import Content from "./Content"; import CodeActionBar from "../../codeSection/components/CodeActionBar"; @@ -30,7 +30,7 @@ import { isEmptyDirectory, isRootFile } from "../utils/files"; import CompareLink from "../../compare/CompareLink"; import { encodePart } from "../components/content/FileLink"; import { ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; -import { useScrollToElement } from "@scm-manager/ui-components"; +import { getFileSearchLink } from "../../codeSection/utils/fileSearchLink"; type Props = { repository: Repository; @@ -110,6 +110,19 @@ const Sources: FC = ({ repository, branches, selectedBranch, baseUrl }) = enabled: !branches || !!selectedBranch, }); + useShortcut( + "g f", + () => { + if (file) { + history.push(getFileSearchLink(repository, file.revision, baseUrl, file)); + } + }, + { + description: t("shortcuts.fileSearch"), + active: !!(repository._links.paths && file), + } + ); + if (error) { return ; } @@ -155,7 +168,7 @@ const Sources: FC = ({ repository, branches, selectedBranch, baseUrl }) = const permalink = file?.revision ? replaceBranchWithRevision(location.pathname, file.revision) : null; const buttons = []; - if (repository._links.paths) { + if (repository._links.paths && file) { buttons.push(