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:
Till-André Diegeler
2025-03-24 15:34:06 +01:00
parent a47c3d4fc4
commit 1fea8429b1
18 changed files with 451 additions and 154 deletions

View File

@@ -10,8 +10,8 @@ verfügbaren Tastenkürzel mittels der `?`-Taste aufrufen.
### Globale Tastenkürzel
| Key Combination | Description |
|-----------------|-------------------------------------|
| Tastenkürzel | Beschreibung |
|--------------|-------------------------------------|
| ? | Öffne die Tastaturkürzelübersicht |
| / | Fokussiere die globale Schnellsuche |
| alt r | Navigiere zur Repositoryübersicht |
@@ -25,21 +25,26 @@ 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 |
|-----------------|---------------------------------------------------|
| 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 |
|-----------------|------------------------------|
| 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
Plugins können selbst neue Tastenkürzel definieren.

View File

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

View 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

View File

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

View File

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

View File

@@ -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={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }) => (
const ResultTable: FC<ResultTableProps> = ({ contentBaseUrl, paths }) => {
return (
<table className="table table-hover table-sm is-fullwidth">
<KeyboardIterator>
<tbody>
{paths.map(path => (
<PathResultRow contentBaseUrl={contentBaseUrl} path={path} />
{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;

View File

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

View File

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

View File

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

View File

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