feat: preserve links to packages

This changes url handling in status page. Firstly, instead of using
hash for repository, now they are handled by query parameter. Secondly,
it adds a query parameter for package base. And lastly, it changes
behaviour for clicking on the package link, now it opens status page,
which allows to e.g. open a tab in background
This commit is contained in:
arcanis committed 2026-10-22 11:33:06 +03:00
1 parent eafa1b417a
commit f776b33310
4 files changed
+87 -21

No files matched your search

+19 -6
View File
@@ -37,7 +37,9 @@ import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
import PackageTableToolbar from "components/table/PackageTableToolbar"; import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell"; import StatusCell from "components/table/StatusCell";
import { useDebounce } from "hooks/useDebounce"; import { useDebounce } from "hooks/useDebounce";
import { navigate, packageHref } from "hooks/useLocationQuery";
import { usePackageTable } from "hooks/usePackageTable"; import { usePackageTable } from "hooks/usePackageTable";
import { useRepository } from "hooks/useRepository";
import type { PackageRow } from "models/PackageRow"; import type { PackageRow } from "models/PackageRow";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
@@ -59,6 +61,7 @@ function createListColumn(
export default function PackageTable(): React.JSX.Element { export default function PackageTable(): React.JSX.Element {
const table = usePackageTable(); const table = usePackageTable();
const { currentRepository } = useRepository();
const { handleRemove, handleUpdate, isAuthorized } = table; const { handleRemove, handleUpdate, isAuthorized } = table;
const apiRef = useGridApiRef(); const apiRef = useGridApiRef();
const debouncedSearch = useDebounce(table.searchText, 300); const debouncedSearch = useDebounce(table.searchText, 300);
@@ -79,11 +82,20 @@ export default function PackageTable(): React.JSX.Element {
headerName: "package base", headerName: "package base",
minWidth: 150, minWidth: 150,
renderCell: params => renderCell: params =>
params.row.webUrl ? <Link
<Link href={params.row.webUrl} rel="noopener noreferrer" target="_blank" underline="hover"> href={packageHref(currentRepository?.key ?? "", params.row.base)}
{params.value as string} onClick={event => {
</Link> const modified = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey;
: params.value as string, if (event.button === 0 && !modified) {
event.preventDefault();
navigate(packageHref(currentRepository?.key ?? "", params.row.base));
}
}}
tabIndex={params.tabIndex}
underline="hover"
>
{params.value as string}
</Link>,
}, },
{ align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 }, { align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 },
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }), createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
@@ -124,7 +136,7 @@ export default function PackageTable(): React.JSX.Element {
], ],
} satisfies GridColDef<PackageRow>] : [], } satisfies GridColDef<PackageRow>] : [],
], ],
[handleRemove, handleUpdate, isAuthorized], [currentRepository, handleRemove, handleUpdate, isAuthorized],
); );
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}> return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
@@ -195,6 +207,7 @@ export default function PackageTable(): React.JSX.Element {
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} /> <PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} /> <KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
<PackageInfoDialog <PackageInfoDialog
key={`${currentRepository?.key}/${table.selectedPackage}`}
onClose={() => table.setSelectedPackage(null)} onClose={() => table.setSelectedPackage(null)}
open={table.selectedPackage !== null} open={table.selectedPackage !== null}
packageBase={table.selectedPackage} packageBase={table.selectedPackage}
+6 -14
View File
@@ -18,31 +18,23 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { RepositoryContext } from "contexts/RepositoryContext"; import { RepositoryContext } from "contexts/RepositoryContext";
import { navigate, packageHref, useLocationQuery } from "hooks/useLocationQuery";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import React, { type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from "react"; import React, { type ReactNode, useCallback, useMemo, useState } from "react";
function subscribeToHash(callback: () => void): () => void {
window.addEventListener("hashchange", callback);
return () => window.removeEventListener("hashchange", callback);
}
function getHashSnapshot(): string {
return window.location.hash.replace("#", "");
}
export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element { export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [repositories, setRepositories] = useState<RepositoryId[]>([]); const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot); const repositoryKey = new URLSearchParams(useLocationQuery()).get("repository_id");
const currentRepository = useMemo(() => { const currentRepository = useMemo(() => {
if (repositories.length === 0) { if (repositories.length === 0) {
return null; return null;
} }
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null; return repositories.find(repository => repository.key === repositoryKey) ?? repositories[0] ?? null;
}, [repositories, hash]); }, [repositories, repositoryKey]);
const setCurrentRepository = useCallback((repository: RepositoryId) => { const setCurrentRepository = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key; navigate(packageHref(repository.key, null));
}, []); }, []);
const value = useMemo(() => ({ const value = useMemo(() => ({
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useSyncExternalStore } from "react";
function subscribe(callback: () => void): () => void {
window.addEventListener("popstate", callback);
return () => window.removeEventListener("popstate", callback);
}
function getSnapshot(): string {
return window.location.search;
}
export function useLocationQuery(): string {
return useSyncExternalStore(subscribe, getSnapshot);
}
export function packageHref(repositoryKey: string, packageBase: string | null): string {
const query = new URLSearchParams(window.location.search);
query.set("repository_id", repositoryKey);
if (packageBase) {
query.set("package", packageBase);
} else {
query.delete("package");
}
return `${window.location.pathname}?${query}`;
}
export function navigate(href: string): void {
if (href === `${window.location.pathname}${window.location.search}`) {
return;
}
window.history.pushState(null, "", href);
window.dispatchEvent(new PopStateEvent("popstate"));
}
+10 -1
View File
@@ -19,6 +19,8 @@
*/ */
import type { GridFilterModel } from "@mui/x-data-grid"; import type { GridFilterModel } from "@mui/x-data-grid";
import { useLocalStorage } from "hooks/useLocalStorage"; import { useLocalStorage } from "hooks/useLocalStorage";
import { navigate, packageHref, useLocationQuery } from "hooks/useLocationQuery";
import { useRepository } from "hooks/useRepository";
import { useState } from "react"; import { useState } from "react";
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport"; export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
@@ -43,7 +45,14 @@ export interface UseTableStateResult {
export function useTableState(): UseTableStateResult { export function useTableState(): UseTableStateResult {
const [selectionModel, setSelectionModel] = useState<string[]>([]); const [selectionModel, setSelectionModel] = useState<string[]>([]);
const [dialogOpen, setDialogOpen] = useState<DialogType | null>(null); const [dialogOpen, setDialogOpen] = useState<DialogType | null>(null);
const [selectedPackage, setSelectedPackage] = useState<string | null>(null); const { currentRepository } = useRepository();
const packageBase = new URLSearchParams(useLocationQuery()).get("package");
const selectedPackage = currentRepository && packageBase ? packageBase : null;
const setSelectedPackage = (base: string | null): void => {
if (currentRepository) {
navigate(packageHref(currentRepository.key, base));
}
};
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", { const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {