Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Move project source browsing loading into the router
Rūdolfs Ošiņš committed 2 years ago
commit a331721c9fdfe6604350b20f782f4b823c9fdbb5
parent 32cb1b667d216480e87b0710e4a795d46cf990eb
24 files changed +564 -482
modified src/App.svelte
@@ -72,7 +72,7 @@
    {:else if $activeRouteStore.resource === "session"}
      <Session activeRoute={$activeRouteStore} />
    {:else if $activeRouteStore.resource === "projects"}
-
      <Projects activeRoute={$activeRouteStore} />
+
      <Projects {...$activeRouteStore.params} />
    {:else if $activeRouteStore.resource === "booting"}
      <Loading />
    {:else if $activeRouteStore.resource === "loadError"}
modified src/components/Markdown.svelte
@@ -16,7 +16,7 @@
  import { updateProjectRoute } from "@app/views/projects/router";

  export let content: string;
-
  export let hash: string | null = null;
+
  export let hash: string | undefined = undefined;
  export let path: string = "/";
  export let rawPath: string | undefined = undefined;

modified src/lib/router.ts
@@ -18,6 +18,8 @@ export const activeRouteStore = writable<LoadedRoute>({
  resource: "booting",
});

+
let currentUrl: URL | undefined;
+

export function useDefaultNavigation(event: MouseEvent) {
  return (
    event.button !== 0 ||
@@ -31,21 +33,28 @@ export function useDefaultNavigation(event: MouseEvent) {
export const base = import.meta.env.VITE_HASH_ROUTING ? "./" : "/";

export async function loadFromLocation(): Promise<void> {
-
  const { pathname, search, hash } = window.location;
-

-
  if (
-
    import.meta.env.VITE_HASH_ROUTING &&
-
    pathname === "/" &&
-
    hash &&
-
    !hash.startsWith("#/")
-
  ) {
-
    // We land here if the user clicked an link with only a hash reference.
-
    // Instead of going to the root page we stop routing here and have the
-
    // browser take care of things.
-
    return;
+
  let { pathname, hash } = window.location;
+

+
  if (import.meta.env.VITE_HASH_ROUTING) {
+
    if (pathname === "/" && hash && !hash.startsWith("#/")) {
+
      // We land here if the user clicked an link with only a hash reference.
+
      // Instead of going to the root page we stop routing here and have the
+
      // browser take care of things.
+
      return;
+
    }
+
    [pathname, hash] = hash.substring(1).split("#");
+
  } else {
+
    if (
+
      currentUrl &&
+
      currentUrl.pathname === pathname &&
+
      currentUrl.search === window.location.search
+
    ) {
+
      return;
+
    }
  }

-
  const url = pathname + search + hash;
+
  const relativeUrl = pathname + window.location.search + (hash || "");
+
  const url = new URL(relativeUrl, window.origin);
  let route = pathToRoute(url);

  if (route) {
@@ -56,7 +65,7 @@ export async function loadFromLocation(): Promise<void> {
      route.params.hash
    ) {
      if (route.params.hash.match(/^L\d+$/)) {
-
        route = createProjectRoute(activeRoute, { line: route.params.hash });
+
        route = createProjectRoute(activeRoute, {});
      } else {
        route = createProjectRoute(activeRoute, { hash: route.params.hash });
      }
@@ -64,7 +73,7 @@ export async function loadFromLocation(): Promise<void> {

    await replace(route);
  } else {
-
    await replace({ resource: "notFound", params: { url } });
+
    await replace({ resource: "notFound", params: { url: relativeUrl } });
  }
}

@@ -77,6 +86,16 @@ async function navigate(
  newRoute: Route,
): Promise<void> {
  isLoading.set(true);
+
  const path = import.meta.env.VITE_HASH_ROUTING
+
    ? "#" + routeToPath(newRoute)
+
    : routeToPath(newRoute);
+

+
  if (action === "push") {
+
    window.history.pushState(newRoute, DOCUMENT_TITLE, path);
+
  } else if (action === "replace") {
+
    window.history.replaceState(newRoute, DOCUMENT_TITLE, path);
+
  }
+
  currentUrl = new URL(window.location.href);

  const loadedRoute = await loadExecutor.run(async () => {
    return loadRoute(newRoute);
@@ -89,16 +108,6 @@ async function navigate(

  activeRouteStore.set(loadedRoute);
  isLoading.set(false);
-

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

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

export async function push(newRoute: Route): Promise<void> {
@@ -109,51 +118,8 @@ export async function replace(newRoute: Route): Promise<void> {
  await navigate("replace", newRoute);
}

-
// We need a SHA1 commit in some places, so we return early if the revision is
-
// a SHA and else we look into branches.
-
export function getOid(
-
  revision: string,
-
  branches?: Record<string, string>,
-
): string | undefined {
-
  if (isOid(revision)) return revision;
-

-
  if (branches) {
-
    const oid = branches[revision];
-
    if (oid) return oid;
-
  }
-
  return undefined;
-
}
-

-
// Check whether the input is a SHA1 commit.
-
export function isOid(input: string): boolean {
-
  return /^[a-fA-F0-9]{40}$/.test(input);
-
}
-

-
export function parseRevisionToOid(
-
  revision: string | undefined,
-
  defaultBranch: string,
-
  branches: Record<string, string>,
-
): string {
-
  if (revision) {
-
    const oid = getOid(revision, branches);
-
    if (!oid) {
-
      throw new Error(`Revision ${revision} not found`);
-
    }
-
    return oid;
-
  }
-
  return branches[defaultBranch];
-
}
-

-
function pathToRoute(path: string): Route | null {
-
  // This matches e.g. an empty string
-
  if (!path) {
-
    return null;
-
  }
-

-
  const url = new URL(path, window.origin);
-
  const segments = import.meta.env.VITE_HASH_ROUTING
-
    ? url.hash.substring(2).split("#")[0].split("/") // Try to remove any additional hashes at the end of the URL.
-
    : url.pathname.substring(1).split("/");
+
function pathToRoute(url: URL): Route | null {
+
  const segments = url.pathname.substring(1).split("/");

  const resource = segments.shift();
  switch (resource) {
@@ -258,9 +224,7 @@ export function routeToPath(route: Route) {
    if (route.params.search) {
      suffix += `?${route.params.search}`;
    }
-
    if (route.params.line) {
-
      suffix += `#${route.params.line}`;
-
    } else if (route.params.hash) {
+
    if (route.params.hash) {
      suffix += `#${route.params.hash}`;
    }

@@ -301,4 +265,4 @@ export function routeToPath(route: Route) {
  }
}

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

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

interface BootingRoute {
@@ -42,7 +46,7 @@ export type LoadedRoute =
  | HomeLoadedRoute
  | LoadError
  | NotFoundRoute
-
  | ProjectRoute
+
  | ProjectLoadedRoute
  | SeedsLoadedRoute
  | SessionRoute;

@@ -51,6 +55,8 @@ export async function loadRoute(route: Route): Promise<LoadedRoute> {
    return await loadSeedRoute(route.params);
  } else if (route.resource === "home") {
    return await loadHomeRoute();
+
  } else if (route.resource === "projects") {
+
    return await loadProjectRoute(route.params);
  } else {
    return route;
  }
modified src/views/projects/Blob.svelte
@@ -1,26 +1,24 @@
<script lang="ts">
  import type { Blob } from "@httpd-client";
  import type { MaybeHighlighted } from "@app/lib/syntax";
-
  import type { ProjectRoute } from "@app/views/projects/router";

-
  import { afterUpdate, beforeUpdate, onMount } from "svelte";
+
  import { afterUpdate, onDestroy, onMount } from "svelte";
  import { toHtml } from "hast-util-to-html";

  import { highlight } from "@app/lib/syntax";
-
  import { isMarkdownPath, scrollIntoView, twemoji } from "@app/lib/utils";
+
  import { isMarkdownPath, twemoji } from "@app/lib/utils";
  import { lineNumbersGutter } from "@app/lib/syntax";
-
  import { updateProjectRoute } from "@app/views/projects/router";

  import Readme from "@app/views/projects/Readme.svelte";
  import SquareButton from "@app/components/SquareButton.svelte";

-
  export let activeRoute: ProjectRoute;
+
  export let path: string;
+
  export let hash: string | undefined = undefined;
  export let blob: Blob;
  export let rawPath: string;
-
  export let line: string | undefined = undefined;

-
  const fileExtension = blob.path.split(".").pop() ?? "";
-
  const lastCommit = blob.lastCommit;
+
  $: fileExtension = blob.path.split(".").pop() ?? "";
+
  $: lastCommit = blob.lastCommit;

  const parentDir = blob.path
    .match(/^.*\/|/)
@@ -28,17 +26,8 @@
    .next().value;
  let content: MaybeHighlighted = undefined;

-
  // Any time a user clicks on a line number, the `line` prop gets updated,
-
  // and the line is highlighted, but the previous line is not unhighlighted.
-
  // So we have to make sure here that any previous highlighting gets removed,
-
  // before updating the component.
-
  beforeUpdate(() => {
-
    for (const item of document.getElementsByClassName("highlight")) {
-
      item.classList.remove("highlight");
-
    }
-
  });
-

  onMount(async () => {
+
    window.addEventListener("hashchange", setTarget);
    if (!blob.content) {
      return;
    }
@@ -48,24 +37,35 @@
    }
  });

-
  afterUpdate(() => {
-
    if (line) {
-
      scrollIntoView(line);
+
  onDestroy(() => {
+
    window.removeEventListener("hashchange", setTarget);
+
  });

-
      const element = document.getElementById(line);
-
      if (element) {
-
        element.classList.add("highlight");
-
      }
-
    }
+
  afterUpdate(() => {
+
    setTarget();
  });

  const isMarkdown = isMarkdownPath(blob.path);
-
  // If we have a line number we should show the raw output.
-
  let showMarkdown = line ? false : isMarkdown;
+
  let showMarkdown = isMarkdown;
  const toggleMarkdown = () => {
-
    void updateProjectRoute({ line: undefined });
+
    window.location.hash = "";
    showMarkdown = !showMarkdown;
  };
+

+
  function setTarget() {
+
    for (const item of document.getElementsByClassName("highlight")) {
+
      item.classList.remove("highlight");
+
    }
+
    const fragmentId = window.location.hash.substr(1);
+
    if (fragmentId && fragmentId.match(/L\d+/)) {
+
      showMarkdown = false;
+
      const target = document.getElementById(fragmentId);
+
      if (target) {
+
        target.classList.add("highlight");
+
        target.scrollIntoView();
+
      }
+
    }
+
  }
</script>

<style>
@@ -253,7 +253,7 @@
        <span class="txt-tiny">Binary content</span>
      </div>
    {:else if showMarkdown && blob.content}
-
      <Readme content={blob.content} {rawPath} {activeRoute} />
+
      <Readme content={blob.content} {rawPath} {path} {hash} />
    {:else if content}
      <table class="code no-scrollbar">
        {@html toHtml(content)}
modified src/views/projects/BranchSelector.svelte
@@ -1,6 +1,6 @@
<script lang="ts" strictEvents>
  import * as utils from "@app/lib/utils";
-
  import { parseRevisionToOid } from "@app/lib/router";
+
  import { parseRevisionToOid } from "@app/views/projects/router";

  import Dropdown from "@app/components/Dropdown.svelte";
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
modified src/views/projects/Browser.svelte
@@ -8,13 +8,9 @@

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

-
  import { onMount } from "svelte";

  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
-
  import { parseRevisionToOid } from "@app/lib/router";

  import Button from "@app/components/Button.svelte";
  import Loading from "@app/components/Loading.svelte";
@@ -30,18 +26,15 @@

  type State =
    | { status: Status.Loading; path: string }
-
    | { status: Status.Loaded; path: string; blob: Blob };
+
    | { status: Status.Loaded; path: string; blob: Blob; commit: string };

+
  export let path: string;
+
  export let hash: string | undefined = undefined;
  export let project: Project;
  export let baseUrl: BaseUrl;
  export let tree: Tree;
  export let revision: string | undefined;
-
  export let branches: Record<string, string>;
-
  export let activeRoute: ProjectRoute;
-

-
  $: path = activeRoute.params.path || "/";
-
  $: line = activeRoute.params.line;
-
  $: commit = parseRevisionToOid(revision, project.defaultBranch, branches);
+
  export let commit: string;

  // When the component is loaded the first time, the blob is yet to be loaded.
  let state: State = { status: Status.Loading, path };
@@ -55,8 +48,13 @@
  // UI from flickering or showing a loading indicator.
  let previousBlob: Blob;

-
  const loadBlob = async (path: string) => {
-
    if (state.status === Status.Loaded && state.path === path) {
+
  const loadBlob = async (projectId: string, commit: string, path: string) => {
+
    browserErrorStore.set(undefined);
+
    if (
+
      state.status === Status.Loaded &&
+
      state.path === path &&
+
      state.commit === commit
+
    ) {
      return state.blob;
    }

@@ -64,20 +62,16 @@

    let blob;
    if (path === "/") {
-
      blob = await api.project.getReadme(project.id, commit);
+
      blob = await api.project.getReadme(projectId, commit);
    } else {
-
      blob = await api.project.getBlob(project.id, commit, path);
+
      blob = await api.project.getBlob(projectId, commit, path);
    }

-
    state = { status: Status.Loaded, path, blob };
+
    state = { status: Status.Loaded, path, blob, commit };
    previousBlob = blob;
    return blob;
  };

-
  onMount(() => {
-
    browserErrorStore.set(undefined);
-
  });
-

  const fetchTree = async (path: string) => {
    return api.project.getTree(project.id, commit, path).catch(() => {
      browserErrorStore.set({
@@ -88,7 +82,7 @@
    });
  };

-
  $: getBlob = loadBlob(path).catch(() => {
+
  $: getBlob = loadBlob(project.id, commit, path).catch(() => {
    browserErrorStore.set({ message: "Not able to load file", path });
    return undefined;
  });
@@ -227,9 +221,9 @@
            {#if previousBlob}
              <div class="layout-desktop">
                <BlobComponent
-
                  {line}
+
                  {path}
+
                  {hash}
                  blob={previousBlob}
-
                  {activeRoute}
                  rawPath={utils.getRawBasePath(project.id, baseUrl, commit)} />
              </div>
              <div class="layout-mobile">
@@ -241,9 +235,9 @@
          {:then blob}
            {#if blob}
              <BlobComponent
-
                {line}
+
                {path}
+
                {hash}
                {blob}
-
                {activeRoute}
                rawPath={utils.getRawBasePath(project.id, baseUrl, commit)} />
            {/if}
          {/await}
modified src/views/projects/Header.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
  import type { BaseUrl } from "@httpd-client";
-
  import type { ProjectRoute } from "@app/views/projects/router";
+
  import type { ProjectLoadedView } from "@app/views/projects/router";

  import { config } from "@app/lib/config";
  import { isLocal } from "@app/lib/utils";
@@ -12,7 +12,7 @@
  import ProjectLink from "@app/components/ProjectLink.svelte";
  import SquareButton from "@app/components/SquareButton.svelte";

-
  export let activeRoute: ProjectRoute;
+
  export let view: ProjectLoadedView;
  export let baseUrl: BaseUrl;

  export let projectId: string;
@@ -20,7 +20,7 @@

  export let openPatchCount: number;
  export let openIssueCount: number;
-
  export let trackings: number = 0;
+
  export let trackings: number;
</script>

<style>
@@ -35,10 +35,6 @@
    margin-bottom: 1rem;
  }

-
  .header:last-of-type {
-
    margin-bottom: 2rem;
-
  }
-

  @media (max-width: 960px) {
    .header {
      padding-left: 2rem;
@@ -59,9 +55,9 @@
      revision: undefined,
    }}>
    <SquareButton
-
      active={activeRoute.params.view.resource === "tree" ||
-
        activeRoute.params.view.resource === "history" ||
-
        activeRoute.params.view.resource === "commits"}>
+
      active={view.resource === "tree" ||
+
        view.resource === "history" ||
+
        view.resource === "commits"}>
      Source
    </SquareButton>
  </ProjectLink>
@@ -77,8 +73,7 @@
      path: undefined,
    }}>
    <SquareButton
-
      active={activeRoute.params.view.resource === "issues" ||
-
        activeRoute.params.view.resource === "issue"}>
+
      active={view.resource === "issues" || view.resource === "issue"}>
      <svelte:fragment slot="icon">
        <Icon size="small" name="exclamation-circle" />
      </svelte:fragment>
@@ -99,8 +94,7 @@
      path: undefined,
    }}>
    <SquareButton
-
      active={activeRoute.params.view.resource === "patches" ||
-
        activeRoute.params.view.resource === "patch"}>
+
      active={view.resource === "patches" || view.resource === "patch"}>
      <svelte:fragment slot="icon">
        <Icon size="small" name="patch" />
      </svelte:fragment>
modified src/views/projects/History.svelte
@@ -23,28 +23,42 @@

  const api = new HttpdClient(baseUrl);

-
  async function loadHistory(): Promise<void> {
+
  let previousCommit = parentCommit;
+

+
  $: showMoreButton =
+
    !loading && !error && totalCommitCount && history.length < totalCommitCount;
+

+
  // To avoid a recursive loop when loading the histrory below,
+
  // we do it in a function outside of the reactive statement.
+
  function appendHistory(commits: CommitHeader[]) {
+
    history = [...history, ...commits];
+
  }
+

+
  $: {
+
    if (previousCommit !== parentCommit) {
+
      page = 0;
+
      history = [];
+
      previousCommit = parentCommit;
+
      error = undefined;
+
    }
    loading = true;
-
    try {
-
      const response = await api.project.getAllCommits(projectId, {
+
    api.project
+
      .getAllCommits(projectId, {
        parent: parentCommit,
        page,
        perPage,
+
      })
+
      .then(response => {
+
        appendHistory(response.commits.map(c => c.commit));
+
        totalCommitCount = response.stats.commits;
+
      })
+
      .catch(e => {
+
        error = e;
+
      })
+
      .finally(() => {
+
        loading = false;
      });
-
      history = [...history, ...response.commits.map(c => c.commit)];
-
      totalCommitCount = response.stats.commits;
-
      page += 1;
-
    } catch (e) {
-
      error = e;
-
    } finally {
-
      loading = false;
-
    }
  }
-

-
  $: showMoreButton =
-
    !loading && !error && totalCommitCount && history.length < totalCommitCount;
-

-
  void loadHistory();
</script>

<style>
@@ -90,7 +104,13 @@
      {/if}

      {#if showMoreButton}
-
        <Button variant="foreground" on:click={loadHistory}>More</Button>
+
        <Button
+
          variant="foreground"
+
          on:click={() => {
+
            page = page + 1;
+
          }}>
+
          More
+
        </Button>
      {/if}
    </div>
  </div>
modified src/views/projects/Issue.svelte
@@ -1,9 +1,9 @@
-
<script lang="ts" strictEvents>
+
<script lang="ts">
  import type { BaseUrl, Issue, IssueState } from "@httpd-client";

-
  import { createEventDispatcher } from "svelte";
  import { isEqual } from "lodash";

+
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
  import { HttpdClient } from "@httpd-client";
  import { httpdStore } from "@app/lib/httpd";
@@ -25,7 +25,6 @@
  export let projectId: string;
  export let projectHead: string;

-
  const dispatch = createEventDispatcher<{ update: never }>();
  const rawPath = utils.getRawBasePath(projectId, baseUrl, projectHead);
  const api = new HttpdClient(baseUrl);

@@ -138,8 +137,17 @@
        { type: "lifecycle", state },
        $httpdStore.session.id,
      );
-
      dispatch("update");
-
      issue = await api.project.getIssueById(projectId, issue.id);
+
      void router.push({
+
        resource: "projects",
+
        params: {
+
          id: projectId,
+
          hostnamePort: baseUrl.hostname,
+
          view: {
+
            resource: "issue",
+
            params: { issue: issue.id },
+
          },
+
        },
+
      });
    }
  }

modified src/views/projects/PeerSelector.svelte
@@ -14,7 +14,7 @@
  export let peer: string | undefined = undefined;
  export let peers: Remote[];

-
  const meta = peers.find(p => p.id === peer);
+
  $: meta = peers.find(p => p.id === peer);

  function createTitle(p: Remote): string {
    const nodeId = formatNodeId(p.id);
modified src/views/projects/ProjectMeta.svelte
@@ -1,13 +1,13 @@
<script lang="ts">
-
  import type { Project } from "@httpd-client";
-

  import Clipboard from "@app/components/Clipboard.svelte";
  import DOMPurify from "dompurify";
  import ProjectLink from "@app/components/ProjectLink.svelte";
  import { formatNodeId, twemoji } from "@app/lib/utils";

-
  export let project: Project;
  export let nodeId: string | undefined = undefined;
+
  export let projectDescription: string;
+
  export let projectId: string;
+
  export let projectName: string;

  const linkifyDescription = (text: string) => {
    return text.replaceAll(/(https?:\/\/[^\s]+)/g, `<a href="$1">$1</a>`);
@@ -93,7 +93,7 @@
          revision: undefined,
        }}>
        <span class="project-name">
-
          {project.name}
+
          {projectName}
        </span>
      </ProjectLink>
    </span>
@@ -106,10 +106,10 @@
    {/if}
  </div>
  <div class="id">
-
    <span class="truncate">{project.id}</span>
-
    <Clipboard small text={project.id} />
+
    <span class="truncate">{projectId}</span>
+
    <Clipboard small text={projectId} />
  </div>
  <div class="description" use:twemoji>
-
    {@html DOMPurify.sanitize(linkifyDescription(project.description))}
+
    {@html DOMPurify.sanitize(linkifyDescription(projectDescription))}
  </div>
</header>
modified src/views/projects/Readme.svelte
@@ -1,14 +1,10 @@
<script lang="ts">
-
  import type { ProjectRoute } from "@app/views/projects/router";
-

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

  export let content: string;
  export let rawPath: string;
-
  export let activeRoute: ProjectRoute;
-

-
  $: path = activeRoute.params.path || "/";
-
  $: hash = activeRoute.params.hash || null;
+
  export let path: string;
+
  export let hash: string | undefined = undefined;
</script>

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

  import { closeFocused } from "@app/components/Floating.svelte";
  import { pluralize } from "@app/lib/pluralize";
@@ -11,10 +11,11 @@
  import SquareButton from "@app/components/SquareButton.svelte";

  export let project: Project;
-
  export let activeRoute: ProjectRoute;
+
  export let peer: string | undefined = undefined;
  export let revision: string | undefined;
  export let peers: Remote[];
  export let branches: Record<string, string>;
+
  export let view: ProjectLoadedView;

  export let commitCount: number;
  export let contributorCount: number;
@@ -44,10 +45,7 @@

<div class="header">
  {#if peers.length > 0}
-
    <PeerSelector
-
      {peers}
-
      peer={activeRoute.params.peer}
-
      on:click={() => closeFocused()} />
+
    <PeerSelector {peers} {peer} on:click={() => closeFocused()} />
  {/if}

  <BranchSelector
@@ -66,8 +64,7 @@
      search: undefined,
    }}>
    <SquareButton
-
      active={activeRoute.params.view.resource === "history" ||
-
        activeRoute.params.view.resource === "commits"}>
+
      active={view.resource === "history" || view.resource === "commits"}>
      <span class="txt-bold">{commitCount}</span>
      {pluralize("commit", commitCount)}
    </SquareButton>
modified src/views/projects/Tree.svelte
@@ -33,10 +33,7 @@
    <ProjectLink
      projectParams={{ view: { resource: "tree" }, path: entry.path, revision }}
      on:click={() => onSelect({ detail: entry.path })}>
-
      <File
-
        active={entry.path === path}
-
        loading={entry.path === loadingPath}
-
        name={entry.name} />
+
      <File active={entry.path === path} name={entry.name} />
    </ProjectLink>
  {/if}
{/each}
modified src/views/projects/Tree/File.svelte
@@ -1,8 +1,5 @@
<script lang="ts">
-
  import Loading from "@app/components/Loading.svelte";
-

  export let active: boolean;
-
  export let loading: boolean;
  export let name: string;
</script>

@@ -28,14 +25,6 @@
    background-color: var(--color-foreground-1);
  }

-
  .spinner {
-
    align-items: center;
-
    display: flex;
-
    justify-content: center;
-
    height: 24px;
-
    width: 24px;
-
  }
-

  .name {
    margin-left: 0.25rem;
    user-select: none;
@@ -48,9 +37,4 @@

<div class="file" class:active>
  <span class="name">{name}</span>
-
  <div class="spinner">
-
    {#if loading}
-
      <Loading noDelay small condensed />
-
    {/if}
-
  </div>
</div>
modified src/views/projects/Tree/Folder.svelte
@@ -96,10 +96,7 @@
                revision,
              }}
              on:click={() => onSelectFile({ detail: entry.path })}>
-
              <File
-
                active={entry.path === currentPath}
-
                loading={entry.path === loadingPath}
-
                name={entry.name} />
+
              <File active={entry.path === currentPath} name={entry.name} />
            </ProjectLink>
          {/if}
        {/each}
modified src/views/projects/View.svelte
@@ -1,20 +1,17 @@
<script lang="ts">
  import type { IssueStatus } from "./Issues.svelte";
  import type { PatchStatus } from "./Patches.svelte";
-
  import type { ProjectRoute } from "@app/views/projects/router";
-
  import type { Tree } from "@httpd-client";
+
  import type { Project } from "@httpd-client";
+
  import type { ProjectLoadedView } from "@app/views/projects/router";

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

  import Loading from "@app/components/Loading.svelte";
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
-
  import NotFound from "@app/components/NotFound.svelte";
-
  import Placeholder from "@app/components/Placeholder.svelte";
  import SourceBrowsingHeader from "./SourceBrowsingHeader.svelte";

  import Browser from "./Browser.svelte";
@@ -28,96 +25,31 @@
  import Patches from "./Patches.svelte";
  import ProjectMeta from "./ProjectMeta.svelte";

-
  export let activeRoute: ProjectRoute;
+
  export let hostnamePort: string;
+
  export let id: string;
+
  export let project: Project;
+
  export let view: ProjectLoadedView;

-
  $: id = activeRoute.params.id;
-
  $: peer = activeRoute.params.peer;
-
  $: revision = activeRoute.params.revision;
+
  export let hash: string | undefined = undefined;
+
  export let path: string | undefined = undefined;
+
  export let peer: string | undefined = undefined;
+
  export let revision: string | undefined = undefined;
+
  export let search: string | undefined = undefined;

-
  $: searchParams = new URLSearchParams(activeRoute.params.search || "");
+
  $: searchParams = new URLSearchParams(search || "");
  $: issueFilter = (searchParams.get("state") as IssueStatus) || "open";
  $: patchTabFilter =
    (searchParams.get("tab") as "activity" | "commits" | "files") || "activity";
  $: patchFilter = (searchParams.get("state") as PatchStatus) || "open";
  $: patchDiffFilter = searchParams.get("diff") || undefined;
-
  $: baseUrl = utils.extractBaseUrl(activeRoute.params.hostnamePort);
+
  $: baseUrl = utils.extractBaseUrl(hostnamePort);
  $: api = new HttpdClient(baseUrl);

-
  // Parses the path consisting of a revision (eg. branch or commit) and file
-
  // path into a tuple [revision, file-path]
-
  function parseRoute(
-
    input: string,
-
    branches: Record<string, string>,
-
  ): { path?: string; revision?: string } {
-
    const parsed: { path?: string; revision?: string } = {};
-
    const commitPath = [input.slice(0, 40), input.slice(41)];
-
    const branch = Object.entries(branches).find(([branchName]) =>
-
      input.startsWith(branchName),
-
    );
-

-
    if (branch) {
-
      const [rev, path] = [
-
        input.slice(0, branch[0].length),
-
        input.slice(branch[0].length + 1),
-
      ];
-
      parsed.revision = rev;
-
      parsed.path = path || "/";
-
    } else if (router.isOid(commitPath[0])) {
-
      parsed.revision = commitPath[0];
-
      parsed.path = commitPath[1] || "/";
-
    } else {
-
      parsed.path = input;
-
    }
-
    return parsed;
-
  }
-

-
  const getProject = async (id: string, peer?: string) => {
-
    const project = await api.project.getById(id);
-
    const peers = await api.project.getAllRemotes(id);
-
    let branches = project.head
-
      ? { [project.defaultBranch]: project.head }
-
      : {};
-
    if (peer) {
-
      try {
-
        branches = (await api.project.getRemoteByPeer(id, peer)).heads;
-
      } catch {
-
        branches = {};
-
      }
-
    }
-

-
    if (activeRoute.params.route) {
-
      const { revision, path } = parseRoute(activeRoute.params.route, branches);
-
      void updateProjectRoute(
-
        {
-
          revision,
-
          path,
-
          line: activeRoute.params.line,
-
          hash: activeRoute.params.hash,
-
          route: undefined,
-
        },
-
        { replace: true },
-
      );
-
    }
-

-
    return { project, branches, peers };
-
  };
-

-
  async function getRoot(
-
    branches: Record<string, string>,
-
    defaultBranch: string,
-
    revision?: string,
-
  ): Promise<{ tree: Tree }> {
-
    const commit = router.parseRevisionToOid(revision, defaultBranch, branches);
-
    const tree = await api.project.getTree(id, commit);
-

-
    return { tree };
-
  }
-

  function handleIssueCreation({ detail: issueId }: CustomEvent<string>) {
    void router.push({
      resource: "projects",
      params: {
-
        id,
+
        id: id,
        hostnamePort: baseUrl.hostname,
        view: {
          resource: "issue",
@@ -125,36 +57,27 @@
        },
      },
    });
-
    // This assignment allows us to have an up-to-date issue count
-
    projectPromise = getProject(id, peer);
  }
-

-
  function handleIssueUpdate() {
-
    projectPromise = getProject(id, peer);
-
  }
-

-
  // React to peer changes
-
  $: projectPromise = getProject(id, peer);
</script>

<style>
-
  main {
+
  .header {
    width: 100%;
    max-width: var(--content-max-width);
    min-width: var(--content-min-width);
-
    padding: 4rem 0;
+
    padding-top: 4rem;
  }
-
  main > header {
-
    padding: 0 2rem 0 8rem;
+
  main {
+
    width: 100%;
+
    max-width: var(--content-max-width);
+
    min-width: var(--content-min-width);
+
    padding-bottom: 4rem;
  }
  main > .message {
    padding: 0 2rem 0 8rem;
  }

  @media (max-width: 960px) {
-
    main > header {
-
      padding-left: 2rem;
-
    }
    main > .message {
      padding-left: 2rem;
    }
@@ -165,154 +88,115 @@
  }
</style>

-
{#await projectPromise}
-
  <main>
-
    <header>
+
<div class="header">
+
  <ProjectMeta
+
    projectId={id}
+
    projectName={project.name}
+
    projectDescription={project.description}
+
    nodeId={peer} />
+
  <Header
+
    projectId={id}
+
    projectName={project.name}
+
    openPatchCount={project.patches.open}
+
    openIssueCount={project.issues.open}
+
    trackings={project.trackings}
+
    {view}
+
    {baseUrl} />
+
</div>
+

+
<main>
+
  {#if view.resource === "tree" || view.resource === "history" || view.resource === "commits"}
+
    <SourceBrowsingHeader
+
      {project}
+
      {peer}
+
      {view}
+
      peers={view.params.loadedPeers}
+
      branches={view.params.loadedBranches}
+
      commitCount={view.params.loadedTree.stats.commits}
+
      contributorCount={view.params.loadedTree.stats.contributors}
+
      {revision} />
+

+
    {#if view.resource === "tree"}
+
      <Browser
+
        {baseUrl}
+
        {project}
+
        {revision}
+
        commit={view.params.selectedCommit}
+
        tree={view.params.loadedTree}
+
        path={path || "/"}
+
        {hash} />
+
    {:else if view.resource === "history"}
+
      <History
+
        projectId={id}
+
        {baseUrl}
+
        parentCommit={view.params.selectedCommit} />
+
    {:else if view.resource === "commits"}
+
      {#await api.project.getCommitBySha(id, view.params.selectedCommit)}
+
        <Loading center />
+
      {:then fetchedCommit}
+
        <Commit commit={fetchedCommit} />
+
      {:catch e}
+
        <div class="message">
+
          <ErrorMessage message="Couln't load commit." stackTrace={e} />
+
        </div>
+
      {/await}
+
    {/if}
+
  {:else if view.resource === "issues" && view.params?.view.resource === "new"}
+
    {#if $httpdStore.state === "authenticated"}
+
      <NewIssue
+
        on:create={handleIssueCreation}
+
        session={$httpdStore.session}
+
        projectId={id}
+
        projectHead={project.head}
+
        {baseUrl} />
+
    {:else}
+
      <div class="message">
+
        <ErrorMessage
+
          message="Couldn't access issue creation. Make sure you're still logged in." />
+
      </div>
+
    {/if}
+
  {:else if view.resource === "issues"}
+
    <Issues
+
      {baseUrl}
+
      projectId={id}
+
      issueCounters={project.issues}
+
      state={issueFilter} />
+
  {:else if view.resource === "issue"}
+
    {#await api.project.getIssueById(id, view.params.issue)}
      <Loading center />
-
    </header>
-
  </main>
-
{:then { project, peers, branches }}
-
  {@const commit = router.parseRevisionToOid(
-
    revision,
-
    project.defaultBranch,
-
    branches,
-
  )}
-
  <main>
-
    <ProjectMeta {project} nodeId={peer} />
-
    {#await getRoot(branches, project.defaultBranch, revision)}
+
    {:then issue}
+
      <Issue projectId={id} projectHead={project.head} {baseUrl} {issue} />
+
    {:catch e}
+
      <div class="message">
+
        <ErrorMessage message="Couldn't load issue." stackTrace={e} />
+
      </div>
+
    {/await}
+
  {:else if view.resource === "patches"}
+
    <Patches
+
      {baseUrl}
+
      projectId={id}
+
      state={patchFilter}
+
      patchCounters={project.patches} />
+
  {:else if view.resource === "patch"}
+
    {#await api.project.getPatchById(id, view.params.patch)}
      <Loading center />
-
    {:then { tree }}
-
      <Header
-
        projectId={project.id}
-
        projectName={project.name}
-
        openPatchCount={project.patches.open}
-
        openIssueCount={project.issues.open}
-
        trackings={project.trackings}
-
        {activeRoute}
-
        {baseUrl} />
-

-
      {#if activeRoute.params.view.resource === "tree" || activeRoute.params.view.resource === "history" || activeRoute.params.view.resource === "commits"}
-
        <SourceBrowsingHeader
-
          {project}
-
          {activeRoute}
-
          {peers}
-
          {branches}
-
          commitCount={tree.stats.commits}
-
          contributorCount={tree.stats.contributors}
-
          revision={activeRoute.params.revision} />
-
      {/if}
-

-
      {#if activeRoute.params.view.resource === "tree"}
-
        <Browser
-
          {baseUrl}
-
          {project}
-
          {revision}
-
          {branches}
-
          {tree}
-
          {activeRoute} />
-
      {:else if activeRoute.params.view.resource === "history"}
-
        <History projectId={project.id} {baseUrl} parentCommit={commit} />
-
      {:else if activeRoute.params.view.resource === "commits"}
-
        {#await api.project.getCommitBySha(id, commit)}
-
          <Loading center />
-
        {:then fetchedCommit}
-
          <Commit commit={fetchedCommit} />
-
        {:catch e}
-
          <div class="message">
-
            <ErrorMessage message="Couln't load commit." stackTrace={e} />
-
          </div>
-
        {/await}
-
      {:else if activeRoute.params.view.resource === "issues" && activeRoute.params.view.params?.view.resource === "new"}
-
        {#if $httpdStore.state === "authenticated"}
-
          <NewIssue
-
            on:create={handleIssueCreation}
-
            session={$httpdStore.session}
-
            projectId={project.id}
-
            projectHead={project.head}
-
            {baseUrl} />
-
        {:else}
-
          <div class="message">
-
            <ErrorMessage
-
              message="Couldn't access issue creation. Make sure you're still logged in." />
-
          </div>
-
        {/if}
-
      {:else if activeRoute.params.view.resource === "issues"}
-
        <Issues
-
          {baseUrl}
-
          projectId={project.id}
-
          issueCounters={project.issues}
-
          state={issueFilter} />
-
      {:else if activeRoute.params.view.resource === "issue"}
-
        {#await api.project.getIssueById(project.id, activeRoute.params.view.params.issue)}
-
          <Loading center />
-
        {:then issue}
-
          <Issue
-
            on:update={handleIssueUpdate}
-
            projectId={project.id}
-
            projectHead={project.head}
-
            {baseUrl}
-
            {issue} />
-
        {:catch e}
-
          <div class="message">
-
            <ErrorMessage message="Couldn't load issue." stackTrace={e} />
-
          </div>
-
        {/await}
-
      {:else if activeRoute.params.view.resource === "patches"}
-
        <Patches
-
          {baseUrl}
-
          projectId={project.id}
-
          state={patchFilter}
-
          patchCounters={project.patches} />
-
      {:else if activeRoute.params.view.resource === "patch"}
-
        {#await api.project.getPatchById(project.id, activeRoute.params.view.params.patch)}
-
          <Loading center />
-
        {:then patch}
-
          {@const latestRevision = patch.revisions[patch.revisions.length - 1]}
-
          <Patch
-
            {patch}
-
            {baseUrl}
-
            projectId={project.id}
-
            projectDefaultBranch={project.defaultBranch}
-
            projectHead={project.head}
-
            revision={activeRoute.params.view.params.revision ??
-
              latestRevision.id}
-
            currentTab={patchTabFilter}
-
            diff={patchDiffFilter} />
-
        {:catch e}
-
          <div class="message">
-
            <ErrorMessage message="Couldn't load patch." stackTrace={e} />
-
          </div>
-
        {/await}
-
      {:else}
-
        {unreachable(activeRoute.params.view)}
-
      {/if}
+
    {:then patch}
+
      {@const latestRevision = patch.revisions[patch.revisions.length - 1]}
+
      <Patch
+
        {patch}
+
        {baseUrl}
+
        projectId={id}
+
        projectDefaultBranch={project.defaultBranch}
+
        projectHead={project.head}
+
        revision={view.params.revision ?? latestRevision.id}
+
        currentTab={patchTabFilter}
+
        diff={patchDiffFilter} />
    {:catch e}
      <div class="message">
-
        {#if peer}
-
          <Placeholder emoji="🍂">
-
            <span slot="title">
-
              <span class="txt-monospace">{formatNodeId(peer)}</span>
-
            </span>
-
            <span slot="body">
-
              <span style="display: block">
-
                Couldn't load remote source tree.
-
              </span>
-
              <span>{e.message}</span>
-
            </span>
-
          </Placeholder>
-
        {:else}
-
          <Placeholder emoji="🍂">
-
            <span slot="body">
-
              <span style="display: block">Couldn't load source tree.</span>
-
              <span>{e.message}</span>
-
            </span>
-
          </Placeholder>
-
        {/if}
+
        <ErrorMessage message="Couldn't load patch." stackTrace={e} />
      </div>
    {/await}
-
  </main>
-
{:catch}
-
  <div class="layout-centered">
-
    <NotFound subtitle={id} title="Project not found" />
-
  </div>
-
{/await}
+
  {:else}
+
    {unreachable(view)}
+
  {/if}
+
</main>
modified src/views/projects/router.ts
@@ -1,17 +1,26 @@
+
import type { LoadError } from "@app/lib/router/definitions";
+
import type { Project, Remote, Tree } from "@httpd-client";
+

import { get } from "svelte/store";

+
import { HttpdClient } from "@httpd-client";
import { activeRouteStore, push, replace, routeToPath } from "@app/lib/router";
+
import { extractBaseUrl } from "@app/lib/utils";

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

+
export interface ProjectLoadedRoute {
+
  resource: "projects";
+
  params: ProjectLoadedParams;
+
}
+

export interface ProjectsParams {
  id: string;
  hash?: string;
  hostnamePort: string;
-
  line?: string;
  path?: string;
  peer?: string;
  revision?: string;
@@ -37,6 +46,211 @@ export interface ProjectsParams {
    | { resource: "patch"; params: { patch: string; revision?: string } };
}

+
export interface ProjectLoadedParams {
+
  hostnamePort: string;
+
  id: string;
+
  project: Project;
+
  view: ProjectLoadedView;
+

+
  hash?: string;
+
  path?: string;
+
  peer?: string;
+
  revision?: string;
+
  search?: string;
+
}
+

+
interface LoadedSourceBrowsingParams {
+
  loadedBranches: Record<string, string>;
+
  loadedPeers: Remote[];
+
  loadedTree: Tree;
+
  selectedCommit: string;
+
}
+

+
export type ProjectLoadedView =
+
  | {
+
      resource: "tree";
+
      params: LoadedSourceBrowsingParams;
+
    }
+
  | {
+
      resource: "commits";
+
      params: LoadedSourceBrowsingParams;
+
    }
+
  | {
+
      resource: "history";
+
      params: LoadedSourceBrowsingParams;
+
    }
+
  | { resource: "issue"; params: { issue: string } }
+
  | {
+
      resource: "issues";
+
      params?: {
+
        view: { resource: "new" };
+
      };
+
    }
+
  | {
+
      resource: "patches";
+
      params?: {
+
        view: { resource: "new" };
+
      };
+
    }
+
  | { resource: "patch"; params: { patch: string; revision?: string } };
+

+
// We need a SHA1 commit in some places, so we return early if the revision is
+
// a SHA and else we look into branches.
+
function getOid(
+
  revision: string,
+
  branches?: Record<string, string>,
+
): string | undefined {
+
  if (isOid(revision)) return revision;
+

+
  if (branches) {
+
    const oid = branches[revision];
+
    if (oid) return oid;
+
  }
+
  return undefined;
+
}
+

+
// Check whether the input is a SHA1 commit.
+
export function isOid(input: string): boolean {
+
  return /^[a-fA-F0-9]{40}$/.test(input);
+
}
+

+
export function parseRevisionToOid(
+
  revision: string | undefined,
+
  defaultBranch: string,
+
  branches: Record<string, string>,
+
): string {
+
  if (revision) {
+
    const oid = getOid(revision, branches);
+
    if (!oid) {
+
      throw new Error(`Revision ${revision} not found`);
+
    }
+
    return oid;
+
  }
+
  return branches[defaultBranch];
+
}
+

+
export async function loadProjectRoute(
+
  params: ProjectsParams,
+
): Promise<ProjectLoadedRoute | LoadError> {
+
  const baseUrl = extractBaseUrl(params.hostnamePort);
+
  const api = new HttpdClient(baseUrl);
+
  try {
+
    if (
+
      params.view.resource === "tree" ||
+
      params.view.resource === "history" ||
+
      params.view.resource === "commits"
+
    ) {
+
      const projectPromise = api.project.getById(params.id);
+
      const peersPromise = api.project.getAllRemotes(params.id);
+
      const branchesPromise = (async () => {
+
        if (params.peer) {
+
          try {
+
            return (await api.project.getRemoteByPeer(params.id, params.peer))
+
              .heads;
+
          } catch {
+
            return {};
+
          }
+
        }
+
      })();
+

+
      const [project, peers, maybeBranches] = await Promise.all([
+
        projectPromise,
+
        peersPromise,
+
        branchesPromise,
+
      ]);
+

+
      let branches: Record<string, string>;
+
      if (maybeBranches) {
+
        branches = maybeBranches;
+
      } else {
+
        branches = project.head
+
          ? { [project.defaultBranch]: project.head }
+
          : {};
+
      }
+

+
      if (params.route) {
+
        const { revision, path } = detectRevision(params.route, branches);
+
        params.revision = revision;
+
        params.path = path;
+
        // TODO Do not mutate `params`. Contruct a new `loadedParams` object
+
        // instead.
+
        delete params.route;
+
      }
+

+
      const commit = parseRevisionToOid(
+
        params.revision,
+
        project.defaultBranch,
+
        branches,
+
      );
+
      const tree = await api.project.getTree(params.id, commit);
+
      return {
+
        resource: "projects",
+
        params: {
+
          ...params,
+
          project,
+
          view: {
+
            resource: params.view.resource,
+
            params: {
+
              loadedBranches: branches,
+
              loadedPeers: peers,
+
              loadedTree: tree,
+
              selectedCommit: commit,
+
            },
+
          },
+
        },
+
      };
+
    } else {
+
      const project = await api.project.getById(params.id);
+
      return {
+
        resource: "projects",
+
        params: {
+
          ...params,
+
          view: params.view,
+
          project,
+
        },
+
      };
+
    }
+
  } catch (error: any) {
+
    return {
+
      resource: "loadError",
+
      params: {
+
        title: params.id,
+
        errorMessage: "Not able to load this project.",
+
        stackTrace: error.stack,
+
      },
+
    };
+
  }
+
}
+

+
// Detects branch names and commit IDs at the start of `input` and extract it.
+
function detectRevision(
+
  input: string,
+
  branches: Record<string, string>,
+
): { path: string; revision?: string } {
+
  const commitPath = [input.slice(0, 40), input.slice(41)];
+
  const branch = Object.entries(branches).find(([branchName]) =>
+
    input.startsWith(branchName),
+
  );
+

+
  if (branch) {
+
    const [revision, path] = [
+
      input.slice(0, branch[0].length),
+
      input.slice(branch[0].length + 1),
+
    ];
+
    return {
+
      revision,
+
      path: path || "/",
+
    };
+
  } else if (isOid(commitPath[0])) {
+
    return {
+
      revision: commitPath[0],
+
      path: commitPath[1] || "/",
+
    };
+
  } else {
+
    return { path: input };
+
  }
+
}
+

function sanitizeQueryString(queryString: string): string {
  return queryString.startsWith("?") ? queryString.substring(1) : queryString;
}
@@ -49,7 +263,6 @@ export function createProjectRoute(
    resource: "projects",
    params: {
      ...activeRoute.params,
-
      line: undefined,
      hash: undefined,
      search: undefined,
      ...projectRouteParams,
@@ -105,7 +318,6 @@ export function resolveProjectRoute(
  }

  if (!content || content === "tree") {
-
    const line = url.href.match(/#L\d+$/)?.pop();
    const hash = url.href.match(/#{1}[^#.]+$/)?.pop();
    return {
      view: { resource: "tree" },
@@ -115,7 +327,6 @@ export function resolveProjectRoute(
      path: undefined,
      revision: undefined,
      search: undefined,
-
      line: line?.substring(1),
      hash: hash?.substring(1),
      route: segments.join("/"),
    };
@@ -202,3 +413,5 @@ export function resolveProjectRoute(

  return null;
}
+

+
export const testExports = { isOid };
modified tests/e2e/historyRouter.spec.ts
@@ -58,6 +58,9 @@ test.describe("project page navigation", () => {
    const projectTreeURL = `${sourceBrowsingUrl}/tree/${aliceMainHead}`;

    await page.goto(projectTreeURL);
+
    await page
+
      .getByRole("progressbar", { name: "Page loading" })
+
      .waitFor({ state: "hidden" });
    await expect(page).toHaveURL(projectTreeURL);

    await page.locator('role=link[name="6 commits"]').click();
modified tests/e2e/project/patches.spec.ts
@@ -80,6 +80,9 @@ test("navigate through revision diffs", async ({ page }) => {
    await secondRevision
      .locator("role=link[name='Compare 0dc373d..5b35def']")
      .click();
+
    await expect(
+
      page.getByRole("link", { name: "Diff 0dc373..5b35de" }),
+
    ).toBeVisible();
    await page.goBack();
  }
  // First revision
modified tests/support/router.ts
@@ -17,13 +17,13 @@ export const expectBackAndForwardNavigationWorks = async (

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

  await page
-
    .locator("role=progressbar[name='Page loading']")
+
    .getByRole("progressbar", { name: "Page loading" })
    .waitFor({ state: "hidden" });
  await expect(page).toHaveURL(currentURL);
};
added tests/unit/projectRouter.test.ts
@@ -0,0 +1,14 @@
+
import { describe, expect, test } from "vitest";
+
import { testExports } from "@app/views/projects/router";
+

+
// Defining the window.origin value, since vitest doesn't provide one.
+
window.origin = "http://localhost:3000";
+

+
describe("isOid", () => {
+
  test.each([
+
    { oid: "a64ae9c6d572e0ad906faa9a4a7a8d43f113278c", expected: true },
+
    { oid: "a64ae9c", expected: false },
+
  ])("isOid $oid => $expected", ({ oid, expected }) => {
+
    expect(testExports.isOid(oid)).toEqual(expected);
+
  });
+
});
modified tests/unit/router.test.ts
@@ -4,15 +4,6 @@ import { testExports } from "@app/lib/router";
// Defining the window.origin value, since vitest doesn't provide one.
window.origin = "http://localhost:3000";

-
describe("isOid", () => {
-
  test.each([
-
    { oid: "a64ae9c6d572e0ad906faa9a4a7a8d43f113278c", expected: true },
-
    { oid: "a64ae9c", expected: false },
-
  ])("isOid $oid => $expected", ({ oid, expected }) => {
-
    expect(testExports.isOid(oid)).toEqual(expected);
-
  });
-
});
-

describe("routeToPath", () => {
  test.each([
    { input: { resource: "home" }, output: "/", description: "Home Route" },
@@ -42,16 +33,25 @@ describe("routeToPath", () => {
});

describe("pathToRoute", () => {
+
  const dummyUrl = "https://localhost";
  test.each([
-
    { input: "", output: null, description: "Empty not found Route" },
    {
-
      input: "/foo/baz/bar",
+
      input: new URL("/foo/baz/bar", dummyUrl),
      output: null,
      description: "Non existant not found route",
    },
-
    { input: "/", output: { resource: "home" }, description: "Home Route" },
    {
-
      input: "/seeds/willow.radicle.garden",
+
      input: new URL("", dummyUrl),
+
      output: { resource: "home" },
+
      description: "Home Route",
+
    },
+
    {
+
      input: new URL("/", dummyUrl),
+
      output: { resource: "home" },
+
      description: "Home Route",
+
    },
+
    {
+
      input: new URL("/seeds/willow.radicle.garden", dummyUrl),
      output: {
        resource: "seeds",
        params: { hostnamePort: "willow.radicle.garden", projectPageIndex: 0 },
@@ -59,7 +59,10 @@ describe("pathToRoute", () => {
      description: "Seed View Route",
    },
    {
-
      input: "/seeds/willow.radicle.garden/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT/",
+
      input: new URL(
+
        "/seeds/willow.radicle.garden/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT/",
+
        dummyUrl,
+
      ),
      output: {
        resource: "projects",
        params: {
@@ -73,13 +76,18 @@ describe("pathToRoute", () => {
      description: "Seed Project Route w trailing slash",
    },
    {
-
      input:
+
      input: new URL(
        "/seeds/willow.radicle.garden/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT/nope",
+
        dummyUrl,
+
      ),
      output: null,
      description: "Seed Project Route w undefined suffix",
    },
    {
-
      input: "/seeds/willow.radicle.garden/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
      input: new URL(
+
        "/seeds/willow.radicle.garden/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
        dummyUrl,
+
      ),
      output: {
        resource: "projects",
        params: {