Remember Path when switching to commits or file search

The path gets remembered by a query parameter.
Using React state to remember the current path has two downsides.
First you would need to wrap the components in a context and store the current state there.
Second the remembered state gets lost by refreshing the state.
By using a query parameter those two downside get avoided.

Committed-by: Thomas Zerr<thomas.zerr@cloudogu.com>
Co-authored-by: Thomas Zerr<thomas.zerr@cloudogu.com>
Pushed-by: Thomas Zerr<thomas.zerr@cloudogu.com>
This commit is contained in:
Thomas Zerr
2024-03-06 12:10:56 +01:00
parent a8d250c13f
commit 1a6e202384
8 changed files with 142 additions and 26 deletions

View File

@@ -0,0 +1,2 @@
- type: changed
description: The currently opened path is now remembered, when switching back and forth between Source, Commit and File Search view.

View File

@@ -24,7 +24,9 @@
import { import {
concat, concat,
createPrevSourcePathQuery,
getNamespaceAndPageFromMatch, getNamespaceAndPageFromMatch,
getPrevSourcePathFromLocation,
getQueryStringFromLocation, getQueryStringFromLocation,
getValueStringFromLocationByKey, getValueStringFromLocationByKey,
withEndingSlash, withEndingSlash,
@@ -143,3 +145,32 @@ describe("tests for getValueStringFromLocationByKey", () => {
expect(getValueStringFromLocationByKey(location, "namespace")).toBeUndefined(); expect(getValueStringFromLocationByKey(location, "namespace")).toBeUndefined();
}); });
}); });
describe("tests for getPrevSourcePathFromLocation", () => {
it("should return the value string", () => {
const location = { search: "?prevSourcePath=src%2Fsub%25%2Ffile%26%252F.abc" };
expect(getPrevSourcePathFromLocation(location)).toBe("src/sub%/file&%2F.abc");
});
it("should return undefined, because query parameter is missing", () => {
const location = { search: "?q=abc" };
expect(getPrevSourcePathFromLocation(location)).toBeUndefined();
});
it("should return undefined, because query parameter is missing", () => {
const location = { search: "?q=abc" };
expect(getPrevSourcePathFromLocation(location)).toBeUndefined();
});
});
describe("tests for createPrevSourcePathQuery", () => {
it("should return empty string if file path is empty", () => {
const encodedPath = createPrevSourcePathQuery("");
expect(encodedPath).toBe("");
});
it("should return the encoded path as query parameter", () => {
const encodedPath = createPrevSourcePathQuery("src/sub%/file&%2F.abc");
expect(encodedPath).toBe("prevSourcePath=src%2Fsub%25%2Ffile%26%252F.abc");
});
});

View File

