From f776b333107383e9cec938a1fddaa2826acfa443 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Thu, 22 Oct 2026 11:33:06 +0300 Subject: [PATCH] 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 --- .../src/components/table/PackageTable.tsx | 25 ++++++--- frontend/src/contexts/RepositoryProvider.tsx | 20 +++---- frontend/src/hooks/useLocationQuery.ts | 52 +++++++++++++++++++ frontend/src/hooks/useTableState.ts | 11 +++- 4 files changed, 87 insertions(+), 21 deletions(-) create mode 100644 frontend/src/hooks/useLocationQuery.ts diff --git a/frontend/src/components/table/PackageTable.tsx b/frontend/src/components/table/PackageTable.tsx index 60bf20bb..ac0ad139 100644 --- a/frontend/src/components/table/PackageTable.tsx +++ b/frontend/src/components/table/PackageTable.tsx @@ -37,7 +37,9 @@ import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog"; import PackageTableToolbar from "components/table/PackageTableToolbar"; import StatusCell from "components/table/StatusCell"; import { useDebounce } from "hooks/useDebounce"; +import { navigate, packageHref } from "hooks/useLocationQuery"; import { usePackageTable } from "hooks/usePackageTable"; +import { useRepository } from "hooks/useRepository"; import type { PackageRow } from "models/PackageRow"; import React, { useMemo } from "react"; @@ -59,6 +61,7 @@ function createListColumn( export default function PackageTable(): React.JSX.Element { const table = usePackageTable(); + const { currentRepository } = useRepository(); const { handleRemove, handleUpdate, isAuthorized } = table; const apiRef = useGridApiRef(); const debouncedSearch = useDebounce(table.searchText, 300); @@ -79,11 +82,20 @@ export default function PackageTable(): React.JSX.Element { headerName: "package base", minWidth: 150, renderCell: params => - params.row.webUrl ? - - {params.value as string} - - : params.value as string, + { + const modified = event.ctrlKey || event.metaKey || event.shiftKey || event.altKey; + if (event.button === 0 && !modified) { + event.preventDefault(); + navigate(packageHref(currentRepository?.key ?? "", params.row.base)); + } + }} + tabIndex={params.tabIndex} + underline="hover" + > + {params.value as string} + , }, { align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 }, createListColumn("packages", "packages", { flex: 1, minWidth: 120 }), @@ -124,7 +136,7 @@ export default function PackageTable(): React.JSX.Element { ], } satisfies GridColDef] : [], ], - [handleRemove, handleUpdate, isAuthorized], + [currentRepository, handleRemove, handleUpdate, isAuthorized], ); return @@ -195,6 +207,7 @@ export default function PackageTable(): React.JSX.Element { table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} /> table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} /> table.setSelectedPackage(null)} open={table.selectedPackage !== null} packageBase={table.selectedPackage} diff --git a/frontend/src/contexts/RepositoryProvider.tsx b/frontend/src/contexts/RepositoryProvider.tsx index 97b49375..be9ed8c6 100644 --- a/frontend/src/contexts/RepositoryProvider.tsx +++ b/frontend/src/contexts/RepositoryProvider.tsx @@ -18,31 +18,23 @@ * along with this program. If not, see . */ import { RepositoryContext } from "contexts/RepositoryContext"; +import { navigate, packageHref, useLocationQuery } from "hooks/useLocationQuery"; import type { RepositoryId } from "models/RepositoryId"; -import React, { type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from "react"; - -function subscribeToHash(callback: () => void): () => void { - window.addEventListener("hashchange", callback); - return () => window.removeEventListener("hashchange", callback); -} - -function getHashSnapshot(): string { - return window.location.hash.replace("#", ""); -} +import React, { type ReactNode, useCallback, useMemo, useState } from "react"; export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element { const [repositories, setRepositories] = useState([]); - const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot); + const repositoryKey = new URLSearchParams(useLocationQuery()).get("repository_id"); const currentRepository = useMemo(() => { if (repositories.length === 0) { return null; } - return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null; - }, [repositories, hash]); + return repositories.find(repository => repository.key === repositoryKey) ?? repositories[0] ?? null; + }, [repositories, repositoryKey]); const setCurrentRepository = useCallback((repository: RepositoryId) => { - window.location.hash = repository.key; + navigate(packageHref(repository.key, null)); }, []); const value = useMemo(() => ({ diff --git a/frontend/src/hooks/useLocationQuery.ts b/frontend/src/hooks/useLocationQuery.ts new file mode 100644 index 00000000..bbf4cc5c --- /dev/null +++ b/frontend/src/hooks/useLocationQuery.ts @@ -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 . + */ +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")); +} diff --git a/frontend/src/hooks/useTableState.ts b/frontend/src/hooks/useTableState.ts index 46cc0bfa..16db76ad 100644 --- a/frontend/src/hooks/useTableState.ts +++ b/frontend/src/hooks/useTableState.ts @@ -19,6 +19,8 @@ */ import type { GridFilterModel } from "@mui/x-data-grid"; import { useLocalStorage } from "hooks/useLocalStorage"; +import { navigate, packageHref, useLocationQuery } from "hooks/useLocationQuery"; +import { useRepository } from "hooks/useRepository"; import { useState } from "react"; export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport"; @@ -43,7 +45,14 @@ export interface UseTableStateResult { export function useTableState(): UseTableStateResult { const [selectionModel, setSelectionModel] = useState([]); const [dialogOpen, setDialogOpen] = useState(null); - const [selectedPackage, setSelectedPackage] = useState(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 [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {