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 (
-
+
{label}
{helpText ? : null}
@@ -49,8 +51,13 @@ const InputField = React.forwardRef(
{descriptionText}
) : null}
-
+
+ {icon ? (
+
+
+
+ ) : null}
{error ? {error} : null}
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 ? : null}
{children}
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}}0> gefunden"
+ "emptyResult": "Keine Ergebnisse gefunden für: <0>{{query}}0>."
},
"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}}0>"
+ "emptyResult": "Nothing found for: <0>{{query}}0>."
},
"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 (
+
+
+ file
+
+
+
+ {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 = (
+ 1}>
+ {t("fileSearch.input.help")}
+
+ );
+ } else if (!isLoading && result.length === 0) {
+ const queryCmp = {query} ;
+ body = (
+
+
+
+ );
+ } else {
+ body = (
+ <>
+
+ {t("fileSearch.results", { count: result.length })}
+
+
+ >
+ );
+ }
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(