@@ -125,3 +125,18 @@ export function escapeUrlForRoute(url: string) {
export function unescapeUrlForRoute(url: string) { export function unescapeUrlForRoute(url: string) {
return url.replace(/\\/g, ""); return url.replace(/\\/g, "");
} }
const prevSourcePathQueryName = "prevSourcePath";
export function getPrevSourcePathFromLocation(location: { search?: string }): string | undefined {
if (location.search) {
const prevSourcePath = queryString.parse(location.search)[prevSourcePathQueryName];
if (prevSourcePath && !Array.isArray(prevSourcePath)) {
return prevSourcePath;
}
}
}
export const createPrevSourcePathQuery = (filePath: string) => {
return filePath ? `${prevSourcePathQueryName}=${encodeURIComponent(filePath)}` : "";
};

View File

@@ -23,24 +23,33 @@
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { File, Repository } from "@scm-manager/ui-types";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Icon } from "@scm-manager/ui-components"; import { Icon } from "@scm-manager/ui-components";
import styled from "styled-components"; import styled from "styled-components";
import { urls } from "@scm-manager/ui-api";
type Props = { type Props = {
repository: Repository;
revision: string; revision: string;
baseUrl: string; baseUrl: string;
currentSource: File;
}; };
const SearchIcon = styled(Icon)` const SearchIcon = styled(Icon)`
line-height: 1.5rem; line-height: 1.5rem;
`; `;
const FileSearchButton: FC<Props> = ({ baseUrl, revision }) => { const FileSearchButton: FC<Props> = ({ baseUrl, revision, currentSource, repository }) => {
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const currentSourcePath =
repository.type === "svn"
? urls.createPrevSourcePathQuery(`${revision}/${currentSource.path}`)
: urls.createPrevSourcePathQuery(currentSource.path);
return ( return (
<Link <Link
to={`${baseUrl}/search/${encodeURIComponent(revision)}`} to={`${baseUrl}/search/${encodeURIComponent(revision)}/?${currentSourcePath}`}
aria-label={t("fileSearch.button.title")} aria-label={t("fileSearch.button.title")}
data-testid="file_search_button" data-testid="file_search_button"
> >

View File

@@ -32,6 +32,7 @@ import { createA11yId, ErrorNotification, FilterInput, Help, Icon, Loading } fro
import CodeActionBar from "../components/CodeActionBar"; import CodeActionBar from "../components/CodeActionBar";
import FileSearchResults from "../components/FileSearchResults"; import FileSearchResults from "../components/FileSearchResults";
import { filepathSearch } from "../utils/filepathSearch"; import { filepathSearch } from "../utils/filepathSearch";
import { encodeFilePath } from "../../sources/components/content/FileLink";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -64,7 +65,10 @@ const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }
const { isLoading, error, data } = usePaths(repository, revision); const { isLoading, error, data } = usePaths(repository, revision);
const [result, setResult] = useState<string[]>([]); const [result, setResult] = useState<string[]>([]);
const query = urls.getQueryStringFromLocation(location) || ""; const query = urls.getQueryStringFromLocation(location) || "";
const prevSourcePath = urls.getPrevSourcePathFromLocation(location) || "";
const [t] = useTranslation("repos"); const [t] = useTranslation("repos");
const [firstSelectedBranch, setBranchChanged] = useState<string | undefined>(selectedBranch);
useEffect(() => { useEffect(() => {
if (query.length > 1 && data) { if (query.length > 1 && data) {
setResult(filepathSearch(data.paths, query)); setResult(filepathSearch(data.paths, query));
@@ -74,20 +78,35 @@ const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }
}, [data, query]); }, [data, query]);
const search = (query: string) => { const search = (query: string) => {
history.push(`${location.pathname}?q=${encodeURIComponent(query)}`); const prevSourceQuery = urls.createPrevSourcePathQuery(prevSourcePath);
history.push(`${location.pathname}?q=${encodeURIComponent(query)}${prevSourceQuery ? `&${prevSourceQuery}` : ""}`);
}; };
const onSelectBranch = (branch?: Branch) => { const onSelectBranch = (branch?: Branch) => {
if (branch) { if (branch) {
history.push(`${baseUrl}/search/${encodeURIComponent(branch.name)}?q=${query}`); const prevSourceQuery = urls.createPrevSourcePathQuery(prevSourcePath);
history.push(
`${baseUrl}/search/${encodeURIComponent(branch.name)}?q=${query}${prevSourceQuery ? `&${prevSourceQuery}` : ""}`
);
} }
}; };
const evaluateSwitchViewLink = (type: string) => { const evaluateSwitchViewLink = (type: string) => {
if (type === "sources") { if (type === "sources" && repository.type !== "svn") {
return `${baseUrl}/sources/${revision}/`; return `${baseUrl}/sources/${revision}/${encodeFilePath(prevSourcePath)}`;
} }
return `${baseUrl}/changesets/${revision}/`;
if (type === "sources" && repository.type === "svn") {
return `${baseUrl}/sources/${encodeFilePath(prevSourcePath)}`;
}
if (repository.type !== "svn") {
return `${baseUrl}/branch/${revision}/changesets/${
prevSourcePath ? `?${urls.createPrevSourcePathQuery(prevSourcePath)}` : ""
}`;
}
return `${baseUrl}/changesets/${prevSourcePath ? `?${urls.createPrevSourcePathQuery(prevSourcePath)}` : ""}`;
}; };
const contentBaseUrl = `${baseUrl}/sources/${revision}/`; const contentBaseUrl = `${baseUrl}/sources/${revision}/`;
@@ -113,8 +132,15 @@ const FileSearch: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }
"pb-0" "pb-0"
)} )}
> >
<HomeLink className={classNames("mr-3", "pr-3")} to={contentBaseUrl}> <HomeLink
<HomeIcon title={t("fileSearch.home")} name="home" color="inherit" /> className={classNames("mr-3", "pr-3")}
to={firstSelectedBranch !== selectedBranch ? contentBaseUrl : () => evaluateSwitchViewLink("sources")}
>
<HomeIcon
title={t("fileSearch.home")}
name={firstSelectedBranch !== selectedBranch ? "home" : "arrow-left"}
color="inherit"
/>
</HomeLink> </HomeLink>
<FilterInput <FilterInput
className="is-full-width pr-2" className="is-full-width pr-2"

