Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Implement data loading in router
Rūdolfs Ošiņš committed 3 years ago
commit b997b12353ff635e139ef61123bbfeea98be1f95
parent 85dc1c3552558b6bafd5df3dbe8750a3b6381cb7
27 files changed +715 -461
modified httpd-client/index.ts
@@ -61,7 +61,7 @@ const nodeSchema = strictObject({
  id: string(),
}) satisfies ZodSchema<Node>;

-
export interface Root {
+
export interface NodeInfo {
  message: string;
  service: string;
  version: string;
@@ -70,7 +70,7 @@ export interface Root {
  links: { href: string; rel: string; type: Method }[];
}

-
const rootSchema = strictObject({
+
const nodeInfoSchema = strictObject({
  message: string(),
  service: string(),
  version: string(),
@@ -88,7 +88,7 @@ const rootSchema = strictObject({
      ]),
    }),
  ),
-
}) satisfies ZodSchema<Root>;
+
}) satisfies ZodSchema<NodeInfo>;

export interface NodeStats {
  projects: { count: number };
@@ -115,14 +115,14 @@ export class HttpdClient {
    this.session = new session.Client(this.#fetcher);
  }

-
  public async getRoot(options?: RequestOptions): Promise<Root> {
+
  public async getNodeInfo(options?: RequestOptions): Promise<NodeInfo> {
    return this.#fetcher.fetchOk(
      {
        method: "GET",
        path: "",
        options,
      },
-
      rootSchema,
+
      nodeInfoSchema,
    );
  }

modified httpd-client/tests/client.test.ts
@@ -8,8 +8,8 @@ const api = new HttpdClient({
});

describe("client", () => {
-
  test("#getRoot()", async () => {
-
    await api.getRoot();
+
  test("#getNodeInfo()", async () => {
+
    await api.getNodeInfo();
  });

  test("#getStats()", async () => {
modified src/App.svelte
@@ -6,18 +6,22 @@
  import { unreachable } from "@app/lib/utils";

  import Header from "./App/Header.svelte";
-
  import ModalPortal from "./App/ModalPortal.svelte";
  import Hotkeys from "./App/Hotkeys.svelte";
+
  import LoadingBar from "./App/LoadingBar.svelte";
+
  import ModalPortal from "./App/ModalPortal.svelte";

-
  import NotFound from "@app/components/NotFound.svelte";
  import Home from "@app/views/home/Index.svelte";
-
  import Session from "@app/views/session/Index.svelte";
  import Projects from "@app/views/projects/View.svelte";
  import Seeds from "@app/views/seeds/View.svelte";
+
  import Session from "@app/views/session/Index.svelte";
+

+
  import LoadError from "@app/components/LoadError.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import NotFound from "@app/components/NotFound.svelte";

  const activeRouteStore = router.activeRouteStore;

-
  router.initialize();
+
  router.loadFromLocation();
  session.initialize();

  if (!window.VITEST && !window.PLAYWRIGHT && import.meta.env.PROD) {
@@ -51,6 +55,10 @@
  <title>Radicle</title>
</svelte:head>

+
{#if $activeRouteStore.resource !== "booting"}
+
  <LoadingBar />
+
{/if}
+

<ModalPortal />
<Hotkeys />

@@ -58,16 +66,22 @@
  <Header />
  <div class="wrapper">
    {#if $activeRouteStore.resource === "home"}
-
      <Home />
+
      <Home {...$activeRouteStore.params} />
    {:else if $activeRouteStore.resource === "seeds"}
-
      <Seeds hostnamePort={$activeRouteStore.params.hostnamePort} />
+
      <Seeds {...$activeRouteStore.params} />
    {:else if $activeRouteStore.resource === "session"}
      <Session activeRoute={$activeRouteStore} />
    {:else if $activeRouteStore.resource === "projects"}
      <Projects activeRoute={$activeRouteStore} />
-
    {:else if $activeRouteStore.resource === "404"}
+
    {:else if $activeRouteStore.resource === "booting"}
+
      <Loading />
+
    {:else if $activeRouteStore.resource === "loadError"}
+
      <LoadError {...$activeRouteStore.params} />
+
    {:else if $activeRouteStore.resource === "notFound"}
      <div class="layout-centered">
-
        <NotFound title="404" subtitle="Nothing here" />
+
        <NotFound
+
          title="Page not found"
+
          subtitle={`${$activeRouteStore.params.url.replace("/", "")}`} />
      </div>
    {:else}
      {unreachable($activeRouteStore)}
modified src/App/Header/Search.svelte
@@ -23,7 +23,7 @@
  let expanded = false;

  // Clears search input on user navigation.
-
  router.historyStore.subscribe(() => (input = ""));
+
  router.activeRouteStore.subscribe(() => (input = ""));

  function shake() {
    shaking = true;
modified src/App/Header/SearchResultsModal.svelte
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
-
  import type { ProjectAndSeed } from "@app/lib/search";
+
  import type { ProjectBaseUrl } from "@app/lib/search";

  import * as modal from "@app/lib/modal";
  import { formatRepositoryId } from "@app/lib/utils";
@@ -8,7 +8,7 @@
  import Modal from "@app/components/Modal.svelte";

  export let query: string;
-
  export let results: ProjectAndSeed[];
+
  export let results: ProjectBaseUrl[];
</script>

<style>
added src/App/LoadingBar.svelte
@@ -0,0 +1,26 @@
+
<script lang="ts">
+
  import { isLoading } from "@app/lib/router";
+
</script>
+

+
<style>
+
  .loading-bar {
+
    height: 0.125rem;
+
    background-color: var(--color-secondary);
+
    width: 0%;
+
    opacity: 0;
+
    position: fixed;
+
    z-index: 10;
+
    transition: width 2s ease;
+
  }
+

+
  .visible {
+
    opacity: 1;
+
    width: 100%;
+
  }
+
</style>
+

+
<div
+
  role="progressbar"
+
  aria-label="Page loading"
+
  class="loading-bar"
+
  class:visible={$isLoading} />
added src/components/LoadError.svelte
@@ -0,0 +1,36 @@
+
<script lang="ts">
+
  import { twemoji } from "@app/lib/utils";
+

+
  import Button from "@app/components/Button.svelte";
+
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+

+
  export let title: string | undefined = undefined;
+
  export let errorMessage: string;
+
  export let stackTrace: string;
+
</script>
+

+
<style>
+
  .wrapper {
+
    gap: 1rem;
+
  }
+

+
  .emoji {
+
    display: flex;
+
    font-size: var(--font-size-xx-large);
+
  }
+
</style>
+

+
<div class="wrapper layout-centered">
+
  <div class="emoji" use:twemoji>🏜️</div>
+
  {#if title}
+
    <div class="title txt-medium txt-bold txt-highlight">
+
      {title}
+
    </div>
+
  {/if}
+
  <ErrorMessage message={errorMessage} {stackTrace} />
+
  <div style:margin-top="1rem">
+
    <Button variant="foreground" on:click={() => window.history.back()}>
+
      Back
+
    </Button>
+
  </div>
+
</div>
modified src/components/Markdown.svelte
@@ -6,13 +6,14 @@
  import { toDom } from "hast-util-to-dom";

  import * as utils from "@app/lib/utils";
-
  import { base, updateProjectRoute, activeRouteStore } from "@app/lib/router";
-
  import { isUrl, twemoji, scrollIntoView, canonicalize } from "@app/lib/utils";
+
  import { base, activeRouteStore } from "@app/lib/router";
  import { highlight } from "@app/lib/syntax";
+
  import { isUrl, twemoji, scrollIntoView, canonicalize } from "@app/lib/utils";
  import {
    markdownExtensions as extensions,
    renderer,
  } from "@app/lib/markdown";
+
  import { updateProjectRoute } from "@app/views/projects/router";

  export let content: string;
  export let hash: string | null = null;
modified src/components/NotFound.svelte
@@ -1,5 +1,4 @@
<script lang="ts">
-
  import * as router from "@app/lib/router";
  import { twemoji } from "@app/lib/utils";

  import Button from "@app/components/Button.svelte";
@@ -36,6 +35,8 @@
  <div>{subtitle}</div>

  <div class="actions">
-
    <Button variant="foreground" on:click={router.pop}>Back</Button>
+
    <Button variant="foreground" on:click={() => window.history.back()}>
+
      Back
+
    </Button>
  </div>
</div>
modified src/components/ProjectLink.svelte
@@ -1,12 +1,12 @@
<script lang="ts" strictEvents>
-
  import type { ProjectsParams } from "@app/lib/router/definitions";
+
  import type { ProjectsParams } from "@app/views/projects/router";
  import { createEventDispatcher } from "svelte";

+
  import { useDefaultNavigation } from "@app/lib/router";
  import {
    projectLinkHref,
    updateProjectRoute,
-
    useDefaultNavigation,
-
  } from "@app/lib/router";
+
  } from "@app/views/projects/router";

  export let projectParams: Partial<ProjectsParams>;
  export let title: string | undefined = undefined;
modified src/lib/router.ts
@@ -1,20 +1,22 @@
-
import type { ProjectsParams, Route, ProjectRoute } from "./router/definitions";
-
import type { Readable } from "svelte/store";
+
import type { LoadedRoute, Route } from "@app/lib/router/definitions";

-
import { get, writable, derived } from "svelte/store";
-
import { unreachable } from "@app/lib/utils";
+
import { writable } from "svelte/store";

-
// This is only respected by Safari.
-
const documentTitle = "Radicle Interface";
+
import * as mutexExecutor from "@app/lib/mutexExecutor";
+
import { loadRoute } from "@app/lib/router/definitions";
+
import { unreachable } from "@app/lib/utils";
+
import {
+
  resolveProjectRoute,
+
  updateProjectRoute,
+
} from "@app/views/projects/router";

-
export const historyStore = writable<Route[]>([{ resource: "home" }]);
+
// Only used by Safari.
+
const DOCUMENT_TITLE = "Radicle Interface";

-
export const activeRouteStore: Readable<Route> = derived(
-
  historyStore,
-
  store => {
-
    return store.slice(-1)[0];
-
  },
-
);
+
export const isLoading = writable<boolean>(true);
+
export const activeRouteStore = writable<LoadedRoute>({
+
  resource: "booting",
+
});

export function useDefaultNavigation(event: MouseEvent) {
  return (
@@ -28,122 +30,71 @@ export function useDefaultNavigation(event: MouseEvent) {

export const base = import.meta.env.VITE_HASH_ROUTING ? "./" : "/";

-
// Gets triggered when clicking on an anchor hash tag e.g. <a href="#header"/>
-
// Allows the jump to a anchor hash
-
window.addEventListener("hashchange", e => {
-
  const route = pathToRoute(e.newURL);
-
  if (route?.resource === "projects" && route.params.hash) {
-
    if (route.params.hash.match(/^L\d+$/)) {
-
      updateProjectRoute({ line: route.params.hash });
-
    } else {
-
      updateProjectRoute({ hash: route.params.hash });
-
    }
-
  }
-
});
-

-
// Replaces history on any user interaction with forward and backwards buttons
-
// with the current window.history.state
-
window.addEventListener("popstate", e => {
-
  if (e.state) replace(e.state);
-
});
-

-
export function createProjectRoute(
-
  activeRoute: ProjectRoute,
-
  projectRouteParams: Partial<ProjectsParams>,
-
): ProjectRoute {
-
  return {
-
    resource: "projects",
-
    params: {
-
      ...activeRoute.params,
-
      line: undefined,
-
      hash: undefined,
-
      ...projectRouteParams,
-
    },
-
  };
-
}
-

-
export function projectLinkHref(
-
  projectRouteParams: Partial<ProjectsParams>,
-
): string | undefined {
-
  const activeRoute = get(activeRouteStore);
+
export async function loadFromLocation(): Promise<void> {
+
  const { pathname, search, hash } = window.location;
+
  const url = pathname + search + hash;
+
  const route = pathToRoute(url);

-
  if (activeRoute.resource === "projects") {
-
    return routeToPath(createProjectRoute(activeRoute, projectRouteParams));
+
  if (route) {
+
    await replace(route);
  } else {
-
    throw new Error(
-
      "Don't use project specific navigation outside of project views",
-
    );
+
    await replace({ resource: "notFound", params: { url } });
  }
}

-
function sanitizeQueryString(queryString: string): string {
-
  return queryString.startsWith("?") ? queryString.substring(1) : queryString;
-
}
+
window.addEventListener("popstate", () => loadFromLocation());

-
export function updateProjectRoute(
-
  projectRouteParams: Partial<ProjectsParams>,
-
  opts: { replace: boolean } = { replace: false },
-
) {
-
  const activeRoute = get(activeRouteStore);
-

-
  if (activeRoute.resource === "projects") {
-
    const updatedRoute = createProjectRoute(activeRoute, projectRouteParams);
-
    if (opts.replace) {
-
      replace(updatedRoute);
+
// Gets triggered when clicking on an anchor hash tag e.g. <a href="#header"/>
+
// Allows the jump to a anchor hash.
+
window.addEventListener("hashchange", async (event: HashChangeEvent) => {
+
  const route = pathToRoute(event.newURL);
+
  if (route?.resource === "projects" && route.params.hash) {
+
    if (route.params.hash.match(/^L\d+$/)) {
+
      await updateProjectRoute({ line: route.params.hash });
    } else {
-
      push(updatedRoute);
+
      await updateProjectRoute({ hash: route.params.hash });
    }
-
  } else {
-
    throw new Error(
-
      "Don't use project specific navigation outside of project views",
-
    );
  }
-
}
+
});

-
export const push = (newRoute: Route): void => {
-
  const history = get(historyStore);
+
const loadExecutor = mutexExecutor.create();

-
  // Limit history to a maximum of 10 steps. We shouldn't be doing more than
-
  // one subsequent pop() anyway.
-
  historyStore.set([...history, newRoute].slice(-10));
+
async function navigate(
+
  action: "push" | "replace",
+
  newRoute: Route,
+
): Promise<void> {
+
  isLoading.set(true);

-
  const path = import.meta.env.VITE_HASH_ROUTING
-
    ? "#" + routeToPath(newRoute)
-
    : routeToPath(newRoute);
+
  const loadedRoute = await loadExecutor.run(async () => {
+
    return loadRoute(newRoute);
+
  });

-
  window.history.pushState(newRoute, documentTitle, path);
-
};
-

-
export const pop = (): void => {
-
  const history = get(historyStore);
-
  const newRoute = history.pop();
-
  if (newRoute) {
-
    historyStore.set(history);
-
    window.history.back();
+
  // Only let the last request through.
+
  if (loadedRoute === undefined) {
+
    return;
  }
-
};

-
export function replace(newRoute: Route): void {
-
  historyStore.set([newRoute]);
+
  activeRouteStore.set(loadedRoute);
+
  isLoading.set(false);

  const path = import.meta.env.VITE_HASH_ROUTING
    ? "#" + routeToPath(newRoute)
    : routeToPath(newRoute);

-
  window.history.replaceState(newRoute, documentTitle, path);
+
  if (action === "push") {
+
    window.history.pushState(newRoute, DOCUMENT_TITLE, path);
+
  } else if (action === "replace") {
+
    window.history.replaceState(newRoute, DOCUMENT_TITLE, path);
+
  }
}

-
export const initialize = () => {
-
  const { pathname, search, hash } = window.location;
-
  const url = pathname + search + hash;
-
  const route = pathToRoute(url);
+
export async function push(newRoute: Route): Promise<void> {
+
  await navigate("push", newRoute);
+
}

-
  if (route) {
-
    replace(route);
-
  } else {
-
    replace({ resource: "404", params: { url } });
-
  }
-
};
+
export async function replace(newRoute: Route): Promise<void> {
+
  await navigate("replace", newRoute);
+
}

function pathToRoute(path: string): Route | null {
  // This matches e.g. an empty string
@@ -191,7 +142,10 @@ function pathToRoute(path: string): Route | null {
          }
          return null;
        }
-
        return { resource: "seeds", params: { hostnamePort } };
+
        return {
+
          resource: "seeds",
+
          params: { hostnamePort, projectPageIndex: 0 },
+
        };
      }
      return null;
    }
@@ -225,6 +179,8 @@ export function routeToPath(route: Route) {
    return `/session?id=${route.params.id}&sig=${route.params.signature}&pk=${route.params.publicKey}`;
  } else if (route.resource === "seeds") {
    return `/seeds/${route.params.hostnamePort}`;
+
  } else if (route.resource === "loadError") {
+
    return "";
  } else if (route.resource === "projects") {
    const hostnamePortPrefix = `/seeds/${route.params.hostnamePort}`;
    const content = `/${route.params.view.resource}`;
@@ -288,123 +244,13 @@ export function routeToPath(route: Route) {
    } else {
      return `${hostnamePortPrefix}/${route.params.id}${peer}${content}`;
    }
-
  } else if (route.resource === "404") {
+
  } else if (route.resource === "booting") {
+
    return "";
+
  } else if (route.resource === "notFound") {
    return route.params.url;
  } else {
    unreachable(route);
  }
}

-
function resolveProjectRoute(
-
  url: URL,
-
  hostnamePort: string,
-
  id: string,
-
  segments: string[],
-
): ProjectsParams | null {
-
  let content = segments.shift();
-
  let peer;
-
  if (content === "remotes") {
-
    peer = segments.shift();
-
    content = segments.shift();
-
  }
-

-
  if (!content || content === "tree") {
-
    const line = url.href.match(/#L\d+$/)?.pop();
-
    const hash = url.href.match(/#{1}[^#.]+$/)?.pop();
-
    return {
-
      view: { resource: "tree" },
-
      id,
-
      hostnamePort,
-
      peer,
-
      path: undefined,
-
      revision: undefined,
-
      search: undefined,
-
      line: line?.substring(1),
-
      hash: hash?.substring(1),
-
      route: segments.join("/"),
-
    };
-
  } else if (content === "history") {
-
    return {
-
      view: { resource: "history" },
-
      id,
-
      hostnamePort,
-
      peer,
-
      path: undefined,
-
      revision: undefined,
-
      search: undefined,
-
      route: segments.join("/"),
-
    };
-
  } else if (content === "commits") {
-
    return {
-
      view: { resource: "commits" },
-
      id,
-
      hostnamePort,
-
      peer,
-
      path: undefined,
-
      revision: undefined,
-
      search: undefined,
-
      route: segments.join("/"),
-
    };
-
  } else if (content === "issues") {
-
    const issueOrAction = segments.shift();
-
    if (issueOrAction === "new") {
-
      return {
-
        view: { resource: "issues", params: { view: { resource: "new" } } },
-
        id,
-
        hostnamePort,
-
        peer,
-
        search: sanitizeQueryString(url.search),
-
        path: undefined,
-
        revision: undefined,
-
      };
-
    } else if (issueOrAction) {
-
      return {
-
        view: { resource: "issue", params: { issue: issueOrAction } },
-
        id,
-
        hostnamePort,
-
        peer,
-
        path: undefined,
-
        revision: undefined,
-
        search: undefined,
-
      };
-
    } else {
-
      return {
-
        view: { resource: "issues" },
-
        id,
-
        hostnamePort,
-
        peer,
-
        search: sanitizeQueryString(url.search),
-
        path: undefined,
-
        revision: undefined,
-
      };
-
    }
-
  } else if (content === "patches") {
-
    const patch = segments.shift();
-
    const revision = segments.shift();
-
    if (patch) {
-
      return {
-
        view: { resource: "patch", params: { patch, revision } },
-
        id,
-
        hostnamePort,
-
        peer,
-
        path: undefined,
-
        revision: undefined,
-
        search: sanitizeQueryString(url.search),
-
      };
-
    } else {
-
      return {
-
        view: { resource: "patches" },
-
        id,
-
        hostnamePort,
-
        peer,
-
        search: sanitizeQueryString(url.search),
-
        path: undefined,
-
        revision: undefined,
-
      };
-
    }
-
  }
-

-
  return null;
-
}
-

export const testExports = { pathToRoute };
modified src/lib/router/definitions.ts
@@ -1,41 +1,57 @@
+
import type { HomeRoute, HomeLoadedRoute } from "@app/views/home/router";
+
import type { ProjectRoute } from "@app/views/projects/router";
+
import type { SeedsLoadedRoute, SeedsRoute } from "@app/views/seeds/router";
+

+
import { loadHomeRoute } from "@app/views/home/router";
+
import { loadSeedRoute } from "@app/views/seeds/router";
+

+
interface BootingRoute {
+
  resource: "booting";
+
}
+

+
interface NotFoundRoute {
+
  resource: "notFound";
+
  params: { url: string };
+
}
+

+
interface SessionRoute {
+
  resource: "session";
+
  params: { id: string; signature: string; publicKey: string };
+
}
+

+
export interface LoadError {
+
  resource: "loadError";
+
  params: {
+
    title?: string;
+
    errorMessage: string;
+
    stackTrace: string;
+
  };
+
}
+

export type Route =
+
  | BootingRoute
+
  | HomeRoute
+
  | LoadError
+
  | NotFoundRoute
  | ProjectRoute
-
  | { resource: "home" }
-
  | {
-
      resource: "session";
-
      params: { id: string; signature: string; publicKey: string };
-
    }
-
  | { resource: "404"; params: { url: string } }
-
  | { resource: "seeds"; params: { hostnamePort: string } };
-

-
export interface ProjectsParams {
-
  id: string;
-
  view:
-
    | { resource: "tree" }
-
    | { resource: "commits" }
-
    | { resource: "history" }
-
    | { resource: "issue"; params: { issue: string } }
-
    | {
-
        resource: "issues";
-
        params?: {
-
          view: { resource: "new" };
-
        };
-
      }
-
    | {
-
        resource: "patches";
-
        params?: {
-
          view: { resource: "new" };
-
        };
-
      }
-
    | { resource: "patch"; params: { patch: string; revision?: string } };
-
  hostnamePort: string;
-
  hash?: string;
-
  line?: string;
-
  path?: string;
-
  peer?: string;
-
  revision?: string;
-
  route?: string;
-
  search?: string;
-
}
+
  | SeedsRoute
+
  | SessionRoute;

-
export type ProjectRoute = { resource: "projects"; params: ProjectsParams };
+
export type LoadedRoute =
+
  | BootingRoute
+
  | HomeLoadedRoute
+
  | LoadError
+
  | NotFoundRoute
+
  | ProjectRoute
+
  | SeedsLoadedRoute
+
  | SessionRoute;
+

+
export async function loadRoute(route: Route): Promise<LoadedRoute> {
+
  if (route.resource === "seeds") {
+
    return await loadSeedRoute(route.params);
+
  } else if (route.resource === "home") {
+
    return await loadHomeRoute();
+
  } else {
+
    return route;
+
  }
+
}
modified src/lib/search.ts
@@ -5,7 +5,7 @@ import { HttpdClient } from "@httpd-client";
import { config } from "@app/lib/config";
import { isFulfilled } from "@app/lib/utils";

-
export interface ProjectAndSeed {
+
export interface ProjectBaseUrl {
  project: Project;
  baseUrl: BaseUrl;
}
@@ -13,7 +13,7 @@ export interface ProjectAndSeed {
type SearchResult =
  | { type: "nothing" }
  | { type: "error"; message: string }
-
  | { type: "projects"; results: ProjectAndSeed[] };
+
  | { type: "projects"; results: ProjectBaseUrl[] };

export async function searchProjectsAndProfiles(
  query: string,
@@ -51,7 +51,7 @@ export async function searchProjectsAndProfiles(

export async function getProjectsFromSeeds(
  params: { id: string; baseUrl: BaseUrl }[],
-
): Promise<ProjectAndSeed[]> {
+
): Promise<ProjectBaseUrl[]> {
  const projectPromises = params.map(async param => {
    const api = new HttpdClient(param.baseUrl);
    const project = await api.project.getById(param.id);
modified src/views/home/Index.svelte
@@ -1,17 +1,16 @@
<script lang="ts">
-
  import { config } from "@app/lib/config";
-
  import { getProjectsFromSeeds } from "@app/lib/search";
-
  import { loadProjectActivity } from "@app/lib/commit";
+
  import type { ProjectBaseUrlActivity } from "./router";
+

  import { twemoji } from "@app/lib/utils";

  import Link from "@app/components/Link.svelte";
-
  import Loading from "@app/components/Loading.svelte";
-
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";
+

+
  export let projects: ProjectBaseUrlActivity[];
</script>

<style>
-
  main {
+
  .wrapper {
    padding: 3rem 3rem;
    width: 100%;
    max-width: 74rem;
@@ -41,9 +40,6 @@
    font-size: var(--font-size-medium);
    margin-bottom: 1rem;
  }
-
  .loading {
-
    padding-top: 2rem;
-
  }
  @media (max-width: 720px) {
    .blurb {
      max-width: none;
@@ -59,7 +55,7 @@
  <title>Radicle &ndash; Home</title>
</svelte:head>

-
<main>
+
<div class="wrapper">
  <div class="blurb">
    <p use:twemoji>
      Radicle 🌱 enables developers 🧙 to securely collaborate 🔐 on software
@@ -67,48 +63,36 @@
    </p>
  </div>

-
  {#await getProjectsFromSeeds(config.projects.pinned)}
-
    <div class="loading">
-
      <Loading center />
+
  {#if projects.length > 0}
+
    <div class="heading">
+
      Explore <span class="txt-bold">projects</span>
+
      on the Radicle network.
    </div>
-
  {:then results}
-
    {#if results.length}
-
      <div class="heading">
-
        Explore <span class="txt-bold">projects</span>
-
        on the Radicle network.
-
      </div>

-
      <div class="projects">
-
        {#each results as result}
-
          {#await loadProjectActivity(result.project.id, result.baseUrl) then activity}
-
            <div class="project">
-
              <Link
-
                route={{
-
                  resource: "projects",
-
                  params: {
-
                    view: { resource: "tree" },
-
                    id: result.project.id,
-
                    hostnamePort: result.baseUrl.hostname,
-
                    peer: undefined,
-
                    revision: undefined,
-
                  },
-
                }}>
-
                <ProjectCard
-
                  compact
-
                  description={result.project.description}
-
                  head={result.project.head}
-
                  id={result.project.id}
-
                  name={result.project.name}
-
                  {activity} />
-
              </Link>
-
            </div>
-
          {/await}
-
        {/each}
-
      </div>
-
    {/if}
-
  {:catch error}
-
    <div class="padding">
-
      <ErrorMessage message="Couldn't load projects." stackTrace={error} />
+
    <div class="projects">
+
      {#each projects as { project, baseUrl, activity }}
+
        <div class="project">
+
          <Link
+
            route={{
+
              resource: "projects",
+
              params: {
+
                view: { resource: "tree" },
+
                id: project.id,
+
                hostnamePort: baseUrl.hostname,
+
                peer: undefined,
+
                revision: undefined,
+
              },
+
            }}>
+
            <ProjectCard
+
              compact
+
              description={project.description}
+
              head={project.head}
+
              id={project.id}
+
              name={project.name}
+
              {activity} />
+
          </Link>
+
        </div>
+
      {/each}
    </div>
-
  {/await}
-
</main>
+
  {/if}
+
</div>
added src/views/home/router.ts
@@ -0,0 +1,48 @@
+
import type { LoadError } from "@app/lib/router/definitions";
+
import type { ProjectBaseUrl } from "@app/lib/search";
+
import type { WeeklyActivity } from "@app/lib/commit";
+

+
import { config } from "@app/lib/config";
+
import { getProjectsFromSeeds } from "@app/lib/search";
+
import { loadProjectActivity } from "@app/lib/commit";
+

+
export interface ProjectBaseUrlActivity extends ProjectBaseUrl {
+
  activity: WeeklyActivity[];
+
}
+

+
export interface HomeRoute {
+
  resource: "home";
+
}
+

+
export interface HomeLoadedRoute {
+
  resource: "home";
+
  params: { projects: ProjectBaseUrlActivity[] };
+
}
+

+
export async function loadHomeRoute(): Promise<HomeLoadedRoute | LoadError> {
+
  try {
+
    const projects = await getProjectsFromSeeds(config.projects.pinned);
+
    const results = await Promise.all(
+
      projects.map(async projectSeed => {
+
        const activity = await loadProjectActivity(
+
          projectSeed.project.id,
+
          projectSeed.baseUrl,
+
        );
+
        return {
+
          ...projectSeed,
+
          activity,
+
        };
+
      }),
+
    );
+

+
    return { resource: "home", params: { projects: results } };
+
  } catch (error: any) {
+
    return {
+
      resource: "loadError",
+
      params: {
+
        errorMessage: "Could not load pinned projects.",
+
        stackTrace: error.stack,
+
      },
+
    };
+
  }
+
}
modified src/views/projects/Blob.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
  import type { Blob } from "@httpd-client";
  import type { MaybeHighlighted } from "@app/lib/syntax";
-
  import type { ProjectRoute } from "@app/lib/router/definitions";
+
  import type { ProjectRoute } from "@app/views/projects/router";

  import { afterUpdate, beforeUpdate, onMount } from "svelte";
  import { toHtml } from "hast-util-to-html";
@@ -9,7 +9,7 @@
  import { highlight } from "@app/lib/syntax";
  import { isMarkdownPath, scrollIntoView, twemoji } from "@app/lib/utils";
  import { lineNumbersGutter } from "@app/lib/syntax";
-
  import { updateProjectRoute } from "@app/lib/router";
+
  import { updateProjectRoute } from "@app/views/projects/router";

  import Readme from "@app/views/projects/Readme.svelte";
  import SquareButton from "@app/components/SquareButton.svelte";
modified src/views/projects/Browser.svelte
@@ -8,7 +8,7 @@

<script lang="ts">
  import type { BaseUrl, Blob, Project, Tree } from "@httpd-client";
-
  import type { ProjectRoute } from "@app/lib/router/definitions";
+
  import type { ProjectRoute } from "@app/views/projects/router";

  import { onMount } from "svelte";

modified src/views/projects/Header.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
  import type { BaseUrl, Project, Remote, Tree } from "@httpd-client";
-
  import type { ProjectRoute } from "@app/lib/router/definitions";
+
  import type { ProjectRoute } from "@app/views/projects/router";

  import { closeFocused } from "@app/components/Floating.svelte";
  import { config } from "@app/lib/config";
@@ -71,6 +71,7 @@
          baseUrl.port === config.seeds.defaultHttpdPort
            ? baseUrl.hostname
            : `${baseUrl.hostname}:${baseUrl.port}`,
+
        projectPageIndex: 0,
      },
    }}>
    <SquareButton>
modified src/views/projects/Patch.svelte
@@ -42,10 +42,10 @@
  import type { Variant } from "@app/components/Badge.svelte";

  import * as utils from "@app/lib/utils";
-
  import * as router from "@app/lib/router";
  import { capitalize } from "lodash";
  import { HttpdClient } from "@httpd-client";
  import { sessionStore } from "@app/lib/session";
+
  import { updateProjectRoute } from "@app/views/projects/router";

  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
@@ -308,7 +308,7 @@
              })}
              selected={currentRevision.id}
              on:select={({ detail: item }) => {
-
                router.updateProjectRoute({
+
                updateProjectRoute({
                  view: {
                    resource: "patch",
                    params: { patch: patch.id, revision: item.value },
modified src/views/projects/Readme.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { ProjectRoute } from "@app/lib/router/definitions";
+
  import type { ProjectRoute } from "@app/views/projects/router";

  import Markdown from "@app/components/Markdown.svelte";

modified src/views/projects/View.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
  import type { IssueStatus } from "./Issues.svelte";
  import type { PatchStatus } from "./Patches.svelte";
-
  import type { ProjectRoute } from "@app/lib/router/definitions";
+
  import type { ProjectRoute } from "@app/views/projects/router";
  import type { Tree } from "@httpd-client";

  import * as router from "@app/lib/router";
@@ -9,6 +9,7 @@
  import { HttpdClient } from "@httpd-client";
  import { formatNodeId, unreachable } from "@app/lib/utils";
  import { sessionStore } from "@app/lib/session";
+
  import { updateProjectRoute } from "@app/views/projects/router";

  import Loading from "@app/components/Loading.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
@@ -84,7 +85,7 @@

    if (activeRoute.params.route) {
      const { revision, path } = parseRoute(activeRoute.params.route, branches);
-
      router.updateProjectRoute(
+
      updateProjectRoute(
        {
          revision,
          path,
@@ -298,6 +299,6 @@
  </main>
{:catch}
  <div class="layout-centered">
-
    <NotFound subtitle={id} title="This project was not found" />
+
    <NotFound subtitle={id} title="Project not found" />
  </div>
{/await}
added src/views/projects/router.ts
@@ -0,0 +1,203 @@
+
import { get } from "svelte/store";
+

+
import { activeRouteStore, push, replace, routeToPath } from "@app/lib/router";
+

+
export interface ProjectRoute {
+
  resource: "projects";
+
  params: ProjectsParams;
+
}
+

+
export interface ProjectsParams {
+
  id: string;
+
  hash?: string;
+
  hostnamePort: string;
+
  line?: string;
+
  path?: string;
+
  peer?: string;
+
  revision?: string;
+
  route?: string;
+
  search?: string;
+
  view:
+
    | { resource: "tree" }
+
    | { resource: "commits" }
+
    | { resource: "history" }
+
    | { resource: "issue"; params: { issue: string } }
+
    | {
+
        resource: "issues";
+
        params?: {
+
          view: { resource: "new" };
+
        };
+
      }
+
    | {
+
        resource: "patches";
+
        params?: {
+
          view: { resource: "new" };
+
        };
+
      }
+
    | { resource: "patch"; params: { patch: string; revision?: string } };
+
}
+

+
function sanitizeQueryString(queryString: string): string {
+
  return queryString.startsWith("?") ? queryString.substring(1) : queryString;
+
}
+

+
export function createProjectRoute(
+
  activeRoute: ProjectRoute,
+
  projectRouteParams: Partial<ProjectsParams>,
+
): ProjectRoute {
+
  return {
+
    resource: "projects",
+
    params: {
+
      ...activeRoute.params,
+
      line: undefined,
+
      hash: undefined,
+
      ...projectRouteParams,
+
    },
+
  };
+
}
+

+
export function projectLinkHref(
+
  projectRouteParams: Partial<ProjectsParams>,
+
): string | undefined {
+
  const activeRoute = get(activeRouteStore);
+

+
  if (activeRoute.resource === "projects") {
+
    return routeToPath(createProjectRoute(activeRoute, projectRouteParams));
+
  } else {
+
    throw new Error(
+
      "Don't use project specific navigation outside of project views",
+
    );
+
  }
+
}
+

+
export async function updateProjectRoute(
+
  projectRouteParams: Partial<ProjectsParams>,
+
  opts: { replace: boolean } = { replace: false },
+
): Promise<void> {
+
  const activeRoute = get(activeRouteStore);
+

+
  if (activeRoute.resource === "projects") {
+
    const updatedRoute = createProjectRoute(activeRoute, projectRouteParams);
+
    if (opts.replace) {
+
      await replace(updatedRoute);
+
    } else {
+
      await push(updatedRoute);
+
    }
+
  } else {
+
    throw new Error(
+
      "Don't use project specific navigation outside of project views",
+
    );
+
  }
+
}
+

+
export function resolveProjectRoute(
+
  url: URL,
+
  hostnamePort: string,
+
  id: string,
+
  segments: string[],
+
): ProjectsParams | null {
+
  let content = segments.shift();
+
  let peer;
+
  if (content === "remotes") {
+
    peer = segments.shift();
+
    content = segments.shift();
+
  }
+

+
  if (!content || content === "tree") {
+
    const line = url.href.match(/#L\d+$/)?.pop();
+
    const hash = url.href.match(/#{1}[^#.]+$/)?.pop();
+
    return {
+
      view: { resource: "tree" },
+
      id,
+
      hostnamePort,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      search: undefined,
+
      line: line?.substring(1),
+
      hash: hash?.substring(1),
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "history") {
+
    return {
+
      view: { resource: "history" },
+
      id,
+
      hostnamePort,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      search: undefined,
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "commits") {
+
    return {
+
      view: { resource: "commits" },
+
      id,
+
      hostnamePort,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      search: undefined,
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "issues") {
+
    const issueOrAction = segments.shift();
+
    if (issueOrAction === "new") {
+
      return {
+
        view: { resource: "issues", params: { view: { resource: "new" } } },
+
        id,
+
        hostnamePort,
+
        peer,
+
        search: sanitizeQueryString(url.search),
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    } else if (issueOrAction) {
+
      return {
+
        view: { resource: "issue", params: { issue: issueOrAction } },
+
        id,
+
        hostnamePort,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
        search: undefined,
+
      };
+
    } else {
+
      return {
+
        view: { resource: "issues" },
+
        id,
+
        hostnamePort,
+
        peer,
+
        search: sanitizeQueryString(url.search),
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    }
+
  } else if (content === "patches") {
+
    const patch = segments.shift();
+
    const revision = segments.shift();
+
    if (patch) {
+
      return {
+
        view: { resource: "patch", params: { patch, revision } },
+
        id,
+
        hostnamePort,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
        search: sanitizeQueryString(url.search),
+
      };
+
    } else {
+
      return {
+
        view: { resource: "patches" },
+
        id,
+
        hostnamePort,
+
        peer,
+
        search: sanitizeQueryString(url.search),
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    }
+
  }
+

+
  return null;
+
}
modified src/views/seeds/View.svelte
@@ -1,11 +1,10 @@
<script lang="ts">
-
  import type { Project, NodeStats } from "@httpd-client";
-
  import type { WeeklyActivity } from "@app/lib/commit";
+
  import type { BaseUrl } from "@httpd-client";
+
  import type { ProjectActivity } from "@app/views/seeds/router";

-
  import { HttpdClient } from "@httpd-client";
  import { config } from "@app/lib/config";
-
  import { extractBaseUrl, isLocal, truncateId } from "@app/lib/utils";
-
  import { loadProjectActivity } from "@app/lib/commit";
+
  import { isLocal, truncateId } from "@app/lib/utils";
+
  import { loadProjects } from "@app/views/seeds/router";

  import Button from "@app/components/Button.svelte";
  import Clipboard from "@app/components/Clipboard.svelte";
@@ -14,45 +13,29 @@
  import Loading from "@app/components/Loading.svelte";
  import ProjectCard from "@app/components/ProjectCard.svelte";

-
  export let hostnamePort: string;
+
  export let baseUrl: BaseUrl;
+
  export let nid: string;
+
  export let projectCount: number;
+
  export let projectPageIndex: number;
+
  export let projects: ProjectActivity[] = [];
+
  export let version: string;

-
  const baseUrl = extractBaseUrl(hostnamePort);
-
  const hostName = isLocal(baseUrl.hostname)
+
  const hostname = isLocal(baseUrl.hostname)
    ? "radicle.local"
    : baseUrl.hostname;
-
  const api = new HttpdClient(baseUrl);

-
  const perPage = 10;
-
  let page = 0;
  let error: any;
  let loadingProjects = false;

-
  let projects: Project[] = [];
-
  let nodeStats: NodeStats | undefined = undefined;
-
  let projectsWithActivity: { project: Project; activity: WeeklyActivity[] }[] =
-
    [];
-

-
  async function loadProjects(): Promise<void> {
+
  async function loadMore(): Promise<void> {
    loadingProjects = true;
    try {
-
      [nodeStats, projects] = await Promise.all([
-
        api.getStats(),
-
        api.project.getAll({ page, perPage }),
-
      ]);
-

-
      const results = await Promise.all(
-
        projects.map(async project => {
-
          const activity = await loadProjectActivity(project.id, baseUrl);
-
          return {
-
            project,
-
            activity,
-
          };
-
        }),
-
      );
-
      projectsWithActivity = [...projectsWithActivity, ...results];
-
      page += 1;
-
    } catch (e) {
-
      error = e;
+
      const result = await loadProjects(projectPageIndex, baseUrl);
+
      projectCount = result.total;
+
      projects = [...projects, ...result.projects];
+
      projectPageIndex += 1;
+
    } catch (err) {
+
      error = err;
    } finally {
      loadingProjects = false;
    }
@@ -61,10 +44,8 @@
  $: showMoreButton =
    !loadingProjects &&
    !error &&
-
    nodeStats &&
-
    projectsWithActivity.length < nodeStats.projects.count;
-

-
  loadProjects();
+
    projectCount &&
+
    projects.length < projectCount;
</script>

<style>
@@ -112,88 +93,76 @@
</style>

<svelte:head>
-
  <title>{hostName}</title>
+
  <title>{hostname}</title>
</svelte:head>

<div class="wrapper">
  <div class="header">
-
    {hostName}
+
    {hostname}
  </div>

-
  {#await api.getRoot()}
-
    <Loading center />
-
  {:then nodeInfo}
-
    <table>
-
      <tr>
-
        <td class="txt-highlight">Address</td>
-
        <td>
-
          <div class="seed-address">
-
            {truncateId(nodeInfo.node.id)}@{baseUrl.hostname}
-
            <Clipboard
-
              small
-
              text={`${nodeInfo.node.id}@${baseUrl.hostname}:${config.seeds.defaultNodePort}`} />
-
          </div>
-
        </td>
-
      </tr>
-
      <tr>
-
        <td class="txt-highlight">Version</td>
-
        <td>
-
          {nodeInfo.version}
-
        </td>
-
      </tr>
-
    </table>
-
  {:catch error}
-
    <div style:margin-bottom="2rem">
-
      <ErrorMessage
-
        message="Not able to query information from this seed."
-
        stackTrace={error.stack} />
-
    </div>
-
  {/await}
+
  <table>
+
    <tr>
+
      <td class="txt-highlight">Address</td>
+
      <td>
+
        <div class="seed-address">
+
          {truncateId(nid)}@{baseUrl.hostname}
+
          <Clipboard
+
            small
+
            text={`${nid}@${baseUrl.hostname}:${config.seeds.defaultNodePort}`} />
+
        </div>
+
      </td>
+
    </tr>
+
    <tr>
+
      <td class="txt-highlight">Version</td>
+
      <td>
+
        {version}
+
      </td>
+
    </tr>
+
  </table>

  <div style:margin-bottom="5rem">
-
    {#if projects}
-
      <div style:margin-top="1rem">
-
        {#each projectsWithActivity as { project, activity }}
-
          <div style:margin-bottom="0.5rem">
-
            <Link
-
              route={{
-
                resource: "projects",
-
                params: {
-
                  view: { resource: "tree" },
-
                  id: project.id,
-
                  hostnamePort:
-
                    baseUrl.port === config.seeds.defaultHttpdPort
-
                      ? baseUrl.hostname
-
                      : `${baseUrl.hostname}:${baseUrl.port}`,
-
                  revision: undefined,
-
                  hash: undefined,
-
                  search: undefined,
-
                },
-
              }}>
-
              <ProjectCard
-
                {activity}
-
                id={project.id}
-
                name={project.name}
-
                description={project.description}
-
                head={project.head} />
-
            </Link>
-
          </div>
-
        {/each}
-
      </div>
-
      {#if loadingProjects}
-
        <div class="more">
-
          <Loading small />
-
        </div>
-
      {/if}
-
      {#if showMoreButton}
-
        <div class="more">
-
          <Button variant="foreground" on:click={loadProjects}>More</Button>
+
    <div style:margin-top="1rem">
+
      {#each projects as { project, activity }}
+
        <div style:margin-bottom="0.5rem">
+
          <Link
+
            route={{
+
              resource: "projects",
+
              params: {
+
                view: { resource: "tree" },
+
                id: project.id,
+
                hostnamePort:
+
                  baseUrl.port === config.seeds.defaultHttpdPort
+
                    ? baseUrl.hostname
+
                    : `${baseUrl.hostname}:${baseUrl.port}`,
+
                revision: undefined,
+
                hash: undefined,
+
                search: undefined,
+
              },
+
            }}>
+
            <ProjectCard
+
              {activity}
+
              id={project.id}
+
              name={project.name}
+
              description={project.description}
+
              head={project.head} />
+
          </Link>
        </div>
-
      {/if}
+
      {/each}
+
    </div>
+
    {#if loadingProjects}
+
      <div class="more">
+
        <Loading small />
+
      </div>
+
    {/if}
+
    {#if showMoreButton}
+
      <div class="more">
+
        <Button variant="foreground" on:click={loadMore}>More</Button>
+
      </div>
    {/if}
    {#if error}
      <ErrorMessage
-
        message="Not able to load projects from this seed."
+
        message="Not able to load more projects from this seed."
        stackTrace={error.stack} />
    {/if}
  </div>
added src/views/seeds/router.ts
@@ -0,0 +1,100 @@
+
import type { BaseUrl, Project } from "@httpd-client";
+
import type { LoadError } from "@app/lib/router/definitions";
+
import type { WeeklyActivity } from "@app/lib/commit";
+

+
import { HttpdClient } from "@httpd-client";
+
import { extractBaseUrl } from "@app/lib/utils";
+
import { loadProjectActivity } from "@app/lib/commit";
+

+
interface SeedsRouteParams {
+
  hostnamePort: string;
+
  projectPageIndex: number;
+
}
+

+
export interface ProjectActivity {
+
  project: Project;
+
  activity: WeeklyActivity[];
+
}
+

+
export interface SeedsRoute {
+
  resource: "seeds";
+
  params: SeedsRouteParams;
+
}
+

+
export interface SeedsLoadedRoute {
+
  resource: "seeds";
+
  params: {
+
    baseUrl: BaseUrl;
+
    projectPageIndex: number;
+
    version: string;
+
    nid: string;
+
    projects: ProjectActivity[];
+
    projectCount: number;
+
  };
+
}
+

+
const PROJECTS_PER_PAGE = 10;
+

+
export async function loadProjects(
+
  page: number,
+
  baseUrl: BaseUrl,
+
): Promise<{
+
  total: number;
+
  projects: ProjectActivity[];
+
}> {
+
  const api = new HttpdClient(baseUrl);
+

+
  const [nodeStats, projects] = await Promise.all([
+
    api.getStats(),
+
    api.project.getAll({ page, perPage: PROJECTS_PER_PAGE }),
+
  ]);
+

+
  const results = await Promise.all(
+
    projects.map(async project => {
+
      const activity = await loadProjectActivity(project.id, baseUrl);
+
      return {
+
        project,
+
        activity,
+
      };
+
    }),
+
  );
+

+
  return {
+
    total: nodeStats.projects.count,
+
    projects: results,
+
  };
+
}
+

+
export async function loadSeedRoute(
+
  params: SeedsRouteParams,
+
): Promise<SeedsLoadedRoute | LoadError> {
+
  const baseUrl = extractBaseUrl(params.hostnamePort);
+
  const api = new HttpdClient(baseUrl);
+
  try {
+
    const projectPageIndex = 0;
+
    const [nodeInfo, { projects, total }] = await Promise.all([
+
      api.getNodeInfo(),
+
      loadProjects(projectPageIndex, baseUrl),
+
    ]);
+
    return {
+
      resource: "seeds",
+
      params: {
+
        projectPageIndex: projectPageIndex + 1,
+
        baseUrl,
+
        nid: nodeInfo.node.id,
+
        version: nodeInfo.version,
+
        projects: projects,
+
        projectCount: total,
+
      },
+
    };
+
  } catch (error: any) {
+
    return {
+
      resource: "loadError",
+
      params: {
+
        title: params.hostnamePort,
+
        errorMessage: "Not able to query this seed.",
+
        stackTrace: error.stack,
+
      },
+
    };
+
  }
+
}
modified src/views/session/Index.svelte
@@ -20,7 +20,7 @@
      modal.show({ component: AuthenticatedModal, props: {} });
      router.push({
        resource: "seeds",
-
        params: { hostnamePort: "radicle.local" },
+
        params: { hostnamePort: "radicle.local", projectPageIndex: 0 },
      });
    } else {
      modal.show({
modified tests/support/router.ts
@@ -14,8 +14,16 @@ export const expectBackAndForwardNavigationWorks = async (
  page: Page,
) => {
  const currentURL = page.url();
+

  await page.goBack();
+
  await page
+
    .locator("role=progressbar[name='Page loading']")
+
    .waitFor({ state: "hidden" });
  await expect(page).toHaveURL(beforeURL);
  await page.goForward();
+

+
  await page
+
    .locator("role=progressbar[name='Page loading']")
+
    .waitFor({ state: "hidden" });
  await expect(page).toHaveURL(currentURL);
};
modified tests/unit/router.test.ts
@@ -35,18 +35,18 @@ describe("routeToPath", () => {

describe("pathToRoute", () => {
  test.each([
-
    { input: "", output: null, description: "Empty 404 Route" },
+
    { input: "", output: null, description: "Empty not found Route" },
    {
      input: "/foo/baz/bar",
      output: null,
-
      description: "Non existant 404 Route",
+
      description: "Non existant not found route",
    },
    { input: "/", output: { resource: "home" }, description: "Home Route" },
    {
      input: "/seeds/willow.radicle.garden",
      output: {
        resource: "seeds",
-
        params: { hostnamePort: "willow.radicle.garden" },
+
        params: { hostnamePort: "willow.radicle.garden", projectPageIndex: 0 },
      },
      description: "Seed View Route",
    },