mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-12-23 08:49:48 +01:00
Accessible File Search
- keyboard combination g+f in code view - navigatable file search list with j+k - remove helptext tooltip
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
4
gradle/changelog/file_search.yaml
Normal file
4
gradle/changelog/file_search.yaml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -29,6 +29,7 @@ type Props<T extends Record<string, unknown>> = Omit<
|
||||
rules?: ComponentProps<typeof Controller>["rules"];
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
function ControlledInputField<T extends Record<string, unknown>>({
|
||||
@@ -41,6 +42,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
||||
defaultValue,
|
||||
readOnly,
|
||||
className,
|
||||
icon,
|
||||
...props
|
||||
}: Props<T>) {
|
||||
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||
@@ -66,6 +68,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
||||
{...field}
|
||||
form={formId}
|
||||
label={labelTranslation}
|
||||
icon={icon}
|
||||
helpText={helpTextTranslation}
|
||||
descriptionText={descriptionTextTranslation}
|
||||
error={
|
||||
|
||||
@@ -20,3 +20,7 @@ This will be our first form field molecule
|
||||
<Story name="WithWidth">
|
||||
<InputField label="MyInput" className="column is-half" />
|
||||
</Story>
|
||||
|
||||
<Story name="WithIcon">
|
||||
<InputField icon="fas fa-filter" />
|
||||
</Story>
|
||||
|
||||
@@ -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<typeof Input>;
|
||||
|
||||
/**
|
||||
* @see https://bulma.io/documentation/form/input/
|
||||
*/
|
||||
const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
||||
({ 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 (
|
||||
<Field className={className}>
|
||||
<Label htmlFor={inputId}>
|
||||
<Label htmlFor={inputId} className={labelClassName}>
|
||||
{label}
|
||||
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
||||
</Label>
|
||||
@@ -49,8 +51,13 @@ const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
||||
{descriptionText}
|
||||
</p>
|
||||
) : null}
|
||||
<Control>
|
||||
<Control className="has-icons-left">
|
||||
<Input variant={variant} ref={ref} id={inputId} aria-describedby={descriptionId} {...props}></Input>
|
||||
{icon ? (
|
||||
<span className="icon is-small is-left">
|
||||
<i className={icon} />
|
||||
</span>
|
||||
) : null}
|
||||
</Control>
|
||||
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
|
||||
</Field>
|
||||
|
||||
@@ -30,7 +30,7 @@ const Notification = React.forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElem
|
||||
const color = type !== "inherit" ? "is-" + type : "";
|
||||
|
||||
return (
|
||||
<div className={classNames("notification", color, className)} role={role} ref={ref}>
|
||||
<div className={classNames("notification", color, className)} role={role} ref={ref} {...props}>
|
||||
{onClose ? <button className="delete" onClick={onClose} /> : null}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Props> = ({ baseUrl, revision, currentSource, repository }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const currentSourcePath =
|
||||
repository.type === "svn"
|
||||
? urls.createPrevSourcePathQuery(`${revision}/${currentSource.path}`)
|
||||
: urls.createPrevSourcePathQuery(currentSource.path);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`${baseUrl}/search/${encodeURIComponent(revision)}?${currentSourcePath}`}
|
||||
to={getFileSearchLink(repository, revision, baseUrl, currentSource)}
|
||||
aria-label={t("fileSearch.button.title")}
|
||||
data-testid="file_search_button"
|
||||
>
|
||||
<SearchIcon title={t("fileSearch.button.title")} name="search" color="inherit" />
|
||||
<SearchIcon title={t("fileSearch.button.title")}>search</SearchIcon>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<tr>
|
||||
<IconColumn aria-hidden="true">
|
||||
<Icon title={t("fileSearch.file")}>file</Icon>
|
||||
</IconColumn>
|
||||
<LeftOverflowTd>
|
||||
<Link title={path} to={link} data-testid="file_search_single_result" ref={ref} key={path}>
|
||||
{path}
|
||||
</Link>
|
||||
</LeftOverflowTd>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -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<PathResultRowProps> = ({ contentBaseUrl, path }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const link = urls.concat(contentBaseUrl, path);
|
||||
return (
|
||||
<tr>
|
||||
<IconColumn>
|
||||
<Link to={link}>
|
||||
<Icon title={t("fileSearch.file")} name="file" color="inherit" />
|
||||
</Link>
|
||||
</IconColumn>
|
||||
<LeftOverflowTd>
|
||||
<Link title={path} to={link} data-testid="file_search_single_result">
|
||||
{path}
|
||||
</Link>
|
||||
</LeftOverflowTd>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
type ResultTableProps = {
|
||||
contentBaseUrl: string;
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
const ResultTable: FC<ResultTableProps> = ({ contentBaseUrl, paths }) => (
|
||||
<table className="table table-hover table-sm is-fullwidth">
|
||||
<tbody>
|
||||
{paths.map(path => (
|
||||
<PathResultRow contentBaseUrl={contentBaseUrl} path={path} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const ResultTable: FC<ResultTableProps> = ({ contentBaseUrl, paths }) => {
|
||||
return (
|
||||
<table className="table table-hover table-sm is-fullwidth">
|
||||
<KeyboardIterator>
|
||||
<tbody>
|
||||
{paths.map((path) => (
|
||||
<FileSearchHit contentBaseUrl={contentBaseUrl} path={path} key={path} />
|
||||
))}
|
||||
</tbody>
|
||||
</KeyboardIterator>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const FileSearchResults: FC<Props> = ({ query, contentBaseUrl, paths = [] }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
let body;
|
||||
if (query.length <= 1) {
|
||||
body = (
|
||||
<Notification className="m-4" type="info">
|
||||
{t("fileSearch.notifications.queryToShort")}
|
||||
</Notification>
|
||||
);
|
||||
} else if (paths.length === 0) {
|
||||
const queryCmp = <strong>{query}</strong>;
|
||||
body = (
|
||||
<Notification className="m-4" type="info">
|
||||
<Trans i18nKey="repos:fileSearch.notifications.emptyResult" values={{ query }} components={[queryCmp]} />
|
||||
</Notification>
|
||||
);
|
||||
} else {
|
||||
body = <ResultTable contentBaseUrl={contentBaseUrl} paths={paths} />;
|
||||
}
|
||||
return <div className="panel-block">{body}</div>;
|
||||
const FileSearchResults: FC<Props> = ({ contentBaseUrl, paths = [] }) => {
|
||||
return <ResultTable contentBaseUrl={contentBaseUrl} paths={paths} />;
|
||||
};
|
||||
|
||||
export default FileSearchResults;
|
||||
|
||||
@@ -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<Props> = ({ repository, baseUrl, branches, selectedBranch }
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { isLoading, error, data } = usePaths(repository, revision);
|
||||
const [result, setResult] = useState<string[]>([]);
|
||||
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<Props> = ({ repository, baseUrl, branches, selectedBranch }
|
||||
);
|
||||
const [firstSelectedBranch] = useState<string | undefined>(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<HTMLInputElement>) => {
|
||||
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<Props> = ({ 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 = (
|
||||
<Notification className="m-4" type="info" hidden={query.length > 1}>
|
||||
<p id={fileSearchDescriptionId}>{t("fileSearch.input.help")}</p>
|
||||
</Notification>
|
||||
);
|
||||
} else if (!isLoading && result.length === 0) {
|
||||
const queryCmp = <strong>{query}</strong>;
|
||||
body = (
|
||||
<p className="mt-3" id={fileSearchDescriptionId}>
|
||||
<Trans i18nKey="repos:fileSearch.notifications.emptyResult" values={{ query }} components={[queryCmp]} />
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
<p
|
||||
className="pl-4 has-text-weight-semibold"
|
||||
hidden={query.length <= 1}
|
||||
id={fileSearchDescriptionId}
|
||||
aria-hidden={true}
|
||||
>
|
||||
{t("fileSearch.results", { count: result.length })}
|
||||
</p>
|
||||
<FileSearchResults contentBaseUrl={contentBaseUrl} paths={result} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -135,27 +177,28 @@ const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }
|
||||
>
|
||||
<HomeLink
|
||||
className={classNames("mr-3", "pr-3")}
|
||||
aria-label={t("fileSearch.home")}
|
||||
to={firstSelectedBranch !== selectedBranch ? contentBaseUrl : () => evaluateSwitchViewLink("sources")}
|
||||
>
|
||||
<HomeIcon
|
||||
title={t("fileSearch.home")}
|
||||
name={firstSelectedBranch !== selectedBranch ? "home" : "arrow-left"}
|
||||
color="inherit"
|
||||
/>
|
||||
<HomeIcon title={t("fileSearch.home")} color="inherit">
|
||||
{firstSelectedBranch !== selectedBranch ? "home" : "arrow-left"}
|
||||
</HomeIcon>
|
||||
</HomeLink>
|
||||
<FilterInput
|
||||
className="is-full-width pr-2"
|
||||
placeholder={t("fileSearch.input.placeholder")}
|
||||
value={query}
|
||||
filter={search}
|
||||
<InputField
|
||||
autoFocus={true}
|
||||
id={id}
|
||||
placeholder={t("fileSearch.input.placeholder")}
|
||||
className="is-full-width is-flex pr-2"
|
||||
label=""
|
||||
labelClassName="mr-2 mt-2"
|
||||
defaultValue={query}
|
||||
aria-describedby={fileSearchDescriptionId}
|
||||
testId="file_search_filter_input"
|
||||
icon="fas fa-search"
|
||||
onChange={onSearch}
|
||||
/>
|
||||
<Help message={t("fileSearch.input.help")} id={id} />
|
||||
</div>
|
||||
<ErrorNotification error={error} />
|
||||
{isLoading ? <Loading /> : <FileSearchResults contentBaseUrl={contentBaseUrl} query={query} paths={result} />}
|
||||
<div className="panel-block">{isLoading ? <Loading /> : body}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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<Props> = ({ 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 <ErrorNotification error={error} />;
|
||||
}
|
||||
@@ -155,7 +168,7 @@ const Sources: FC<Props> = ({ 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(
|
||||
<FileSearchButton
|
||||
baseUrl={baseUrl}
|
||||
|
||||
Reference in New Issue
Block a user