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", {