View File

@@ -23,13 +23,13 @@
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { Route, useRouteMatch, useHistory } from "react-router-dom"; import { Route, useRouteMatch, useHistory, useLocation } from "react-router-dom";
import { Repository, Branch } from "@scm-manager/ui-types"; import { Repository, Branch } from "@scm-manager/ui-types";
import CodeActionBar from "../codeSection/components/CodeActionBar"; import CodeActionBar from "../codeSection/components/CodeActionBar";
import { urls } from "@scm-manager/ui-components"; import { urls } from "@scm-manager/ui-components";
import Changesets from "./Changesets"; import Changesets from "./Changesets";
import { RepositoryRevisionContextProvider } from "@scm-manager/ui-api"; import { RepositoryRevisionContextProvider } from "@scm-manager/ui-api";
import { encodePart } from "../sources/components/content/FileLink"; import { encodeFilePath, encodePart } from "../sources/components/content/FileLink";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -41,29 +41,37 @@ type Props = {
const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }) => { const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranch }) => {
const match = useRouteMatch(); const match = useRouteMatch();
const history = useHistory(); const history = useHistory();
const location = useLocation();
if (!repository) { if (!repository) {
return null; return null;
} }
const url = urls.stripEndingSlash(urls.escapeUrlForRoute(match.url)); const url = urls.stripEndingSlash(urls.escapeUrlForRoute(match.url));
const defaultBranch = branches?.find(b => b.defaultBranch === true); const defaultBranch = branches?.find((b) => b.defaultBranch === true);
const isBranchAvailable = () => { const isBranchAvailable = () => {
return branches?.filter(b => b.name === selectedBranch).length === 0; return branches?.filter((b) => b.name === selectedBranch).length === 0;
}; };
const evaluateSwitchViewLink = () => { const evaluateSwitchViewLink = () => {
const sourcePath = encodeFilePath(urls.getPrevSourcePathFromLocation(location) || "");
if (selectedBranch) { if (selectedBranch) {
return `${baseUrl}/sources/${encodePart(selectedBranch)}/`; return `${baseUrl}/sources/${encodePart(selectedBranch)}/${sourcePath}`;
} }
if (repository.type === "svn") {
return `${baseUrl}/sources/${sourcePath !== "/" ? sourcePath : ""}`;
}
return `${baseUrl}/sources/`; return `${baseUrl}/sources/`;
}; };
const onSelectBranch = (branch?: Branch) => { const onSelectBranch = (branch?: Branch) => {
if (branch) { if (branch) {
history.push(`${baseUrl}/branch/${encodePart(branch.name)}/changesets/`); history.push(`${baseUrl}/branch/${encodePart(branch.name)}/changesets/${location.search}`);
} else { } else {
history.push(`${baseUrl}/changesets/`); history.push(`${baseUrl}/changesets/${location.search}`);
} }
}; };
@@ -76,7 +84,7 @@ const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranc
switchViewLink={evaluateSwitchViewLink()} switchViewLink={evaluateSwitchViewLink()}
/> />
<Route path={`${url}/:page?`}> <Route path={`${url}/:page?`}>
<Changesets repository={repository} branch={branches?.filter(b => b.name === selectedBranch)[0]} url={url} /> <Changesets repository={repository} branch={branches?.filter((b) => b.name === selectedBranch)[0]} url={url} />
</Route> </Route>
</RepositoryRevisionContextProvider> </RepositoryRevisionContextProvider>
); );

View File

@@ -62,6 +62,11 @@ export const encodePart = (part: string) => {
return encodeURIComponent(part); return encodeURIComponent(part);
}; };
export const encodeFilePath = (filePath: string) => {
const encodedUri = encodePart(filePath);
return encodedUri.replace(/%2F/g, "/");
};
export const createRelativeLink = ( export const createRelativeLink = (
repositoryUrl: string, repositoryUrl: string,
contextPath: string, contextPath: string,

View File

@@ -24,7 +24,7 @@
import React, { FC, useEffect } from "react"; import React, { FC, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useParams } from "react-router-dom"; import { useHistory, useLocation, useParams } from "react-router-dom";
import { RepositoryRevisionContextProvider, useSources } from "@scm-manager/ui-api"; import { RepositoryRevisionContextProvider, urls, useSources } from "@scm-manager/ui-api";
import { Branch, Repository } from "@scm-manager/ui-types"; import { Branch, Repository } from "@scm-manager/ui-types";
import { Breadcrumb, ErrorNotification, Loading, Notification } from "@scm-manager/ui-components"; import { Breadcrumb, ErrorNotification, Loading, Notification } from "@scm-manager/ui-components";
import FileTree from "../components/FileTree"; import FileTree from "../components/FileTree";
@@ -52,7 +52,7 @@ const useUrlParams = () => {
const { revision, path } = useParams<Params>(); const { revision, path } = useParams<Params>();
return { return {
revision: revision ? decodeURIComponent(revision) : undefined, revision: revision ? decodeURIComponent(revision) : undefined,
path: path || "" path: path || "",
}; };
}; };
@@ -70,12 +70,18 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
); );
} }
}, [branches, selectedBranch, history, baseUrl]); }, [branches, selectedBranch, history, baseUrl]);
const { isLoading, error, data: file, isFetchingNextPage, fetchNextPage } = useSources(repository, { const {
isLoading,
error,
data: file,
isFetchingNextPage,
fetchNextPage,
} = useSources(repository, {
revision, revision,
path, path,
// we have to wait until a branch is selected, // we have to wait until a branch is selected,
// expect if we have no branches (svn) // expect if we have no branches (svn)
enabled: !branches || !!selectedBranch enabled: !branches || !!selectedBranch,
}); });
if (error) { if (error) {
@@ -106,9 +112,16 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
}; };
const evaluateSwitchViewLink = () => { const evaluateSwitchViewLink = () => {
if (branches && selectedBranch && branches?.filter(b => b.name === selectedBranch).length !== 0) { if (branches && selectedBranch && branches?.filter((b) => b.name === selectedBranch).length !== 0) {
return `${baseUrl}/branch/${encodeURIComponent(selectedBranch)}/changesets/`; return `${baseUrl}/branch/${encodeURIComponent(selectedBranch)}/changesets/?${urls.createPrevSourcePathQuery(
file.path
)}`;
} }
if (repository.type === "svn") {
return `${baseUrl}/changesets/?${urls.createPrevSourcePathQuery(`${file.revision}/${file.path}`)}`;
}
return `${baseUrl}/changesets/`; return `${baseUrl}/changesets/`;
}; };
@@ -117,7 +130,14 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
const buttons = []; const buttons = [];
if (repository._links.paths) { if (repository._links.paths) {
buttons.push(<FileSearchButton baseUrl={baseUrl} revision={revision || file.revision} />); buttons.push(
<FileSearchButton
baseUrl={baseUrl}
revision={revision || file.revision}
currentSource={file}
repository={repository}
/>
);
} }
return ( return (
@@ -127,8 +147,8 @@ const Sources: FC<Props> = ({ repository, branches, selectedBranch, baseUrl }) =
revision={revision || file.revision} revision={revision || file.revision}
path={path || ""} path={path || ""}
baseUrl={baseUrl + "/sources"} baseUrl={baseUrl + "/sources"}
branch={branches?.filter(b => b.name === selectedBranch)[0]} branch={branches?.filter((b) => b.name === selectedBranch)[0]}
defaultBranch={branches?.filter(b => b.defaultBranch === true)[0]} defaultBranch={branches?.filter((b) => b.defaultBranch === true)[0]}
sources={file} sources={file}
permalink={permalink} permalink={permalink}
/> />