Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Remove `svelte-routing` and add custom routing
Sebastian Martinez committed 3 years ago
commit f8ddbbf5daf5195c556218d591a1a5a64769175c
parent 955ec7f24023fa116efd5f443982d2fde045d075
57 files changed +1661 -1190
modified cypress/e2e/projectHeader.spec.ts
@@ -53,7 +53,7 @@ describe("project header", () => {
      { fixture: "projectReadme.json" },
    ).as("projectReadme");
    cy.intercept(
-
      "v1/projects/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/commits/cbf5df499ab4f4a908f1756fbe2c236a4530516a",
+
      "v1/projects/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/commit/cbf5df499ab4f4a908f1756fbe2c236a4530516a",
      { fixture: "projectCommit.json" },
    ).as("projectCommit");
  });
@@ -109,7 +109,7 @@ describe("project header", () => {
    ]);
    cy.location().should(location => {
      expect(location.pathname).to.eq(
-
        "/seeds/willow.radicle.garden/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke/tree",
+
        "/seeds/willow.radicle.garden/bright-forest-protocol/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke/tree",
      );
    });
    cy.get(
@@ -130,7 +130,7 @@ describe("project header", () => {
    cy.wait(["@projectTreecbf5df4", "@projectReadme"]);
    cy.location().should(location => {
      expect(location.pathname).to.eq(
-
        "/seeds/willow.radicle.garden/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke/tree/master",
+
        "/seeds/willow.radicle.garden/bright-forest-protocol/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke/tree/master",
      );
    });
    cy.get("div.stat.branch").should("have.text", "master");
@@ -139,7 +139,7 @@ describe("project header", () => {

  it("navigate to commit history", () => {
    cy.get("div.stat.commit-count").should("not.have.class", "active").click();
-
    cy.wait(["@projectTreecbf5df4", "@projectCommits"]);
+
    cy.wait(["@projectCommits"]);
    cy.location().should(location => {
      expect(location.pathname).to.eq(
        "/seeds/willow.radicle.garden/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke/history/master",
@@ -150,7 +150,7 @@ describe("project header", () => {

  it("navigate to issues listing", () => {
    cy.get("div.stat.issue-count").click();
-
    cy.wait(["@projectTreecbf5df4", "@projectIssues"]);
+
    cy.wait(["@projectIssues"]);
    cy.location().should(location => {
      expect(location.pathname).to.eq(
        "/seeds/willow.radicle.garden/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke/issues",
@@ -161,7 +161,7 @@ describe("project header", () => {

  it("navigate to patches listing", () => {
    cy.get("div.stat.patch-count").click();
-
    cy.wait(["@projectTree56e4e02", "@projectPatches"]);
+
    cy.wait(["@projectPatches"]);
    cy.location().should(location => {
      expect(location.pathname).to.eq(
        "/seeds/willow.radicle.garden/rad:git:hnrk8mbpirp7ua7sy66o4t9soasbq4y8uwgoy/remotes/hyndc7nx9keq76p1bkw9831arcndeeu3trwsc7kxt3osmpi6j9oeke/patches",
modified package-lock.json
@@ -27,7 +27,6 @@
        "siwe": "^2.0.5",
        "svelte": "^3.52.0",
        "svelte-preprocess": "^4.10.7",
-
        "svelte-routing": "^1.6.0",
        "util": "^0.12.5"
      },
      "devDependencies": {
@@ -2705,11 +2704,6 @@
        "node": ">=0.10"
      }
    },
-
    "node_modules/dedent-js": {
-
      "version": "1.0.1",
-
      "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz",
-
      "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ=="
-
    },
    "node_modules/deep-eql": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
@@ -5130,14 +5124,6 @@
        "get-func-name": "^2.0.0"
      }
    },
-
    "node_modules/lower-case": {
-
      "version": "2.0.2",
-
      "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
-
      "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
-
      "dependencies": {
-
        "tslib": "^2.0.3"
-
      }
-
    },
    "node_modules/lru-cache": {
      "version": "7.14.1",
      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz",
@@ -5335,15 +5321,6 @@
      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
      "dev": true
    },
-
    "node_modules/no-case": {
-
      "version": "3.0.4",
-
      "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
-
      "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
-
      "dependencies": {
-
        "lower-case": "^2.0.2",
-
        "tslib": "^2.0.3"
-
      }
-
    },
    "node_modules/node-addon-api": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz",
@@ -5546,15 +5523,6 @@
      "optional": true,
      "peer": true
    },
-
    "node_modules/pascal-case": {
-
      "version": "3.1.2",
-
      "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
-
      "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
-
      "dependencies": {
-
        "no-case": "^3.0.4",
-
        "tslib": "^2.0.3"
-
      }
-
    },
    "node_modules/path-exists": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6607,30 +6575,6 @@
        "sourcemap-codec": "^1.4.8"
      }
    },
-
    "node_modules/svelte-routing": {
-
      "version": "1.6.0",
-
      "resolved": "https://registry.npmjs.org/svelte-routing/-/svelte-routing-1.6.0.tgz",
-
      "integrity": "sha512-+DbrSGttLA6lan7oWFz1MjyGabdn3tPRqn8Osyc471ut2UgCrzM5x1qViNMc2gahOP6fKbKK1aNtZMJEQP2vHQ==",
-
      "dependencies": {
-
        "svelte2tsx": "^0.1.157"
-
      },
-
      "peerDependencies": {
-
        "svelte": "^3.20.x"
-
      }
-
    },
-
    "node_modules/svelte2tsx": {
-
      "version": "0.1.193",
-
      "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.1.193.tgz",
-
      "integrity": "sha512-vzy4YQNYDnoqp2iZPnJy7kpPAY6y121L0HKrSBjU/IWW7DQ6T7RMJed2VVHFmVYm0zAGYMDl9urPc6R4DDUyhg==",
-
      "dependencies": {
-
        "dedent-js": "^1.0.1",
-
        "pascal-case": "^3.1.1"
-
      },
-
      "peerDependencies": {
-
        "svelte": "^3.24",
-
        "typescript": "^4.1.2"
-
      }
-
    },
    "node_modules/sync-request": {
      "version": "6.1.0",
      "resolved": "https://registry.npmjs.org/sync-request/-/sync-request-6.1.0.tgz",
@@ -6780,7 +6724,8 @@
    "node_modules/tslib": {
      "version": "2.4.0",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
-
      "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+
      "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
+
      "dev": true
    },
    "node_modules/tsutils": {
      "version": "3.21.0",
@@ -6880,6 +6825,7 @@
      "version": "4.8.4",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
      "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+
      "devOptional": true,
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
@@ -9158,11 +9104,6 @@
      "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
      "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og=="
    },
-
    "dedent-js": {
-
      "version": "1.0.1",
-
      "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz",
-
      "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ=="
-
    },
    "deep-eql": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
@@ -10892,14 +10833,6 @@
        "get-func-name": "^2.0.0"
      }
    },
-
    "lower-case": {
-
      "version": "2.0.2",
-
      "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
-
      "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
-
      "requires": {
-
        "tslib": "^2.0.3"
-
      }
-
    },
    "lru-cache": {
      "version": "7.14.1",
      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz",
@@ -11049,15 +10982,6 @@
      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
      "dev": true
    },
-
    "no-case": {
-
      "version": "3.0.4",
-
      "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
-
      "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
-
      "requires": {
-
        "lower-case": "^2.0.2",
-
        "tslib": "^2.0.3"
-
      }
-
    },
    "node-addon-api": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz",
@@ -11196,15 +11120,6 @@
      "optional": true,
      "peer": true
    },
-
    "pascal-case": {
-
      "version": "3.1.2",
-
      "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
-
      "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
-
      "requires": {
-
        "no-case": "^3.0.4",
-
        "tslib": "^2.0.3"
-
      }
-
    },
    "path-exists": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -11923,23 +11838,6 @@
        }
      }
    },
-
    "svelte-routing": {
-
      "version": "1.6.0",
-
      "resolved": "https://registry.npmjs.org/svelte-routing/-/svelte-routing-1.6.0.tgz",
-
      "integrity": "sha512-+DbrSGttLA6lan7oWFz1MjyGabdn3tPRqn8Osyc471ut2UgCrzM5x1qViNMc2gahOP6fKbKK1aNtZMJEQP2vHQ==",
-
      "requires": {
-
        "svelte2tsx": "^0.1.157"
-
      }
-
    },
-
    "svelte2tsx": {
-
      "version": "0.1.193",
-
      "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.1.193.tgz",
-
      "integrity": "sha512-vzy4YQNYDnoqp2iZPnJy7kpPAY6y121L0HKrSBjU/IWW7DQ6T7RMJed2VVHFmVYm0zAGYMDl9urPc6R4DDUyhg==",
-
      "requires": {
-
        "dedent-js": "^1.0.1",
-
        "pascal-case": "^3.1.1"
-
      }
-
    },
    "sync-request": {
      "version": "6.1.0",
      "resolved": "https://registry.npmjs.org/sync-request/-/sync-request-6.1.0.tgz",
@@ -12070,7 +11968,8 @@
    "tslib": {
      "version": "2.4.0",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
-
      "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+
      "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
+
      "dev": true
    },
    "tsutils": {
      "version": "3.21.0",
@@ -12150,7 +12049,8 @@
    "typescript": {
      "version": "4.8.4",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
-
      "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ=="
+
      "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+
      "devOptional": true
    },
    "unbox-primitive": {
      "version": "1.0.2",
modified package.json
@@ -55,7 +55,6 @@
    "siwe": "^2.0.5",
    "svelte": "^3.52.0",
    "svelte-preprocess": "^4.10.7",
-
    "svelte-routing": "^1.6.0",
    "util": "^0.12.5"
  }
}
modified src/Address.svelte
@@ -1,18 +1,19 @@
<script lang="ts">
+
  import type { Wallet } from "@app/wallet";
+

  import { onMount } from "svelte";
-
  import { link } from "svelte-routing";
  import { ethers } from "ethers";
  import {
+
    AddressType,
    explorerLink,
-
    identifyAddress,
    formatAddress,
-
    AddressType,
+
    identifyAddress,
    parseEnsLabel,
  } from "@app/utils";
  import { Profile, ProfileType } from "@app/profile";
  import Avatar from "@app/Avatar.svelte";
  import Badge from "@app/Badge.svelte";
-
  import type { Wallet } from "@app/wallet";
+
  import Link from "@app/router/Link.svelte";

  export let address: string;
  export let wallet: Wallet;
@@ -28,7 +29,7 @@

  let addressType: AddressType | null = null;

-
  const nameOrAddress = profile?.ens?.name || address;
+
  const addressOrName = profile?.ens?.name || address;

  onMount(async () => {
    if (!profile) {
@@ -95,19 +96,31 @@
  {/if}
  <div class="wrapper">
    {#if addressType === AddressType.Org}
-
      <a use:link href={`/${nameOrAddress}`}>{addressLabel}</a>
+
      <Link
+
        route={{
+
          resource: "profile",
+
          params: { addressOrName: addressOrName },
+
        }}>
+
        {addressLabel}
+
      </Link>
      {#if !noBadge}
        <Badge variant="foreground">org</Badge>
      {/if}
    {:else if addressType === AddressType.Contract}
-
      <a use:link href={`/${address}`}>
+
      <Link route={{ resource: "profile", params: { addressOrName: address } }}>
        {addressLabel}
-
      </a>
+
      </Link>
      {#if !noBadge}
        <Badge variant="foreground">contract</Badge>
      {/if}
    {:else if addressType === AddressType.EOA}
-
      <a use:link href={`/${nameOrAddress}`}>{addressLabel}</a>
+
      <Link
+
        route={{
+
          resource: "profile",
+
          params: { addressOrName: addressOrName },
+
        }}>
+
        {addressLabel}
+
      </Link>
    {:else}
      <!-- While we're waiting to find out what address type it is -->
      <a href={explorerLink(address, wallet)} target="_blank" rel="noreferrer">
modified src/App.svelte
@@ -1,19 +1,23 @@
<script lang="ts">
-
  import { Router, Route } from "svelte-routing";
-
  import { getWallet } from "@app/wallet";
  import { Connection, state, session } from "@app/session";
+
  import { getWallet } from "@app/wallet";
+
  import { initialize, activeRouteStore } from "@app/router";
+
  import { unreachable } from "@app/utils";

-
  import Home from "@app/base/home/Index.svelte";
-
  import Vesting from "@app/base/vesting/Index.svelte";
-
  import Registrations from "@app/base/registrations/Routes.svelte";
-
  import Seeds from "@app/base/seeds/Routes.svelte";
+
  import ColorPalette from "@app/ColorPalette.svelte";
  import Faucet from "@app/base/faucet/Routes.svelte";
-
  import Projects from "@app/base/projects/Routes.svelte";
-
  import Profile from "@app/Profile.svelte";
  import Header from "@app/Header.svelte";
+
  import Home from "@app/base/home/Index.svelte";
  import Loading from "@app/Loading.svelte";
  import Modal from "@app/Modal.svelte";
-
  import ColorPalette from "./ColorPalette.svelte";
+
  import NotFound from "@app/NotFound.svelte";
+
  import Profile from "@app/Profile.svelte";
+
  import Projects from "@app/base/projects/View.svelte";
+
  import Registrations from "@app/base/registrations/Routes.svelte";
+
  import Seeds from "@app/base/seeds/Routes.svelte";
+
  import Vesting from "@app/base/vesting/Index.svelte";
+

+
  initialize();

  const loadWallet = getWallet().then(async wallet => {
    if ($state.connection === Connection.Connected) {
@@ -82,21 +86,33 @@
    <ColorPalette />
    <Header session={$session} {wallet} />
    <div class="wrapper">
-
      <Router>
-
        <Route path="/">
-
          <Home />
-
        </Route>
-
        <Route path="vesting">
-
          <Vesting {wallet} session={$session} />
-
        </Route>
-
        <Registrations {wallet} session={$session} />
-
        <Seeds {wallet} session={$session} />
-
        <Faucet {wallet} />
-
        <Route path="/:addressOrName" let:params>
-
          <Profile addressOrName={params.addressOrName} {wallet} />
-
        </Route>
-
        <Projects {wallet} />
-
      </Router>
+
      {#if $activeRouteStore.resource === "home"}
+
        <Home />
+
      {:else if $activeRouteStore.resource === "faucet"}
+
        <Faucet {wallet} activeRoute={$activeRouteStore} />
+
      {:else if $activeRouteStore.resource === "seeds"}
+
        <Seeds
+
          {wallet}
+
          session={$session}
+
          host={$activeRouteStore.params.host} />
+
      {:else if $activeRouteStore.resource === "registrations"}
+
        <Registrations
+
          {wallet}
+
          session={$session}
+
          activeRoute={$activeRouteStore} />
+
      {:else if $activeRouteStore.resource === "vesting"}
+
        <Vesting {wallet} session={$session} />
+
      {:else if $activeRouteStore.resource === "projects"}
+
        <Projects {wallet} activeRoute={$activeRouteStore} />
+
      {:else if $activeRouteStore.resource === "profile"}
+
        <Profile
+
          addressOrName={$activeRouteStore.params.addressOrName}
+
          {wallet} />
+
      {:else if $activeRouteStore.resource === "404"}
+
        <NotFound title="404" subtitle="Nothing here" />
+
      {:else}
+
        {unreachable($activeRouteStore)}
+
      {/if}
    </div>
  {:catch err}
    <div class="wrapper">
modified src/Avatar.svelte
@@ -51,11 +51,11 @@
  }
</style>

-
<!-- svelte-ignore a11y-missing-attribute -->
<img
  {title}
  src={source}
  class="avatar"
+
  alt="avatar"
  on:error={handleMissingFile}
  class:inline
  class:grayscale />
modified src/Form.svelte
@@ -47,7 +47,6 @@
  import type { Wallet } from "@app/wallet";

  import cloneDeep from "lodash/cloneDeep";
-
  import { link } from "svelte-routing";
  import { createEventDispatcher } from "svelte";
  import { marked } from "marked";
  import {
@@ -230,7 +229,7 @@
              </div>
            {:else if field.url}
              <div>
-
                <a use:link href={field.url} class="txt-link">{field.value}</a>
+
                <a href={field.url} class="txt-link">{field.value}</a>
              </div>
            {:else if field.validate === "id"}
              <div class="layout-mobile">
modified src/Header.svelte
@@ -3,13 +3,12 @@
  import type { ProjectsAndProfiles } from "@app/Search.svelte";
  import type { Session } from "@app/session";

-
  import { link } from "svelte-routing";
-

  import Avatar from "@app/Avatar.svelte";
  import Button from "@app/Button.svelte";
  import Connect from "@app/Connect.svelte";
  import Floating from "@app/Floating.svelte";
  import Icon from "@app/Icon.svelte";
+
  import Link from "@app/router/Link.svelte";
  import Loading from "@app/Loading.svelte";
  import Logo from "@app/Logo.svelte";
  import Search from "@app/Search.svelte";
@@ -127,18 +126,18 @@
    right: 1.5rem;
    top: 5rem;
  }
-
  .modal a {
+
  .modal-register {
    color: var(--color-foreground-6);
    padding-left: 0.5rem;
  }
-
  .modal a:hover {
+
  .modal-register:hover {
    color: var(--color-foreground);
  }
</style>

<header>
  <div class="left">
-
    <a use:link href="/" class="logo"><Logo /></a>
+
    <Link route={{ resource: "home" }}><span class="logo"><Logo /></span></Link>
    <div class="search">
      <Search
        {wallet}
@@ -162,15 +161,25 @@

  <div class="right">
    {#if wallet && wallet.network.name === "goerli"}
-
      <a use:link href="/faucet">
+
      <Link
+
        route={{
+
          resource: "faucet",
+
          params: { view: { resource: "form" } },
+
        }}>
        <span class="network">Goerli</span>
-
      </a>
+
      </Link>
    {:else if wallet && wallet.network.name === "homestead"}
      <!-- Don't show anything -->
    {:else}
      <span class="network unavailable">No Network</span>
    {/if}
-
    <a use:link class="register" href="/registrations">Register</a>
+
    <Link
+
      route={{
+
        resource: "registrations",
+
        params: { view: { resource: "validateName" } },
+
      }}>
+
      <span class="register">Register</span>
+
    </Link>

    {#if address}
      <span class="balance">
@@ -229,14 +238,16 @@
                  ({ query, results } = e.detail);
                }} />
            </div>
-
            <a
-
              use:link
+
            <Link
+
              route={{
+
                resource: "registrations",
+
                params: { view: { resource: "validateName" } },
+
              }}
              on:click={() => {
                closeFocused();
-
              }}
-
              href="/registrations">
-
              Register
-
            </a>
+
              }}>
+
              <span class="modal-register">Register</span>
+
            </Link>
          </div>
        </svelte:fragment>
      </Floating>
modified src/Markdown.svelte
@@ -7,11 +7,13 @@
    markdownExtensions as extensions,
    getImageMime,
    isUrl,
+
    scrollIntoView,
  } from "@app/utils";
  import dompurify from "dompurify";

  export let content: string;
  export let getImage: (path: string) => Promise<proj.Blob>;
+
  export let hash: string | null = null;
  export let doc = matter(content);

  const frontMatter = Object.entries(doc.data);
@@ -32,6 +34,8 @@
      }
    }

+
    if (hash) scrollIntoView(hash);
+

    // Iterate over all images, and fetch their data from the API, then
    // replace the source with a Data-URL. We do this due to the absence
    // of a static file server.
modified src/NotFound.svelte
@@ -1,11 +1,11 @@
<script lang="ts">
+
  import * as router from "@app/router";
+

  import Button from "@app/Button.svelte";
  import Modal from "@app/Modal.svelte";

  export let title = "";
  export let subtitle = "";
-

-
  const back = () => window.history.back();
</script>

<Modal subtle>
@@ -17,6 +17,6 @@
    <p>{subtitle}</p>
  </span>
  <span slot="actions">
-
    <Button variant="foreground" on:click={back}>Back</Button>
+
    <Button variant="foreground" on:click={router.pop}>Back</Button>
  </span>
</Modal>
modified src/Profile.svelte
@@ -3,25 +3,26 @@
  import type { Wallet } from "@app/wallet";
  import type { Seed, Stats } from "@app/base/seeds/Seed";
  import type { ProjectInfo } from "@app/project";
+
  import * as utils from "@app/utils";
  import Address from "@app/Address.svelte";
+
  import Async from "@app/Async.svelte";
  import Avatar from "@app/Avatar.svelte";
+
  import Badge from "@app/Badge.svelte";
+
  import Button from "@app/Button.svelte";
+
  import ErrorModal from "@app/ErrorModal.svelte";
  import Icon from "@app/Icon.svelte";
-
  import SetName from "@app/ens/SetName.svelte";
-
  import SeedAddress from "@app/SeedAddress.svelte";
-
  import { Profile, ProfileType } from "@app/profile";
+
  import Link from "@app/router/Link.svelte";
  import Loading from "@app/Loading.svelte";
-
  import * as utils from "@app/utils";
-
  import { session } from "@app/session";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import { User } from "@app/base/users/User";
-
  import Projects from "@app/base/seeds/View/Projects.svelte";
-
  import { MissingReverseRecord, NotFoundError } from "@app/error";
  import NotFound from "@app/NotFound.svelte";
+
  import Projects from "@app/base/seeds/View/Projects.svelte";
  import RadicleUrn from "@app/RadicleUrn.svelte";
-
  import Async from "@app/Async.svelte";
-
  import Badge from "@app/Badge.svelte";
-
  import Button from "@app/Button.svelte";
+
  import SeedAddress from "@app/SeedAddress.svelte";
+
  import SetName from "@app/ens/SetName.svelte";
+
  import { MissingReverseRecord, NotFoundError } from "@app/error";
+
  import { Profile, ProfileType } from "@app/profile";
+
  import { User } from "@app/base/users/User";
  import { defaultLinkPort } from "@app/base/seeds/Seed";
+
  import { session } from "@app/session";

  export let wallet: Wallet;
  export let addressOrName: string;
@@ -216,9 +217,18 @@
        {#if utils.isAddressEqual(profile.address, profile.org.address)}
          <div class="overflow-text">
            {#if profile.name && profile.ens}
-
              <a href={`/registrations/${profile.ens.name}`} class="txt-link">
-
                {profile.name}
-
              </a>
+
              <Link
+
                route={{
+
                  resource: "registrations",
+
                  params: {
+
                    view: {
+
                      resource: "view",
+
                      params: { nameOrDomain: profile.ens.name, retry: false },
+
                    },
+
                  },
+
                }}>
+
                <span class="txt-link">{profile.name}</span>
+
              </Link>
            {:else}
              <span class="txt-missing">Not set</span>
            {/if}
@@ -228,9 +238,18 @@
        <!-- User Profile -->
        <div>
          {#if profile.name && profile.ens}
-
            <a href={`/registrations/${profile.ens.name}`} class="txt-link">
-
              {profile.name}
-
            </a>
+
            <Link
+
              route={{
+
                resource: "registrations",
+
                params: {
+
                  view: {
+
                    resource: "view",
+
                    params: { nameOrDomain: profile.ens.name, retry: false },
+
                  },
+
                },
+
              }}>
+
              <span class="txt-link">{profile.name}</span>
+
            </Link>
          {:else}
            <span class="txt-missing">Not set</span>
          {/if}
deleted src/Redirect.svelte
@@ -1,10 +0,0 @@
-
<script>
-
  import { onMount } from "svelte";
-
  import { navigate } from "svelte-routing";
-

-
  export let to;
-

-
  onMount(() => {
-
    navigate(to, { replace: true });
-
  });
-
</script>
modified src/Search.svelte
@@ -127,7 +127,7 @@

  import debounce from "lodash/debounce";
  import { createEventDispatcher } from "svelte";
-
  import { navigate } from "svelte-routing";
+
  import * as router from "@app/router";

  import TextInput from "@app/TextInput.svelte";
  import { unreachable } from "@app/utils";
@@ -165,12 +165,24 @@
      shake();
    } else if (searchResult.type === "singleProfile") {
      input = "";
-
      navigate(`/${searchResult.id}`, { replace: true });
+
      router.replace({
+
        resource: "profile",
+
        params: { addressOrName: searchResult.id },
+
      });
      dispatch("finished");
    } else if (searchResult.type === "singleProject") {
      input = "";
-
      navigate(`/seeds/${searchResult.seedHost}/${searchResult.id}`, {
-
        replace: true,
+
      router.replace({
+
        resource: "projects",
+
        params: {
+
          view: { resource: "tree" },
+
          urn: searchResult.id,
+
          peer: undefined,
+
          profile: undefined,
+
          seed: searchResult.seedHost,
+
          hash: undefined,
+
          search: undefined,
+
        },
      });
      dispatch("finished");
    } else if (searchResult.type === "projectsAndProfiles") {
modified src/SeedAddress.spec.ts
@@ -16,9 +16,7 @@ describe("SeedAddress", () => {
      },
    });
    cy.get("span.seed-icon").should("have.text", "🌱");
-
    cy.contains("seed.cloudhead.io")
-
      .should("have.attr", "href", "/seeds/seed.cloudhead.io:8777")
-
      .should("be.visible");
+
    cy.contains("seed.cloudhead.io").should("be.visible");
  });

  it("shows the full seed id", () => {
modified src/SeedAddress.svelte
@@ -2,14 +2,15 @@
  import { formatSeedAddress, formatSeedId, formatSeedHost } from "@app/utils";
  import type { Seed } from "@app/base/seeds/Seed";
  import Clipboard from "@app/Clipboard.svelte";
+
  import Link from "@app/router/Link.svelte";

  export let seed: Seed;
  export let port: number;
  export let full = false;

-
  const linkToSeed = seed.api.port
-
    ? `/seeds/${seed.api.host}:${seed.api.port}`
-
    : `/seeds/${formatSeedHost(seed.api.host)}`;
+
  const seedHost = seed.api.port
+
    ? `${seed.api.host}:${seed.api.port}`
+
    : `${formatSeedHost(seed.api.host)}`;
</script>

<style>
@@ -39,16 +40,24 @@
    <span class="seed-icon">{seed.emoji}</span>
    {#if full}
      <span>
-
        <a href={linkToSeed} class="txt-link">
-
          {formatSeedId(seed.id)}@{seed.host}
-
        </a>
+
        <Link
+
          route={{
+
            resource: "seeds",
+
            params: { host: formatSeedHost(seedHost) },
+
          }}>
+
          <span class="txt-link">{formatSeedId(seed.id)}@{seed.host}</span>
+
        </Link>
      </span>
      <span class="txt-faded">:{port}</span>
    {:else}
      <span>
-
        <a href={linkToSeed} class="txt-link">
-
          {formatSeedHost(seed.host)}
-
        </a>
+
        <Link
+
          route={{
+
            resource: "seeds",
+
            params: { host: seedHost },
+
          }}>
+
          <span class="txt-link">{formatSeedHost(seedHost)}</span>
+
        </Link>
      </span>
    {/if}
  </div>
modified src/SeedDropdown.svelte
@@ -1,8 +1,9 @@
<script lang="ts">
-
  import { navigate } from "svelte-routing";
-
  import { Seed } from "@app/base/seeds/Seed";
-
  import Dropdown from "@app/Dropdown.svelte";
  import type { SeedSession } from "@app/siwe";
+

+
  import * as router from "@app/router";
+
  import Dropdown from "@app/Dropdown.svelte";
+
  import { Seed } from "@app/base/seeds/Seed";
  import { closeFocused } from "@app/Floating.svelte";

  export let seeds: { [key: string]: SeedSession };
@@ -31,6 +32,6 @@
    selected={null}
    on:select={item => {
      closeFocused();
-
      navigate(`/seeds/${item.detail}`);
+
      router.push({ resource: "seeds", params: { host: item.detail } });
    }} />
{/await}
added src/base/faucet/Form.svelte
@@ -0,0 +1,176 @@
+
<script lang="ts">
+
  import type { Wallet } from "@app/wallet";
+

+
  import { formatEther } from "@ethersproject/units";
+

+
  import * as router from "@app/router";
+
  import Button from "@app/Button.svelte";
+
  import TextInput from "@app/TextInput.svelte";
+
  import {
+
    calculateTimeLock,
+
    getMaxWithdrawAmount,
+
    lastWithdrawalByUser,
+
  } from "./lib";
+
  import { session } from "@app/session";
+
  import { setOpenGraphMetaTag, toWei, capitalize } from "@app/utils";
+

+
  export let wallet: Wallet;
+

+
  let amount: string = "";
+
  let loading: boolean = false;
+
  let validationMessage: string | undefined = undefined;
+
  let valid: boolean = false;
+

+
  setOpenGraphMetaTag([
+
    { prop: "og:title", content: "Radicle Faucet" },
+
    { prop: "og:description", content: "Goerli Testnet Faucet" },
+
    { prop: "og:url", content: window.location.href },
+
  ]);
+

+
  async function withdraw(amount: string) {
+
    if (!valid || !$session) {
+
      return;
+
    }
+

+
    loading = true;
+
    try {
+
      const currentTime = new Date().getTime();
+
      const timelock = await calculateTimeLock(amount, $session.signer, wallet);
+
      const lastWithdrawal = await lastWithdrawalByUser(
+
        $session.signer,
+
        wallet,
+
      );
+
      const maxWithdrawAmount = await getMaxWithdrawAmount(
+
        $session.signer,
+
        wallet,
+
      );
+

+
      if (toWei(amount).gt(maxWithdrawAmount)) {
+
        validationMessage = `Reduce amount, max withdrawal is ${formatEther(
+
          maxWithdrawAmount,
+
        )}.`;
+
        return;
+
      }
+

+
      // Converting a 10 digit to 13 digit timestamp by multiplying by 1000
+
      // since JS doesn't display a correct Date string when passing a 10 digit
+
      // timestamp.
+
      const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
+
      if (nextAvailableWithdraw.gt(currentTime)) {
+
        validationMessage = `Not ready to withdraw, return after ${new Date(
+
          nextAvailableWithdraw.toNumber(),
+
        ).toLocaleString("en-GB")}`;
+
        return;
+
      }
+

+
      router.push({
+
        resource: "faucet",
+
        params: { view: { resource: "withdraw", params: { amount } } },
+
      });
+
    } catch (error) {
+
      validationMessage = "There was an error, check the dev console.";
+
      console.error(error);
+
    } finally {
+
      loading = false;
+
    }
+
  }
+

+
  function validate(amount: string) {
+
    if (amount === "") {
+
      return { valid: false };
+
    }
+

+
    if (isNaN(Number(amount)) || Number(amount) <= 0) {
+
      return {
+
        valid: false,
+
        validationMessage: "Please enter a positive number.",
+
      };
+
    }
+

+
    return { valid: true };
+
  }
+

+
  $: ({ valid, validationMessage } = validate(amount));
+
</script>
+

+
<style>
+
  main {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 28rem;
+
  }
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
+
  }
+
  .subtitle {
+
    color: var(--color-secondary);
+
  }
+
  .form {
+
    display: flex;
+
    gap: 1rem;
+
  }
+
</style>
+

+
<svelte:head>
+
  <title>Radicle &ndash; Faucet</title>
+
</svelte:head>
+

+
<main>
+
  <div class="title">
+
    Obtain RAD tokens on <span class="txt-bold">
+
      {capitalize(wallet.network.name)}
+
    </span>
+
  </div>
+

+
  {#if wallet.network.name === "homestead"}
+
    <div class="subtitle">
+
      To get RAD tokens on <span class="txt-bold">
+
        {capitalize(wallet.network.name)},
+
      </span>
+
      please
+
      <br />
+
      check
+
      <a
+
        href="https://docs.radicle.xyz/get-involved/obtain-rad"
+
        class="txt-link">
+
        popular exchanges
+
      </a>
+
      &#8203;.
+
    </div>
+
  {:else if !$session}
+
    <div class="subtitle">
+
      To get RAD tokens on <span class="txt-bold">
+
        {capitalize(wallet.network.name)}
+
      </span>
+
      &#8203;,
+
      <br />
+
      please connect your wallet.
+
    </div>
+
  {:else}
+
    <div class="form">
+
      <TextInput
+
        autofocus
+
        placeholder="Enter amount to withdraw"
+
        {validationMessage}
+
        on:submit={() => {
+
          withdraw(amount);
+
        }}
+
        bind:value={amount}
+
        {valid}
+
        {loading} />
+

+
      <Button
+
        variant="primary"
+
        on:click={() => withdraw(amount)}
+
        disabled={!valid || loading}>
+
        Withdraw
+
      </Button>
+
    </div>
+
  {/if}
+
</main>
deleted src/base/faucet/Index.svelte
@@ -1,172 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-

-
  import { session } from "@app/session";
-
  import { setOpenGraphMetaTag, toWei, capitalize } from "@app/utils";
-
  import { formatEther } from "@ethersproject/units";
-
  import { navigate } from "svelte-routing";
-
  import {
-
    calculateTimeLock,
-
    getMaxWithdrawAmount,
-
    lastWithdrawalByUser,
-
  } from "./lib";
-
  import Button from "@app/Button.svelte";
-
  import TextInput from "@app/TextInput.svelte";
-

-
  export let wallet: Wallet;
-

-
  let amount: string = "";
-
  let loading: boolean = false;
-
  let validationMessage: string | undefined = undefined;
-
  let valid: boolean = false;
-

-
  setOpenGraphMetaTag([
-
    { prop: "og:title", content: "Radicle Faucet" },
-
    { prop: "og:description", content: "Goerli Testnet Faucet" },
-
    { prop: "og:url", content: window.location.href },
-
  ]);
-

-
  async function withdraw(amount: string) {
-
    if (!valid || !$session) {
-
      return;
-
    }
-

-
    loading = true;
-
    try {
-
      const currentTime = new Date().getTime();
-
      const timelock = await calculateTimeLock(amount, $session.signer, wallet);
-
      const lastWithdrawal = await lastWithdrawalByUser(
-
        $session.signer,
-
        wallet,
-
      );
-
      const maxWithdrawAmount = await getMaxWithdrawAmount(
-
        $session.signer,
-
        wallet,
-
      );
-

-
      if (toWei(amount).gt(maxWithdrawAmount)) {
-
        validationMessage = `Reduce amount, max withdrawal is ${formatEther(
-
          maxWithdrawAmount,
-
        )}.`;
-
        return;
-
      }
-

-
      // Converting a 10 digit to 13 digit timestamp by multiplying by 1000
-
      // since JS doesn't display a correct Date string when passing a 10 digit
-
      // timestamp.
-
      const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
-
      if (nextAvailableWithdraw.gt(currentTime)) {
-
        validationMessage = `Not ready to withdraw, return after ${new Date(
-
          nextAvailableWithdraw.toNumber(),
-
        ).toLocaleString("en-GB")}`;
-
        return;
-
      }
-

-
      navigate("/faucet/withdraw", { state: { amount } });
-
    } catch (error) {
-
      validationMessage = "There was an error, check the dev console.";
-
      console.error(error);
-
    } finally {
-
      loading = false;
-
    }
-
  }
-

-
  function validate(amount: string) {
-
    if (amount === "") {
-
      return { valid: false };
-
    }
-

-
    if (isNaN(Number(amount)) || Number(amount) <= 0) {
-
      return {
-
        valid: false,
-
        validationMessage: "Please enter a positive number.",
-
      };
-
    }
-

-
    return { valid: true };
-
  }
-

-
  $: ({ valid, validationMessage } = validate(amount));
-
</script>
-

-
<style>
-
  main {
-
    display: flex;
-
    flex-direction: column;
-
    gap: 1.5rem;
-
    height: 100%;
-
    justify-content: center;
-
    padding-bottom: 24vh;
-
    padding-top: 5rem;
-
    width: 28rem;
-
  }
-
  .title {
-
    color: var(--color-secondary);
-
    font-size: var(--font-size-medium);
-
  }
-
  .subtitle {
-
    color: var(--color-secondary);
-
  }
-
  .form {
-
    display: flex;
-
    gap: 1rem;
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>Radicle &ndash; Faucet</title>
-
</svelte:head>
-

-
<main>
-
  <div class="title">
-
    Obtain RAD tokens on <span class="txt-bold">
-
      {capitalize(wallet.network.name)}
-
    </span>
-
  </div>
-

-
  {#if wallet.network.name === "homestead"}
-
    <div class="subtitle">
-
      To get RAD tokens on <span class="txt-bold">
-
        {capitalize(wallet.network.name)},
-
      </span>
-
      please
-
      <br />
-
      check
-
      <a
-
        href="https://docs.radicle.xyz/get-involved/obtain-rad"
-
        class="txt-link">
-
        popular exchanges
-
      </a>
-
      &#8203;.
-
    </div>
-
  {:else if !$session}
-
    <div class="subtitle">
-
      To get RAD tokens on <span class="txt-bold">
-
        {capitalize(wallet.network.name)}
-
      </span>
-
      &#8203;,
-
      <br />
-
      please connect your wallet.
-
    </div>
-
  {:else}
-
    <div class="form">
-
      <TextInput
-
        autofocus
-
        placeholder="Enter amount to withdraw"
-
        {validationMessage}
-
        on:submit={() => {
-
          withdraw(amount);
-
        }}
-
        bind:value={amount}
-
        {valid}
-
        {loading} />
-

-
      <Button
-
        variant="primary"
-
        on:click={() => withdraw(amount)}
-
        disabled={!valid || loading}>
-
        Withdraw
-
      </Button>
-
    </div>
-
  {/if}
-
</main>
modified src/base/faucet/Routes.svelte
@@ -1,16 +1,16 @@
<script lang="ts">
-
  import { Route } from "svelte-routing";
-
  import Index from "@app/base/faucet/Index.svelte";
  import type { Wallet } from "@app/wallet";
-
  import Withdraw from "./Withdraw.svelte";
+
  import type { FaucetRoute } from "@app/router/definitions";

+
  import Form from "@app/base/faucet/Form.svelte";
+
  import Withdraw from "@app/base/faucet/Withdraw.svelte";
+

+
  export let activeRoute: FaucetRoute;
  export let wallet: Wallet;
</script>

-
<Route path="faucet">
-
  <Index {wallet} />
-
</Route>
-

-
<Route path="faucet/withdraw">
-
  <Withdraw {wallet} />
-
</Route>
+
{#if activeRoute.params.view.resource === "form"}
+
  <Form {wallet} />
+
{:else if activeRoute.params.view.resource === "withdraw"}
+
  <Withdraw {wallet} amount={activeRoute.params.view.params.amount} />
+
{/if}
modified src/base/faucet/Withdraw.svelte
@@ -1,30 +1,37 @@
<script lang="ts">
-
  import { onMount } from "svelte";
-
  import { navigate } from "svelte-routing";
+
  import type { State } from "@app/utils";
  import type { Wallet } from "@app/wallet";
+

+
  import { onMount } from "svelte";
+

+
  import * as router from "@app/router";
+
  import Button from "@app/Button.svelte";
+
  import ErrorModal from "@app/ErrorModal.svelte";
  import Loading from "@app/Loading.svelte";
  import Modal from "@app/Modal.svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import type { State } from "@app/utils";
  import { Status } from "@app/utils";
-
  import { withdraw } from "./lib";
  import { session } from "@app/session";
-
  import Button from "@app/Button.svelte";
+
  import { withdraw } from "./lib";

  export let wallet: Wallet;
+
  export let amount: string | null;

  let error: Error;
-
  const amount: string = window.history.state.amount;
  let state: State = {
    status: Status.Failed,
    error: "Error withdrawing, something happened.",
  };
  $: requester = $session && $session.address;

-
  const back = () => navigate(`/faucet`);
+
  function back() {
+
    router.push({ resource: "faucet", params: { view: { resource: "form" } } });
+
  }

  onMount(async () => {
    try {
+
      if (!amount) {
+
        throw new Error("You must supply the withdrawable amount.");
+
      }
      if ($session) {
        state.status = Status.Signing;
        const tx = await withdraw(amount, $session.signer, wallet);
modified src/base/home/Index.svelte
@@ -1,14 +1,14 @@
<script lang="ts">
-
  import { navigate } from "svelte-routing";
+
  import type { Host } from "@app/api";
+
  import type { ProjectInfo } from "@app/project";
+

+
  import * as router from "@app/router";
  import Loading from "@app/Loading.svelte";
+
  import Message from "@app/Message.svelte";
  import Widget from "@app/base/projects/Widget.svelte";
-
  import type { ProjectInfo } from "@app/project";
+
  import config from "@app/config.json";
  import { Project } from "@app/project";
-
  import type { Host } from "@app/api";
-
  import * as proj from "@app/project";
-
  import Message from "@app/Message.svelte";
  import { setOpenGraphMetaTag } from "@app/utils";
-
  import config from "@app/config.json";

  setOpenGraphMetaTag([
    { prop: "og:title", content: "Radicle Interface" },
@@ -26,16 +26,19 @@
        )
      : Promise.resolve([]);

-
  const onClick = (project: ProjectInfo, seed: Host) => {
-
    navigate(
-
      proj.path({
+
  function onClick(project: ProjectInfo, seed: Host) {
+
    router.push({
+
      resource: "projects",
+
      params: {
+
        view: { resource: "tree" },
        urn: project.urn,
+
        peer: undefined,
        seed: seed.host,
-
        profile: null,
-
        revision: project.head,
-
      }),
-
    );
-
  };
+
        profile: undefined,
+
        revision: project.head ?? undefined,
+
      },
+
    });
+
  }
</script>

<style>
modified src/base/projects/Blob.svelte
@@ -1,24 +1,33 @@
<script lang="ts">
  import type { Blob } from "@app/project";
+

  import { onMount } from "svelte";

+
  import { scrollIntoView } from "@app/utils";
+
  import ProjectLink from "@app/router/ProjectLink.svelte";
+

  export let blob: Blob;
-
  export let line: number | null;
+
  export let line: string | null;
+

+
  $: lineNumber = line ? parseInt(line.substring(1)) : null;

  const lastCommit = blob.info.lastCommit;
  const lines = blob.binary ? 0 : (blob.content.match(/\n/g) || []).length;
  const lineNumbers = Array(lines)
    .fill(0)
-
    .map((_, index) => index + 1);
+
    .map((_, index) => (index + 1).toString());
  const parentDir = blob.path
    .match(/^.*\/|/)
    ?.values()
    .next().value;

+
  $: if (line) {
+
    scrollIntoView(line);
+
  }
+

  // Waiting onMount, due to the line numbers still loading.
  onMount(() => {
-
    const lineElement = document.getElementById(`L${line}`);
-
    if (lineElement) lineElement.scrollIntoView();
+
    if (line) scrollIntoView(line);
  });
</script>

@@ -154,20 +163,21 @@
          <span class="txt-tiny">Binary content</span>
        </div>
      {:else}
-
        {#if line}
+
        {#if lineNumber}
          <div
            class="highlight"
-
            style="top: {line === 1 ? 1 : 1.5 * line - 0.5}rem" />
+
            style="top: {lineNumber === 1 ? 1 : 1.5 * lineNumber - 0.5}rem" />
        {/if}
        <div class="line-numbers">
          {#each lineNumbers as lineNumber}
-
            <a
-
              href="#L{lineNumber}"
-
              class="line-number"
-
              class:highlighted={lineNumber === line}
+
            <ProjectLink
+
              projectParams={{ hash: `L${lineNumber}` }}
              id="L{lineNumber}">
+
              <span
+
                class="line-number"
+
                class:highlighted={lineNumber === line} />
              {lineNumber}
-
            </a>
+
            </ProjectLink>
          {/each}
        </div>
        {#if blob.html}
modified src/base/projects/Browser.svelte
@@ -1,13 +1,14 @@
<script lang="ts">
-
  import type { Readable } from "svelte/store";
-
  import type * as proj from "@app/project";
  import type { Theme } from "@app/ThemeToggle.svelte";
+
  import type { ProjectRoute } from "@app/router/definitions";
+
  import type * as proj from "@app/project";

  import Loading from "@app/Loading.svelte";
  import Placeholder from "@app/Placeholder.svelte";
  import * as utils from "@app/utils";
  import Button from "@app/Button.svelte";
  import { theme } from "@app/ThemeToggle.svelte";
+
  import * as router from "@app/router";

  import Tree from "./Tree.svelte";
  import Blob from "./Blob.svelte";
@@ -24,12 +25,11 @@

  export let project: proj.Project;
  export let tree: proj.Tree;
-
  export let browserStore: Readable<proj.Browser>;
  export let commit: string;
+
  export let activeRoute: ProjectRoute;

-
  $: browser = $browserStore;
-
  $: path = browser.path || "/";
-
  $: revision = browser.revision;
+
  $: path = activeRoute.params.path || "/";
+
  $: line = activeRoute.params.hash || null;

  // When the component is loaded the first time, the blob is yet to be loaded.
  let state: State = { status: Status.Loading, path };
@@ -79,9 +79,10 @@
    // Close mobile tree if user navigates to other file
    mobileFileTree = false;

-
    if (path) {
-
      project.navigateTo({ path: newPath, revision, line: null });
-
    }
+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
+
      path: newPath,
+
    });
  };

  const fetchTree = async (path: string) => {
@@ -207,9 +208,9 @@
          <Loading small center />
        {:then blob}
          {#if utils.isMarkdownPath(blob.path)}
-
            <Readme content={blob.content} {getImage} />
+
            <Readme {activeRoute} content={blob.content} {getImage} />
          {:else}
-
            <Blob line={browser.line} {blob} />
+
            <Blob {line} {blob} />
          {/if}
        {:catch}
          <Placeholder icon="🍂">
modified src/base/projects/Commit.svelte
@@ -1,19 +1,18 @@
<script lang="ts">
-
  import * as proj from "@app/project";
-
  import { formatCommit } from "@app/utils";
  import type { Commit } from "@app/commit";

+
  import { formatCommit } from "@app/utils";
+

  import Changeset from "@app/base/projects/SourceBrowser/Changeset.svelte";
  import CommitAuthorship from "@app/base/projects/Commit/CommitAuthorship.svelte";
  import CommitVerifiedBadge from "@app/base/projects/Commit/CommitVerifiedBadge.svelte";
+
  import * as router from "@app/router";

-
  export let project: proj.Project;
  export let commit: Commit;

  const onBrowse = (event: { detail: string }) => {
-
    project.navigateTo({
-
      content: proj.ProjectContent.Tree,
-
      revision: commit.header.sha1,
+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
      path: event.detail,
    });
  };
modified src/base/projects/Commit/CommitAuthorship.svelte
@@ -44,7 +44,8 @@
      src={gravatarURL(commit.header.committer.email)} />
    {#if commit.context?.committer}
      <span class="txt-bold committer">
-
        {commit.context?.committer.peer.person.name}
+
        {commit.context.committer.peer.person?.name ||
+
          commit.context.committer.peer.id}
      </span>
      {#if !noDelegate && commit.context?.committer.peer.delegate}
        <Badge variant="tertiary">delegate</Badge>
@@ -73,7 +74,8 @@
      src={gravatarURL(commit.header.committer.email)} />
    {#if commit.context?.committer}
      <span class="txt-bold committer">
-
        {commit.context?.committer.peer.person.name}
+
        {commit.context.committer.peer.person?.name ||
+
          commit.context.committer.peer.id}
      </span>
      {#if !noDelegate && commit.context?.committer.peer.delegate}
        <Badge variant="tertiary">delegate</Badge>
modified src/base/projects/Commit/CommitTeaser.svelte
@@ -103,7 +103,7 @@
    <!-- svelte-ignore a11y-click-events-have-key-events -->
    <div
      class="browse"
-
      title="View file"
+
      title="Browse the repository at this point in the history"
      on:click|stopPropagation={() => browseCommit(commit.header.sha1)}>
      <Icon name="browse" />
    </div>
modified src/base/projects/Header.svelte
@@ -1,47 +1,61 @@
<script lang="ts">
-
  import type { Writable } from "svelte/store";
-
  import { navigate } from "svelte-routing";
-
  import type { Browser } from "@app/project";
-
  import { ProjectContent, Project } from "@app/project";
+
  import type { Project } from "@app/project";
+
  import type { Tree } from "@app/project";
+
  import type { ProjectRoute } from "@app/router/definitions";
+

+
  import * as router from "@app/router";
  import BranchSelector from "@app/base/projects/BranchSelector.svelte";
  import CloneButton from "@app/base/projects/CloneButton.svelte";
  import PeerSelector from "@app/base/projects/PeerSelector.svelte";
-
  import type { Tree } from "@app/project";
+
  import { closeFocused } from "@app/Floating.svelte";

+
  export let activeRoute: ProjectRoute;
  export let project: Project;
  export let tree: Tree;
  export let commit: string;
-
  export let browserStore: Writable<Browser>;

  const { urn, peers, branches, seed } = project;

-
  $: browser = $browserStore;
-
  $: revision = browser.revision || commit;
-
  $: content = browser.content;
+
  $: revision = activeRoute.params.revision ?? commit;

  // Switches between project views.
-
  const toggleContent = (input: ProjectContent, keepSourceInPath: boolean) => {
-
    project.navigateTo({
-
      content: content === input ? ProjectContent.Tree : input,
-
      issue: null, // Removing issue here from browserStore to not contaminate path on navigation.
-
      patch: null, // Removing patch here from browserStore to not contaminate path on navigation.
-
      ...(keepSourceInPath ? null : { revision: null, path: null }),
+
  const toggleContent = (
+
    input: "patches" | "issues" | "history",
+
    keepSourceInPath: boolean,
+
  ) => {
+
    router.updateProjectRoute({
+
      view: {
+
        resource: activeRoute.params.view.resource === input ? "tree" : input,
+
      },
+
      urn: project.urn,
+
      revision: revision,
+
      ...(keepSourceInPath ? null : { revision: undefined, path: undefined }),
    });
  };

  const updatePeer = (peer: string) => {
-
    project.navigateTo({ peer, revision: null });
+
    router.updateProjectRoute({
+
      peer,
+
      revision: undefined,
+
    });
+
    closeFocused();
  };

  const updateRevision = (revision: string) => {
-
    project.navigateTo({ revision });
+
    router.updateProjectRoute({
+
      revision,
+
    });
+
    closeFocused();
  };

  function goToSeed() {
    if (seed.api.port) {
-
      navigate(`/seeds/${seed.api.host}:${seed.api.port}`);
+
      router.push({
+
        resource: "seeds",
+
        params: { host: `${seed.api.host}:${seed.api.port}` },
+
      });
    } else {
-
      navigate(`/seeds/${seed.api.host}`);
+
      router.push({ resource: "seeds", params: { host: seed.api.host } });
    }
  }
</script>
@@ -99,7 +113,7 @@
  {#if peers.length > 0}
    <PeerSelector
      {peers}
-
      peer={browser.peer}
+
      peer={activeRoute.params.peer}
      on:peerChanged={event => updatePeer(event.detail)} />
  {/if}

@@ -126,8 +140,8 @@
  <!-- svelte-ignore a11y-click-events-have-key-events -->
  <div
    class="stat commit-count clickable widget"
-
    class:active={content === ProjectContent.History}
-
    on:click={() => toggleContent(ProjectContent.History, true)}>
+
    class:active={activeRoute.params.view.resource === "history"}
+
    on:click={() => toggleContent("history", true)}>
    <span class="txt-bold">{tree.stats.commits}</span>
    commit(s)
  </div>
@@ -135,10 +149,10 @@
    <!-- svelte-ignore a11y-click-events-have-key-events -->
    <div
      class="stat issue-count clickable widget"
-
      class:active={content === ProjectContent.Issues}
+
      class:active={activeRoute.params.view.resource === "issues"}
      class:not-allowed={project.issues === 0}
      class:clickable={project.issues > 0}
-
      on:click={() => toggleContent(ProjectContent.Issues, false)}>
+
      on:click={() => toggleContent("issues", false)}>
      <span class="txt-bold">{project.issues}</span>
      issue(s)
    </div>
@@ -147,10 +161,10 @@
    <!-- svelte-ignore a11y-click-events-have-key-events -->
    <div
      class="stat patch-count clickable widget"
-
      class:active={content === ProjectContent.Patches}
+
      class:active={activeRoute.params.view.resource === "patches"}
      class:not-allowed={project.patches === 0}
      class:clickable={project.patches > 0}
-
      on:click={() => toggleContent(ProjectContent.Patches, false)}>
+
      on:click={() => toggleContent("patches", false)}>
      <span class="txt-bold">{project.patches}</span>
      patch(es)
    </div>
modified src/base/projects/History.svelte
@@ -1,23 +1,15 @@
<script lang="ts">
-
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
-
  import { Project, ProjectContent } from "@app/project";
  import type { CommitMetadata, CommitsHistory } from "@app/commit";
+

+
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
+
  import { Project } from "@app/project";
  import { groupCommits } from "@app/commit";
  import List from "@app/List.svelte";
+
  import * as router from "@app/router";

  export let project: Project;
  export let history: CommitsHistory;

-
  const navigateHistory = (revision: string, content?: ProjectContent) => {
-
    project.navigateTo({
-
      content,
-
      revision,
-
      issue: null,
-
      patch: null,
-
      path: null,
-
    });
-
  };
-

  const fetchMoreCommits = async (): Promise<CommitMetadata[]> => {
    const response = await Project.getCommits(project.urn, project.seed.api, {
      // Fetching 31 elements since we remove the first one
@@ -30,11 +22,9 @@
  };

  const browseCommit = (event: { detail: string }) => {
-
    project.navigateTo({
-
      content: ProjectContent.Tree,
+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
      revision: event.detail,
-
      issue: null,
-
      path: null,
    });
  };
</script>
@@ -91,8 +81,12 @@
              <!-- svelte-ignore a11y-click-events-have-key-events -->
              <div
                class="commit"
-
                on:click={() =>
-
                  navigateHistory(commit.header.sha1, ProjectContent.Commit)}>
+
                on:click={() => {
+
                  router.updateProjectRoute({
+
                    view: { resource: "commits" },
+
                    revision: commit.header.sha1,
+
                  });
+
                }}>
                <CommitTeaser {commit} on:browseCommit={browseCommit} />
              </div>
            {/each}
modified src/base/projects/Issues.svelte
@@ -7,10 +7,9 @@
  import type { Issue } from "@app/issue";
  import type { ToggleButtonOption } from "@app/ToggleButton.svelte";

-
  import { Project, ProjectContent } from "@app/project";
  import { capitalize } from "@app/utils";
  import { groupIssues } from "@app/issue";
-
  import { navigate } from "svelte-routing";
+
  import * as router from "@app/router";

  import IssueTeaser from "@app/base/projects/Issue/IssueTeaser.svelte";
  import Placeholder from "@app/Placeholder.svelte";
@@ -18,7 +17,6 @@

  export let wallet: Wallet;
  export let issues: Issue[];
-
  export let project: Project;
  export let state: State;

  let options: ToggleButtonOption<State>[];
@@ -66,7 +64,9 @@
    <ToggleButton
      {options}
      on:select={e => {
-
        navigate(`?state=${e.detail}`);
+
        router.updateProjectRoute({
+
          search: e.detail,
+
        });
      }}
      active={state} />
  </div>
@@ -78,12 +78,11 @@
        <div
          class="teaser"
          on:click={() => {
-
            project.navigateTo({
-
              content: ProjectContent.Issue,
-
              issue: issue.id,
-
              patch: null,
-
              revision: null,
-
              path: null,
+
            router.updateProjectRoute({
+
              view: {
+
                resource: "issue",
+
                params: { issue: issue.id },
+
              },
            });
          }}>
          <IssueTeaser {wallet} {issue} />
modified src/base/projects/Patch.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
  import type { Wallet } from "@app/wallet";
-
  import { Project, ProjectContent } from "@app/project";
+
  import type { Project } from "@app/project";
+

  import { capitalize } from "@app/utils";
  import { Patch, PatchTab } from "@app/patch";
  import { formatObjectId } from "@app/cobs";
@@ -11,6 +12,7 @@
  import PatchTabBar from "./Patch/PatchTabBar.svelte";
  import PatchTimeline from "./Patch/PatchTimeline.svelte";
  import Placeholder from "@app/Placeholder.svelte";
+
  import * as router from "@app/router";

  export let patch: Patch;
  export let project: Project;
@@ -25,10 +27,9 @@
  };

  const onBrowse = (event: { detail: string }, revision: string) => {
-
    project.navigateTo({
-
      content: ProjectContent.Tree,
+
    router.updateProjectRoute({
+
      view: { resource: "tree" },
      revision,
-
      patch: null,
      path: event.detail,
    });
  };
modified src/base/projects/Patches.svelte
@@ -1,6 +1,8 @@
-
<script lang="ts">
-
  type State = "proposed" | "draft" | "archived";
+
<script lang="ts" context="module">
+
  export type State = "proposed" | "draft" | "archived";
+
</script>

+
<script lang="ts">
  import type { Wallet } from "@app/wallet";
  import type { Patch } from "@app/patch";
  import type { ToggleButtonOption } from "@app/ToggleButton.svelte";
@@ -9,14 +11,13 @@
  import Placeholder from "@app/Placeholder.svelte";
  import ToggleButton from "@app/ToggleButton.svelte";

-
  import { Project, ProjectContent } from "@app/project";
  import { capitalize } from "@app/utils";
  import { groupPatches } from "@app/patch";
+
  import * as router from "@app/router";

-
  export let state: State = "proposed";
+
  export let state: State;
  export let wallet: Wallet;
  export let patches: Patch[];
-
  export let project: Project;

  let options: ToggleButtonOption<State>[];
  const sortedPatches = groupPatches(patches);
@@ -63,7 +64,9 @@
    <ToggleButton
      {options}
      on:select={e => {
-
        state = e.detail;
+
        router.updateProjectRoute({
+
          search: e.detail,
+
        });
      }}
      active={state} />
  </div>
@@ -75,12 +78,8 @@
        <div
          class="teaser"
          on:click={() => {
-
            project.navigateTo({
-
              content: ProjectContent.Patch,
-
              patch: patch.id,
-
              issue: null,
-
              revision: null,
-
              path: null,
+
            router.updateProjectRoute({
+
              view: { resource: "patch", params: { patch: patch.id } },
            });
          }}>
          <PatchTeaser {wallet} {patch} />
deleted src/base/projects/Project.svelte
@@ -1,167 +0,0 @@
-
<script lang="ts">
-
  import type { Wallet } from "@app/wallet";
-
  import type { State as IssueState } from "./Issues.svelte";
-

-
  import * as proj from "@app/project";
-
  import Placeholder from "@app/Placeholder.svelte";
-
  import Loading from "@app/Loading.svelte";
-
  import { formatProfile, formatSeedId, setOpenGraphMetaTag } from "@app/utils";
-
  import { browserStore } from "@app/project";
-
  import * as patch from "@app/patch";
-
  import * as issue from "@app/issue";
-

-
  import Header from "@app/base/projects/Header.svelte";
-
  import Async from "@app/Async.svelte";
-

-
  import Browser from "./Browser.svelte";
-
  import Commit from "./Commit.svelte";
-
  import History from "./History.svelte";
-
  import Issues from "./Issues.svelte";
-
  import Issue from "./Issue.svelte";
-
  import ProjectMeta from "./ProjectMeta.svelte";
-
  import Patches from "./Patches.svelte";
-
  import Patch from "./Patch.svelte";
-

-
  export let peer: string | null = null;
-
  export let wallet: Wallet;
-
  export let project: proj.Project;
-
  export let content: proj.ProjectContent;
-
  export let revision: string | null;
-

-
  const parentName = project.profile
-
    ? formatProfile(project.profile.nameOrAddress, wallet)
-
    : null;
-
  let pageTitle = parentName ? `${parentName}/${project.name}` : project.name;
-

-
  $: issueFilter = ($browserStore.search?.get("state") as IssueState) ?? "open";
-

-
  const baseName = parentName ? `${parentName}/${project.name}` : project.name;
-

-
  if (project.description) {
-
    pageTitle = `${baseName}: ${project.description}`;
-
  } else {
-
    pageTitle = baseName;
-
  }
-

-
  setOpenGraphMetaTag([
-
    { prop: "og:title", content: project.name },
-
    { prop: "og:description", content: project.description },
-
    { prop: "og:url", content: window.location.href },
-
  ]);
-
</script>
-

-
<style>
-
  .error {
-
    color: var(--color-negative);
-
    background-color: var(--color-negative-2);
-
    word-wrap: break-word;
-
    text-overflow: ellipsis;
-
    overflow-x: hidden;
-
    padding: 1rem;
-
  }
-
  .error::selection,
-
  .error ::selection {
-
    background-color: var(--color-negative);
-
  }
-
  .content {
-
    padding: 0 2rem 0 8rem;
-
  }
-
  @media (max-width: 960px) {
-
    .content {
-
      padding-left: 2rem;
-
    }
-
  }
-
</style>
-

-
<svelte:head>
-
  <title>{pageTitle}</title>
-
</svelte:head>
-

-
<ProjectMeta
-
  noDescription={content !== proj.ProjectContent.Tree}
-
  {project}
-
  {peer} />
-

-
{#if revision}
-
  {#await project.getRoot(revision)}
-
    <Loading center />
-
  {:then { tree, commit }}
-
    <Header {tree} {commit} {browserStore} {project} />
-

-
    {#if content === proj.ProjectContent.Tree}
-
      <Browser {project} {commit} {tree} {browserStore} />
-
    {:else if content === proj.ProjectContent.History}
-
      <Async
-
        fetch={proj.Project.getCommits(project.urn, project.seed.api, {
-
          parent: commit,
-
          verified: true,
-
        })}
-
        let:result>
-
        <History {project} history={result} />
-
      </Async>
-
    {:else if content === proj.ProjectContent.Commit}
-
      <Async fetch={project.getCommit(commit)} let:result>
-
        <Commit {project} commit={result} />
-
      </Async>
-
    {/if}
-
  {:catch err}
-
    <div class="content">
-
      <div class="error txt-tiny">
-
        <!-- TODO: Differentiate between (1) commit doesn't exist and (2) failed
-
             to fetch - this needs a change to the backend. -->
-
        API request to
-
        <span class="txt-monospace">{err.url}</span>
-
        failed
-
      </div>
-
    </div>
-
  {/await}
-

-
  {#if content === proj.ProjectContent.Issues}
-
    <Async
-
      fetch={issue.Issue.getIssues(project.urn, project.seed.api)}
-
      let:result>
-
      <Issues {project} state={issueFilter} {wallet} issues={result} />
-
    </Async>
-
  {:else if content === proj.ProjectContent.Issue && $browserStore.issue}
-
    <Async
-
      fetch={issue.Issue.getIssue(
-
        project.urn,
-
        $browserStore.issue,
-
        project.seed.api,
-
      )}
-
      let:result>
-
      <Issue {project} {wallet} issue={result} />
-
    </Async>
-
  {:else if content === proj.ProjectContent.Patches}
-
    <Async
-
      fetch={patch.Patch.getPatches(project.urn, project.seed.api)}
-
      let:result>
-
      <Patches {project} {wallet} patches={result} />
-
    </Async>
-
  {:else if content === proj.ProjectContent.Patch && $browserStore.patch}
-
    <Async
-
      fetch={patch.Patch.getPatch(
-
        project.urn,
-
        $browserStore.patch,
-
        project.seed.api,
-
      )}
-
      let:result>
-
      <Patch {project} {wallet} patch={result} />
-
    </Async>
-
  {/if}
-
{:else}
-
  <div class="content">
-
    {#if peer}
-
      <Placeholder icon="🍂">
-
        <span slot="title">
-
          <span class="txt-monospace">{formatSeedId(peer)}</span>
-
        </span>
-
        <span slot="body">Couldn't load remote source tree.</span>
-
      </Placeholder>
-
    {:else}
-
      <Placeholder icon="🍂">
-
        <span slot="body">Couldn't load source tree.</span>
-
      </Placeholder>
-
    {/if}
-
  </div>
-
{/if}
modified src/base/projects/ProjectMeta.svelte
@@ -1,24 +1,14 @@
<script lang="ts">
-
  import { link } from "svelte-routing";
+
  import type { PeerId, Project } from "@app/project";
+

  import Avatar from "@app/Avatar.svelte";
  import Clipboard from "@app/Clipboard.svelte";
+
  import Link from "@app/router/Link.svelte";
+
  import ProjectLink from "@app/router/ProjectLink.svelte";
  import { formatSeedId } from "@app/utils";
-
  import { type PeerId, type Project, ProjectContent } from "@app/project";

  export let project: Project;
  export let peer: PeerId | null = null;
-
  export let noDescription = false;
-

-
  function rootPath(): string {
-
    return project.pathTo({
-
      content: ProjectContent.Tree,
-
      peer: null,
-
      path: "/",
-
      revision: null,
-
      issue: null,
-
      patch: null,
-
    });
-
  }
</script>

<style>
@@ -61,9 +51,6 @@
  .description {
    margin: 1rem 0 1.5rem 0;
  }
-
  .placeholder {
-
    height: 2rem;
-
  }

  .content {
    padding: 0 2rem 0 8rem;
@@ -85,18 +72,33 @@
<header class="content">
  <div class="title txt-bold txt-title">
    {#if project.profile}
-
      <a
-
        class="org-avatar"
-
        title={project.profile.nameOrAddress}
-
        href="/{project.profile.nameOrAddress}">
-
        <Avatar
-
          source={project.profile.avatar || project.profile.address}
-
          title={project.profile.address} />
-
      </a>
+
      <Link
+
        route={{
+
          resource: "profile",
+
          params: { addressOrName: project.profile.addressOrName },
+
        }}
+
        title={project.profile.addressOrName}>
+
        <span class="org-avatar">
+
          <Avatar
+
            source={project.profile.avatar || project.profile.address}
+
            title={project.profile.address} />
+
        </span>
+
      </Link>
      <span class="divider">/</span>
    {/if}
    <span class="truncate">
-
      <a use:link class="project-name" href={rootPath()}>{project.name}</a>
+
      <ProjectLink
+
        projectParams={{
+
          view: { resource: "tree" },
+
          path: "/",
+
          peer: undefined,
+
          route: undefined,
+
          revision: undefined,
+
        }}>
+
        <span class="project-name">
+
          {project.name}
+
        </span>
+
      </ProjectLink>
    </span>
    {#if peer}
      <span class="peer-id">
@@ -110,9 +112,5 @@
    <span class="truncate">{project.urn}</span>
    <Clipboard small text={project.urn} />
  </div>
-
  {#if !noDescription}
-
    <div class="description">{project.description}</div>
-
  {:else}
-
    <div class="placeholder" />
-
  {/if}
+
  <div class="description">{project.description}</div>
</header>
deleted src/base/projects/ProjectRoute.svelte
@@ -1,59 +0,0 @@
-
<script lang="ts">
-
  import type { Writable } from "svelte/store";
-
  import type { Wallet } from "@app/wallet";
-
  import { formatLocationHash } from "@app/utils";
-
  import * as proj from "@app/project";
-
  import type { RouteLocation } from "@app/index";
-

-
  import Project from "@app/base/projects/Project.svelte";
-

-
  export let browserStore: Writable<proj.Browser> = proj.browserStore;
-
  export let route: string | null = null;
-
  export let revision: string | null = null;
-
  export let issue: string | null = null;
-
  export let patch: string | null = null;
-
  export let peer: string | null;
-
  export let content: proj.ProjectContent = proj.ProjectContent.Tree;
-
  export let project: proj.Project;
-
  export let wallet: Wallet;
-
  export let location: RouteLocation | null = null;
-

-
  const browse: proj.BrowseTo = { content, peer, path: "/" };
-
  const head = project.branches[project.defaultBranch] || null;
-

-
  // If line-number hash changes, we update the browser.
-
  $: browse.line = formatLocationHash(location?.hash || null);
-

-
  // `route` includes any unmatched path segments.
-
  $: if (route) {
-
    const { path, revision } = proj.parseRoute(route, project.branches);
-

-
    if (path) browse.path = path;
-
    if (revision) browse.revision = revision;
-
  } else if (revision) {
-
    browse.revision = revision;
-
  } else if (issue) {
-
    browse.issue = issue;
-
  } else if (location) {
-
    browse.search = new URLSearchParams(location.search);
-
  } else if (patch) {
-
    browse.patch = patch;
-
  } else if (head) {
-
    browse.revision = head;
-
  } else {
-
    const branchNames = Object.keys(project.branches);
-
    const firstBranch = branchNames.length >= 1 ? branchNames[0] : null;
-

-
    browse.revision = firstBranch;
-
  }
-

-
  $: proj.browse({ ...browse, peer });
-
  $: browser = $browserStore;
-
</script>
-

-
<Project
-
  peer={browser.peer}
-
  revision={browser.revision || head}
-
  content={browser.content}
-
  {project}
-
  {wallet} />
modified src/base/projects/Readme.svelte
@@ -1,9 +1,14 @@
<script lang="ts">
-
  import Markdown from "@app/Markdown.svelte";
  import type * as proj from "@app/project";
+
  import type { ProjectRoute } from "@app/router/definitions";
+

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

  export let content: string;
  export let getImage: (path: string) => Promise<proj.Blob>;
+
  export let activeRoute: ProjectRoute;
+

+
  $: hash = activeRoute.params.hash || null;
</script>

<style>
@@ -16,5 +21,5 @@
</style>

<article>
-
  <Markdown {content} {getImage} />
+
  <Markdown {content} {getImage} {hash} />
</article>
deleted src/base/projects/Routes.svelte
@@ -1,40 +0,0 @@
-
<script lang="ts">
-
  import { Route } from "svelte-routing";
-
  import View from "@app/base/projects/View.svelte";
-
  import type { Wallet } from "@app/wallet";
-
  import Redirect from "@app/Redirect.svelte";
-

-
  export let wallet: Wallet;
-
</script>
-

-
<!-- With a seed context -->
-

-
<Route path="/seeds/:seed/:id/*" let:params>
-
  <View {wallet} seedHost={params.seed} id={params.id} />
-
</Route>
-

-
<Route path="/seeds/:seed/:id/remotes/:peer/*" let:params>
-
  <View {wallet} seedHost={params.seed} peer={params.peer} id={params.id} />
-
</Route>
-

-
<!-- Explicit user and org context, will at some point be replaced by the generic route -->
-
<Route path="/orgs/:addressOrName/projects/:id/*" let:params>
-
  <Redirect to="/{params.addressOrName}/{params.id}/{params['*']}" />
-
</Route>
-

-
<Route path="/users/:addressOrName/projects/:id/*" let:params>
-
  <Redirect to="/{params.addressOrName}/{params.id}/{params['*']}" />
-
</Route>
-
<!-- End of eventual dropped routes -->
-

-
<Route path="/:profile/:id/remotes/:peer/*" let:params>
-
  <View
-
    {wallet}
-
    profileName={params.profile}
-
    id={params.id}
-
    peer={params.peer} />
-
</Route>
-

-
<Route path="/:profile/:id/*" let:params>
-
  <View {wallet} profileName={params.profile} id={params.id} />
-
</Route>
modified src/base/projects/View.svelte
@@ -1,17 +1,70 @@
<script lang="ts">
  import type { Wallet } from "@app/wallet";
-
  import { Route, Router } from "svelte-routing";
-
  import { Project, ProjectContent } from "@app/project";
+
  import type { ProjectRoute, ProjectsParams } from "@app/router/definitions";
+
  import type { State as IssueState } from "./Issues.svelte";
+
  import type { State as PatchState } from "./Patches.svelte";
+

+
  import * as issue from "@app/issue";
+
  import * as patch from "@app/patch";
+
  import * as proj from "@app/project";
+
  import * as router from "@app/router";
  import Loading from "@app/Loading.svelte";
  import NotFound from "@app/NotFound.svelte";
+
  import { formatSeedId, unreachable } from "@app/utils";

-
  import ProjectRoute from "./ProjectRoute.svelte";
+
  import Header from "./Header.svelte";
+
  import Browser from "./Browser.svelte";
+
  import History from "./History.svelte";
+
  import Commit from "./Commit.svelte";
+
  import Issues from "./Issues.svelte";
+
  import Issue from "./Issue.svelte";
+
  import Patches from "./Patches.svelte";
+
  import Patch from "./Patch.svelte";
+
  import ProjectMeta from "./ProjectMeta.svelte";
+
  import Message from "@app/Message.svelte";
+
  import Placeholder from "@app/Placeholder.svelte";

-
  export let id: string; // Project name or URN.
-
  export let seedHost: string | null = null;
-
  export let profileName: string | null = null; // Address or name of parent profile.
-
  export let peer: string | null = null;
  export let wallet: Wallet;
+
  export let activeRoute: ProjectRoute;
+

+
  $: urn = activeRoute.params.urn;
+
  $: peer = activeRoute.params.peer ?? null;
+

+
  $: searchParams = new URLSearchParams(activeRoute.params.search || "");
+
  $: issueFilter = (searchParams.get("state") as IssueState) || "open";
+
  $: patchFilter = (searchParams.get("state") as PatchState) || "proposed";
+

+
  const getProject = async (params: ProjectsParams) => {
+
    const project = await proj.Project.get(
+
      params.urn,
+
      params.peer ?? null,
+
      params.profile ?? null,
+
      params.seed ?? null,
+
      wallet,
+
    );
+
    if (params.route) {
+
      const { revision, path } = proj.parseRoute(
+
        params.route,
+
        project.branches,
+
      );
+
      router.updateProjectRoute({
+
        revision,
+
        path,
+
        hash: params.hash,
+
        route: undefined,
+
      });
+
    }
+
    if (!params.revision) {
+
      // We need a revision to fetch `getRoot`.
+
      // Don't use router.updateProjectRoute, to avoid changing the URL.
+
      params.revision = project.defaultBranch;
+
    }
+

+
    return project;
+
  };
+

+
  // Content can be altered in child components.
+
  $: revision = activeRoute.params.revision || null;
</script>

<style>
@@ -24,11 +77,17 @@
  main > header {
    padding: 0 2rem 0 8rem;
  }
+
  main > .message {
+
    padding: 0 2rem 0 8rem;
+
  }

  @media (max-width: 960px) {
    main > header {
      padding-left: 2rem;
    }
+
    main > .message {
+
      padding-left: 2rem;
+
    }
    main {
      padding-top: 2rem;
      min-width: 0;
@@ -37,99 +96,107 @@
</style>

<main>
-
  {#await Project.get(id, peer, profileName, seedHost, wallet)}
+
  {#await getProject(activeRoute.params)}
    <header>
      <Loading center />
    </header>
  {:then project}
-
    <Router>
-
      <!-- The default action is to render Browser with the default branch head -->
-
      <Route path="/">
-
        <ProjectRoute content={ProjectContent.Tree} {peer} {project} {wallet} />
-
      </Route>
-
      <Route path="/tree">
-
        <ProjectRoute content={ProjectContent.Tree} {peer} {project} {wallet} />
-
      </Route>
-
      <Route path="/tree/*" let:params let:location>
-
        <ProjectRoute
-
          route={params["*"]}
-
          content={ProjectContent.Tree}
-
          {location}
-
          {peer}
-
          {project}
-
          {wallet} />
-
      </Route>
-

-
      <Route path="/history">
-
        <ProjectRoute
-
          content={ProjectContent.History}
-
          {peer}
-
          {project}
-
          {wallet} />
-
      </Route>
-
      <Route path="/history/*" let:params let:location>
-
        <ProjectRoute
-
          route={params["*"]}
-
          content={ProjectContent.History}
-
          {location}
-
          {peer}
-
          {project}
-
          {wallet} />
-
      </Route>
-

-
      <Route path="/commits/:commit" let:params>
-
        <ProjectRoute
-
          revision={params.commit}
-
          content={ProjectContent.Commit}
-
          {peer}
-
          {project}
-
          {wallet} />
-
      </Route>
-
      <Route path="/commits/*" let:params let:location>
-
        <ProjectRoute
-
          route={params["*"]}
-
          content={ProjectContent.Commit}
-
          {location}
-
          {peer}
-
          {project}
-
          {wallet} />
-
      </Route>
-

-
      <Route path="/issues" let:location>
-
        <ProjectRoute
-
          content={ProjectContent.Issues}
-
          {peer}
-
          {project}
-
          {location}
-
          {wallet} />
-
      </Route>
-
      <Route path="/issues/:issue" let:params let:location>
-
        <ProjectRoute
-
          content={ProjectContent.Issue}
-
          issue={params.issue}
-
          {peer}
-
          {project}
-
          {location}
-
          {wallet} />
-
      </Route>
+
    <ProjectMeta {project} {peer} />
+
    {#await project.getRoot(revision)}
+
      <Loading center />
+
    {:then { tree, commit }}
+
      <Header {tree} {commit} {project} {activeRoute} />

-
      <Route path="/patches">
-
        <ProjectRoute
-
          content={ProjectContent.Patches}
-
          {peer}
-
          {project}
-
          {wallet} />
-
      </Route>
-
      <Route path="/patches/:patch" let:params>
-
        <ProjectRoute
-
          content={ProjectContent.Patch}
-
          patch={params.patch}
-
          {peer}
-
          {project}
-
          {wallet} />
-
      </Route>
-
    </Router>
+
      {#if activeRoute.params.view.resource === "tree"}
+
        <Browser {project} {commit} {tree} {activeRoute} />
+
      {:else if activeRoute.params.view.resource === "history"}
+
        {#await proj.Project.getCommits( project.urn, project.seed.api, { parent: commit, verified: true }, )}
+
          <Loading center />
+
        {:then history}
+
          <History {project} {history} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "commits"}
+
        {#await project.getCommit(commit)}
+
          <Loading center />
+
        {:then commit}
+
          <Commit {commit} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "issues"}
+
        {#await issue.Issue.getIssues(project.urn, project.seed.api)}
+
          <Loading center />
+
        {:then issues}
+
          <Issues state={issueFilter} {wallet} {issues} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "issue"}
+
        {#await issue.Issue.getIssue(project.urn, activeRoute.params.view.params.issue, project.seed.api)}
+
          <Loading center />
+
        {:then issue}
+
          <Issue {project} {wallet} {issue} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "patches"}
+
        {#await patch.Patch.getPatches(project.urn, project.seed.api)}
+
          <Loading center />
+
        {:then patches}
+
          <Patches {wallet} state={patchFilter} {patches} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else if activeRoute.params.view.resource === "patch"}
+
        {#await patch.Patch.getPatch(project.urn, activeRoute.params.view.params.patch, project.seed.api)}
+
          <Loading center />
+
        {:then patch}
+
          <Patch {project} {wallet} {patch} />
+
        {:catch e}
+
          <div class="message">
+
            <Message error>{e.message}</Message>
+
          </div>
+
        {/await}
+
      {:else}
+
        {unreachable(activeRoute.params.view)}
+
      {/if}
+
    {:catch e}
+
      <div class="message">
+
        {#if peer}
+
          <Placeholder icon="🍂">
+
            <span slot="title">
+
              <span class="txt-monospace">{formatSeedId(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 icon="🍂">
+
            <span slot="body">
+
              <span style="display: block">Couldn't load source tree.</span>
+
              <span>{e.message}</span>
+
            </span>
+
          </Placeholder>
+
        {/if}
+
      </div>
+
    {/await}
  {:catch}
-
    <NotFound title={id} subtitle="This project was not found." />
+
    <NotFound title={urn} subtitle="This project was not found." />
  {/await}
</main>
modified src/base/registrations/Index.svelte
@@ -1,9 +1,10 @@
<script lang="ts">
-
  import { navigate } from "svelte-routing";
  import type { Wallet } from "@app/wallet";

-
  import TextInput from "@app/TextInput.svelte";
+
  import * as router from "@app/router";
+

  import Button from "@app/Button.svelte";
+
  import TextInput from "@app/TextInput.svelte";

  export let wallet: Wallet;

@@ -15,7 +16,15 @@
    if (!valid) {
      return;
    }
-
    navigate(`/registrations/${ensName}/form`);
+
    router.push({
+
      resource: "registrations",
+
      params: {
+
        view: {
+
          resource: "checkNameAvailability",
+
          params: { nameOrDomain: ensName, owner: null },
+
        },
+
      },
+
    });
  }

  function validate(input: string) {
modified src/base/registrations/New.svelte
@@ -1,17 +1,17 @@
<script lang="ts">
-
  import { onMount } from "svelte";
-
  import { navigate } from "svelte-routing";
-
  import { formatAddress } from "@app/utils";
-
  import { session } from "@app/session";
  import type { Wallet } from "@app/wallet";

+
  import { onMount } from "svelte";
+

+
  import * as router from "@app/router";
+
  import Button from "@app/Button.svelte";
  import Connect from "@app/Connect.svelte";
-
  import Modal from "@app/Modal.svelte";
  import Loading from "@app/Loading.svelte";
  import Message from "@app/Message.svelte";
-
  import Button from "@app/Button.svelte";
-

+
  import Modal from "@app/Modal.svelte";
+
  import { formatAddress } from "@app/utils";
  import { registrar } from "./registrar";
+
  import { session } from "@app/session";

  enum State {
    CheckingAvailability,
@@ -32,13 +32,15 @@
  $: registrationOwner = owner || ($session && $session.address);

  function begin() {
-
    navigate(
-
      `/registrations/${name}/submit?${
-
        registrationOwner
-
          ? new URLSearchParams({ owner: registrationOwner })
-
          : ""
-
      }`,
-
    );
+
    router.push({
+
      resource: "registrations",
+
      params: {
+
        view: {
+
          resource: "register",
+
          params: { nameOrDomain: name, owner: registrationOwner },
+
        },
+
      },
+
    });
  }

  onMount(async () => {
@@ -55,6 +57,13 @@
      error = err.message;
    }
  });
+

+
  function goToValidateName() {
+
    router.push({
+
      resource: "registrations",
+
      params: { view: { resource: "validateName" } },
+
    });
+
  }
</script>

<style>
@@ -116,13 +125,9 @@
          {wallet} />
      {/if}

-
      <Button on:click={() => navigate("/registrations")} variant="text">
-
        Cancel
-
      </Button>
+
      <Button on:click={goToValidateName} variant="text">Cancel</Button>
    {:else if state === State.NameUnavailable || state === State.CheckingFailed}
-
      <Button variant="foreground" on:click={() => navigate("/registrations")}>
-
        Back
-
      </Button>
+
      <Button variant="foreground" on:click={goToValidateName}>Back</Button>
    {/if}
  </span>
</Modal>
modified src/base/registrations/Routes.svelte
@@ -1,40 +1,51 @@
<script lang="ts">
-
  import { Route, navigate } from "svelte-routing";
-
  import Index from "@app/base/registrations/Index.svelte";
+
  import type { RegistrationRoute } from "@app/router/definitions";
+
  import type { Session } from "@app/session";
+
  import { unreachable } from "@app/utils";
+
  import type { Wallet } from "@app/wallet";
+

+
  import * as router from "@app/router";
+

  import New from "@app/base/registrations/New.svelte";
  import Submit from "@app/base/registrations/Submit.svelte";
+
  import Index from "@app/base/registrations/Index.svelte";
  import View from "@app/base/registrations/View.svelte";
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import type { Wallet } from "@app/wallet";
-
  import type { Session } from "@app/session";
-
  import { getSearchParam } from "@app/utils";

-
  export let session: Session | null;
  export let wallet: Wallet;
+
  export let activeRoute: RegistrationRoute;
+
  export let session: Session | null;
</script>

-
<Route path="registrations">
+
{#if activeRoute.params.view.resource === "validateName"}
  <Index {wallet} />
-
</Route>
-

-
<Route path="registrations/:name/form" let:params let:location>
-
  <New {wallet} name={params.name} owner={getSearchParam("owner", location)} />
-
</Route>
-

-
<Route path="registrations/:name/submit" let:params let:location>
+
{:else if activeRoute.params.view.resource === "checkNameAvailability"}
+
  <New
+
    {wallet}
+
    name={activeRoute.params.view.params.nameOrDomain}
+
    owner={activeRoute.params.view.params.owner} />
+
{:else if activeRoute.params.view.resource === "register"}
  {#if session}
    <Submit
      {wallet}
-
      name={params.name}
-
      owner={getSearchParam("owner", location)}
+
      name={activeRoute.params.view.params.nameOrDomain}
+
      owner={activeRoute.params.view.params.owner}
      {session} />
  {:else}
    <ErrorModal
      message={"You must connect your wallet to register"}
-
      on:close={() => navigate("/registrations")} />
+
      on:close={() => {
+
        router.push({
+
          resource: "registrations",
+
          params: { view: { resource: "validateName" } },
+
        });
+
      }} />
  {/if}
-
</Route>
-

-
<Route path="registrations/:domain" let:params>
-
  <View {wallet} domain={params.domain} />
-
</Route>
+
{:else if activeRoute.params.view.resource === "view"}
+
  <View
+
    {wallet}
+
    retry={activeRoute.params.view.params.retry}
+
    domain={activeRoute.params.view.params.nameOrDomain} />
+
{:else}
+
  {unreachable(activeRoute.params.view)}
+
{/if}
modified src/base/registrations/Submit.svelte
@@ -1,15 +1,15 @@
<script lang="ts">
-
  // TODO: When name is registered, prompt user to edit records.
-
  import { onMount } from "svelte";
-
  import { navigate } from "svelte-routing";
  import type { Session } from "@app/session";
  import type { Wallet } from "@app/wallet";
-
  import Loading from "@app/Loading.svelte";
-
  import Modal from "@app/Modal.svelte";
-
  import ErrorModal from "@app/ErrorModal.svelte";
+

+
  import { onMount } from "svelte";
+

+
  import * as router from "@app/router";
  import BlockTimer from "@app/BlockTimer.svelte";
  import Button from "@app/Button.svelte";
-

+
  import ErrorModal from "@app/ErrorModal.svelte";
+
  import Loading from "@app/Loading.svelte";
+
  import Modal from "@app/Modal.svelte";
  import { registerName, State, state } from "./registrar";

  export let wallet: Wallet;
@@ -20,10 +20,20 @@
  let error: Error | null = null;
  const registrationOwner = owner || session.address;

-
  const view = () =>
-
    navigate(`/registrations/${name}.${wallet.registrar.domain}`, {
-
      state: { retry: true },
+
  function view() {
+
    router.push({
+
      resource: "registrations",
+
      params: {
+
        view: {
+
          resource: "view",
+
          params: {
+
            nameOrDomain: `${name}.${wallet.registrar.domain}`,
+
            retry: true,
+
          },
+
        },
+
      },
    });
+
  }

  onMount(async () => {
    try {
@@ -58,7 +68,11 @@
  <ErrorModal
    title="Transaction failed"
    message={error.message}
-
    on:close={() => navigate("/registrations")} />
+
    on:close={() =>
+
      router.push({
+
        resource: "registrations",
+
        params: { view: { resource: "validateName" } },
+
      })} />
{:else}
  <Modal>
    <span slot="title">
modified src/base/registrations/View.svelte
@@ -1,23 +1,25 @@
<script lang="ts">
-
  import { onMount } from "svelte";
-
  import { link, navigate } from "svelte-routing";
+
  import type { EnsRecord } from "./resolver";
+
  import type { Field } from "@app/Form.svelte";
+
  import type { Registration } from "./registrar";
  import type { Wallet } from "@app/wallet";
  import type { ethers } from "ethers";
-
  import { session } from "@app/session";
+

+
  import { onMount } from "svelte";
+

+
  import * as router from "@app/router";
+
  import Button from "@app/Button.svelte";
+
  import ErrorModal from "@app/ErrorModal.svelte";
+
  import Form from "@app/Form.svelte";
+
  import Link from "@app/router/Link.svelte";
  import Loading from "@app/Loading.svelte";
  import Modal from "@app/Modal.svelte";
-
  import Form from "@app/Form.svelte";
-
  import type { Field } from "@app/Form.svelte";
+
  import Update from "./Update.svelte";
  import { assert } from "@app/error";
-
  import ErrorModal from "@app/ErrorModal.svelte";
-
  import { isAddressEqual, isReverseRecordSet } from "@app/utils";
-
  import Button from "@app/Button.svelte";
  import { defaultHttpApiPort } from "@app/base/seeds/Seed";
-

  import { getRegistration, getOwner } from "./registrar";
-
  import type { EnsRecord } from "./resolver";
-
  import type { Registration } from "./registrar";
-
  import Update from "./Update.svelte";
+
  import { isAddressEqual, isReverseRecordSet } from "@app/utils";
+
  import { session } from "@app/session";

  enum Status {
    Loading,
@@ -34,6 +36,7 @@

  export let domain: string;
  export let wallet: Wallet;
+
  export let retry: boolean;

  domain = domain.toLowerCase();

@@ -154,7 +157,7 @@
    } else {
      state = { status: Status.NotFound };
    }
-
    if (window.history.state?.retry) retries -= 1;
+
    if (retry) retries -= 1;
    return r;
  }

@@ -178,11 +181,7 @@
      });
  };

-
  $: if (
-
    window.history.state?.retry &&
-
    state.status === Status.NotFound &&
-
    retries > 0
-
  ) {
+
  $: if (retry && state.status === Status.NotFound && retries > 0) {
    getRegistration(domain, wallet, resolver)
      .then(parseRecords)
      .catch(err => {
@@ -234,7 +233,11 @@
{:else if state.status === Status.Failed}
  <ErrorModal
    title="Registration could not be loaded"
-
    on:close={() => navigate("/registrations")}>
+
    on:close={() =>
+
      router.push({
+
        resource: "registrations",
+
        params: { view: { resource: "validateName" } },
+
      })}>
    {state.error}
  </ErrorModal>
{:else if state.status === Status.NotFound}
@@ -252,12 +255,18 @@
    </span>

    <span slot="actions">
-
      <a
-
        use:link
-
        href={`/registrations/${domain}/form`}
-
        class="txt-link register">
-
        Register &rarr;
-
      </a>
+
      <Link
+
        route={{
+
          resource: "registrations",
+
          params: {
+
            view: {
+
              resource: "register",
+
              params: { nameOrDomain: domain, owner: null },
+
            },
+
          },
+
        }}>
+
        <span class="txt-link register">Register &rarr;</span>
+
      </Link>
    </span>
  </Modal>
{:else if state.status === Status.Found}
modified src/base/seeds/Routes.svelte
@@ -1,17 +1,15 @@
<script lang="ts">
-
  import { Route } from "svelte-routing";
  import View from "@app/base/seeds/View.svelte";
  import type { Wallet } from "@app/wallet";
  import type { Session } from "@app/session";

  export let wallet: Wallet;
  export let session: Session | null;
+
  export let host: string;
</script>

-
<Route path="/seeds/radicle.local">
+
{#if host === "radicle.local"}
  <View {wallet} {session} hostAndPort={"0.0.0.0"} />
-
</Route>
-

-
<Route path="/seeds/:seed" let:params>
-
  <View {wallet} {session} hostAndPort={params.seed} />
-
</Route>
+
{:else}
+
  <View {wallet} {session} hostAndPort={host} />
+
{/if}
modified src/base/seeds/View/Projects.svelte
@@ -1,11 +1,12 @@
<script lang="ts">
-
  import { navigate } from "svelte-routing";
-
  import * as proj from "@app/project";
-
  import Widget from "@app/base/projects/Widget.svelte";
  import type { Profile } from "@app/profile";
  import type { ProjectInfo } from "@app/project";
  import type { Seed, Stats } from "@app/base/seeds/Seed";
+

+
  import * as proj from "@app/project";
+
  import * as router from "@app/router";
  import List from "@app/List.svelte";
+
  import Widget from "@app/base/projects/Widget.svelte";

  export let seed: Seed;
  export let profile: Profile | null = null;
@@ -35,16 +36,20 @@
  };

  const onClick = (project: ProjectInfo) => {
-
    navigate(
-
      proj.path({
+
    router.push({
+
      resource: "projects",
+
      params: {
+
        view: { resource: "tree" },
        urn: project.urn,
        seed: seed.api.port
          ? `${seed.api.host}:${seed.api.port}`
          : seed.api.host,
        profile: profile?.name ?? profile?.address,
-
        revision: project.head,
-
      }),
-
    );
+
        revision: project.head ?? undefined,
+
        hash: undefined,
+
        search: undefined,
+
      },
+
    });
  };
</script>

modified src/commit.ts
@@ -33,7 +33,7 @@ export interface CommitContext {
  committer?: {
    peer: {
      id: string;
-
      person: Person;
+
      person: Person | null;
      delegate: boolean;
    };
  };
modified src/components/Modal/SearchResults.svelte
@@ -1,12 +1,12 @@
<script lang="ts">
  import Modal from "@app/Modal.svelte";
-
  import { link } from "svelte-routing";
  import { formatRadicleUrn, getSeedEmoji } from "@app/utils";
  import type { Wallet } from "@app/wallet";
  import Address from "@app/Address.svelte";
  import Button from "@app/Button.svelte";
  import { createEventDispatcher } from "svelte";
  import type { ProjectsAndProfiles } from "@app/Search.svelte";
+
  import Link from "@app/router/Link.svelte";

  export let query: string;
  export let results: ProjectsAndProfiles;
@@ -48,7 +48,15 @@
      <ul>
        {#each results.projects as project}
          <li>
-
            <a use:link href="/seeds/{project.seed.host}/{project.info.urn}">
+
            <Link
+
              route={{
+
                resource: "projects",
+
                params: {
+
                  view: { resource: "tree" },
+
                  seed: project.seed.host,
+
                  urn: project.info.urn,
+
                },
+
              }}>
              <span title={project.seed.host}>
                <span>
                  {getSeedEmoji(project.seed.host)}&nbsp;{project.info.name}
@@ -57,7 +65,7 @@
                  &nbsp;{formatRadicleUrn(project.info.urn)}
                </span>
              </span>
-
            </a>
+
            </Link>
          </li>
        {/each}
      </ul>
modified src/ens/SetName.svelte
@@ -1,14 +1,16 @@
<script lang="ts">
-
  import { createEventDispatcher } from "svelte";
-
  import { navigate } from "svelte-routing";
-
  import Modal from "@app/Modal.svelte";
  import type { Wallet } from "@app/wallet";
-
  import { formatAddress, isAddressEqual } from "@app/utils";
  import type { User } from "@app/base/users/User";
-
  import ErrorModal from "@app/ErrorModal.svelte";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import * as router from "@app/router";
  import Button from "@app/Button.svelte";
-
  import TextInput from "@app/TextInput.svelte";
+
  import ErrorModal from "@app/ErrorModal.svelte";
  import Loading from "@app/Loading.svelte";
+
  import Modal from "@app/Modal.svelte";
+
  import TextInput from "@app/TextInput.svelte";
+
  import { formatAddress, isAddressEqual } from "@app/utils";

  const dispatch = createEventDispatcher();

@@ -99,7 +101,16 @@
    <div slot="actions">
      <Button
        variant="negative"
-
        on:click={() => navigate(`/registrations/${name}`)}>
+
        on:click={() =>
+
          router.push({
+
            resource: "registrations",
+
            params: {
+
              view: {
+
                resource: "view",
+
                params: { nameOrDomain: name, retry: false },
+
              },
+
            },
+
          })}>
        Go to registration &rarr;
      </Button>
      <Button variant="negative" on:click={() => dispatch("close")}>
modified src/index.ts
@@ -1,15 +1,5 @@
import App from "./App.svelte";

-
// Taken from svelte-routing, since it's not exported.
-
export interface RouteLocation {
-
  pathname: string;
-
  search: string;
-
  hash?: string;
-
  state: {
-
    [k in string | number]: unknown;
-
  };
-
}
-

const app = new App({
  target: document.body,
});
modified src/profile.ts
@@ -98,7 +98,7 @@ export class Profile {
  }

  // Get the name, and if not available, the address.
-
  get nameOrAddress(): string {
+
  get addressOrName(): string {
    return this.name ?? this.address;
  }

modified src/project.ts
@@ -1,5 +1,3 @@
-
import { navigate } from "svelte-routing";
-
import { get, writable } from "svelte/store";
import { type Host, Request } from "@app/api";
import type { Commit, CommitHeader, CommitsHistory } from "@app/commit";
import { isFulfilled, isOid, isRadicleId } from "@app/utils";
@@ -95,116 +93,6 @@ export interface Peer {
  delegate: boolean;
}

-
export interface Browser {
-
  content: ProjectContent;
-
  revision: string | null;
-
  issue: string | null;
-
  patch: string | null;
-
  peer: string | null;
-
  path: string | null;
-
  line: number | null;
-
  search: URLSearchParams | null;
-
}
-

-
export const browserStore = writable({
-
  content: ProjectContent.Tree,
-
  branches: {},
-
  revision: null,
-
  issue: null,
-
  patch: null,
-
  peer: null,
-
  path: null,
-
  line: null,
-
  search: null,
-
} as Browser);
-

-
export interface BrowseTo {
-
  content?: ProjectContent;
-
  revision?: string | null;
-
  issue?: string | null;
-
  patch?: string | null;
-
  path?: string | null;
-
  peer?: string | null;
-
  line?: number | null;
-
  search?: URLSearchParams | null;
-
}
-

-
export interface PathOptions extends BrowseTo {
-
  urn: string;
-
  profile?: string | null;
-
  seed?: string | null;
-
}
-

-
export function browse(browse: BrowseTo): void {
-
  const browser = get(browserStore);
-
  browserStore.set({ ...browser, ...browse });
-
}
-

-
export function path(opts: PathOptions): string {
-
  const { urn, profile, seed, peer, content, revision, path, issue, patch } =
-
    opts;
-
  const result = [];
-

-
  if (profile) {
-
    result.push(profile);
-
  } else if (seed) {
-
    result.push("seeds", seed);
-
  }
-
  result.push(urn);
-

-
  if (peer) {
-
    result.push("remotes", peer);
-
  }
-

-
  switch (content) {
-
    case ProjectContent.History:
-
      result.push("history");
-
      break;
-

-
    case ProjectContent.Commit:
-
      result.push("commits");
-
      break;
-

-
    case ProjectContent.Issues:
-
      result.push("issues");
-
      break;
-

-
    case ProjectContent.Issue:
-
      result.push("issues");
-
      break;
-

-
    case ProjectContent.Patches:
-
      result.push("patches");
-
      break;
-

-
    case ProjectContent.Patch:
-
      result.push("patches");
-
      break;
-

-
    default:
-
      result.push("tree");
-
      break;
-
  }
-

-
  if (issue) {
-
    result.push(issue);
-
  }
-

-
  if (patch) {
-
    result.push(patch);
-
  }
-

-
  if (revision) {
-
    result.push(revision);
-
  }
-

-
  // Avoids appending a slash when the path is the root directory.
-
  if (path && path !== "/") {
-
    result.push(path);
-
  }
-
  return "/" + result.join("/");
-
}
-

// 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?: Branches): string | null {
  if (isOid(revision)) return revision;
@@ -301,12 +189,7 @@ export class Project implements ProjectInfo {
  }

  static async getInfo(nameOrUrn: string, host: Host): Promise<ProjectInfo> {
-
    const info = await new Request(`projects/${nameOrUrn}`, host).get();
-

-
    return {
-
      ...info,
-
      ...info.meta, // Nb. This is only needed while we are upgrading to the new http-api.
-
    };
+
    return await new Request(`projects/${nameOrUrn}`, host).get();
  }

  static async getProjects(
@@ -415,31 +298,6 @@ export class Project implements ProjectInfo {
    ).get();
  }

-
  navigateTo(browse: BrowseTo): void {
-
    navigate(this.pathTo(browse));
-
  }
-

-
  pathTo(browse: BrowseTo): string {
-
    const browser = get(browserStore);
-
    const options: PathOptions = {
-
      urn: this.urn,
-
      ...browser,
-
      ...browse,
-
    };
-

-
    if (this.profile) {
-
      options.profile = this.profile?.nameOrAddress;
-
    } else {
-
      if (this.seed.api.port) {
-
        options.seed = `${this.seed.api.host}:${this.seed.api.port}`;
-
      } else {
-
        options.seed = this.seed.host;
-
      }
-
    }
-

-
    return path(options);
-
  }
-

  static async get(
    id: string,
    peer: string | null,
added src/router/Link.svelte
@@ -0,0 +1,22 @@
+
<script lang="ts" strictEvents>
+
  import { createEventDispatcher } from "svelte";
+
  import type { Route } from "./definitions";
+
  import { push, routeToPath } from "./index";
+

+
  export let route: Route;
+
  export let title: string | null = null;
+
  export let id: string | null = null;
+

+
  const dispatch = createEventDispatcher<{
+
    click: never;
+
  }>();
+

+
  function onClick(): void {
+
    push(route);
+
    dispatch("click");
+
  }
+
</script>
+

+
<a on:click|preventDefault={onClick} {title} {id} href={routeToPath(route)}>
+
  <slot />
+
</a>
added src/router/ProjectLink.svelte
@@ -0,0 +1,16 @@
+
<script lang="ts">
+
  import type { ProjectsParams } from "./definitions";
+
  import { updateProjectRoute, projectLinkHref } from "./index";
+

+
  export let projectParams: Partial<ProjectsParams>;
+
  export let id: string | undefined = undefined;
+
</script>
+

+
<a
+
  {id}
+
  on:click|preventDefault={() => {
+
    updateProjectRoute(projectParams);
+
  }}
+
  href={projectLinkHref(projectParams)}>
+
  <slot />
+
</a>
added src/router/definitions.ts
@@ -0,0 +1,61 @@
+
export type Route =
+
  | FaucetRoute
+
  | ProjectRoute
+
  | RegistrationRoute
+
  | { resource: "home" }
+
  | { resource: "404"; params: { url: string } }
+
  | { resource: "profile"; params: { addressOrName: string } }
+
  | { resource: "seeds"; params: { host: string } }
+
  | { resource: "vesting" };
+

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

+
export interface FaucetParams {
+
  view:
+
    | { resource: "form" }
+
    | { resource: "withdraw"; params: { amount: string | null } };
+
}
+

+
export interface RegistrationParams {
+
  view:
+
    | {
+
        resource: "validateName";
+
      }
+
    | {
+
        resource: "checkNameAvailability";
+
        params: { nameOrDomain: string; owner: string | null };
+
      }
+
    | {
+
        resource: "register";
+
        params: { nameOrDomain: string; owner: string | null };
+
      }
+
    | {
+
        resource: "view";
+
        params: { nameOrDomain: string; retry: boolean };
+
      };
+
}
+

+
export type ProjectRoute = { resource: "projects"; params: ProjectsParams };
+
export type FaucetRoute = { resource: "faucet"; params: FaucetParams };
+
export type RegistrationRoute = {
+
  resource: "registrations";
+
  params: RegistrationParams;
+
};
added src/router/index.test.ts
@@ -0,0 +1,239 @@
+
import { describe, expect, test } from "vitest";
+
import { routeToPath } from "./index";
+
import { testExports } from "./index";
+

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

+
describe("routeToPath", () => {
+
  test.each([
+
    { input: { resource: "home" }, output: "/", description: "Home Route" },
+
    {
+
      input: { resource: "vesting" },
+
      output: "/vesting",
+
      description: "Vesting Route",
+
    },
+
    {
+
      input: { resource: "faucet", params: { view: { resource: "form" } } },
+
      output: "/faucet",
+
      description: "Faucet Form Route",
+
    },
+
    {
+
      input: {
+
        resource: "profile",
+
        params: { addressOrName: "cloudhead.eth" },
+
      },
+
      output: "/cloudhead.eth",
+
      description: "Profile Route",
+
    },
+
    {
+
      input: { resource: "seeds", params: { host: "willow.radicle.garden" } },
+
      output: "/seeds/willow.radicle.garden",
+
      description: "Seed View Route",
+
    },
+
    {
+
      input: {
+
        resource: "registrations",
+
        params: {
+
          view: { resource: "validateName" },
+
        },
+
      },
+
      output: "/registrations",
+
      description: "registrations Index Route",
+
    },
+
    {
+
      input: {
+
        resource: "registrations",
+
        params: {
+
          view: {
+
            resource: "view",
+
            params: { nameOrDomain: "sebastinez", retry: true },
+
          },
+
        },
+
      },
+
      output: "/registrations/sebastinez?retry=true",
+
      description: "registrations View Route",
+
    },
+
    {
+
      input: {
+
        resource: "registrations",
+
        params: {
+
          view: {
+
            resource: "view",
+
            params: { nameOrDomain: "sebastinez", retry: false },
+
          },
+
        },
+
      },
+
      output: "/registrations/sebastinez?retry=false",
+
      description: "registrations View Route",
+
    },
+
    {
+
      input: {
+
        resource: "registrations",
+
        params: {
+
          view: {
+
            resource: "checkNameAvailability",
+
            params: {
+
              nameOrDomain: "sebastinez",
+
            },
+
          },
+
        },
+
      },
+
      output: "/registrations/sebastinez/checkNameAvailability",
+
      description: "registrations Form Route",
+
    },
+
    {
+
      input: {
+
        resource: "registrations",
+
        params: {
+
          view: {
+
            resource: "register",
+
            params: { nameOrDomain: "sebastinez" },
+
          },
+
        },
+
      },
+
      output: "/registrations/sebastinez/register",
+
      description: "registrations Submit Route",
+
    },
+
    {
+
      input: {
+
        resource: "projects",
+
        params: {
+
          view: { resource: "tree" },
+
          seed: "willow.radicle.garden",
+
          urn: "rad:git:hnrkmg77m8tfzj4gi4pa4mbhgysfgzwntjpao",
+
        },
+
      },
+
      output:
+
        "/seeds/willow.radicle.garden/rad:git:hnrkmg77m8tfzj4gi4pa4mbhgysfgzwntjpao/tree",
+
      description: "Seed Project Route",
+
    },
+
  ])("$description", (route: any) => {
+
    expect(routeToPath(route.input)).toEqual(route.output);
+
  });
+
});
+

+
describe("pathToRoute", () => {
+
  test.each([
+
    { input: "", output: null, description: "Empty 404 Route" },
+
    {
+
      input: "/foo/baz/bar",
+
      output: null,
+
      description: "Non existant 404 Route",
+
    },
+
    { input: "/", output: { resource: "home" }, description: "Home Route" },
+
    {
+
      input: "/vesting",
+
      output: { resource: "vesting" },
+
      description: "Vesting Route",
+
    },
+
    {
+
      input: "/faucet",
+
      output: { resource: "faucet", params: { view: { resource: "form" } } },
+
      description: "Faucet Form Route",
+
    },
+
    {
+
      input: "/faucet/withdraw?amount=10",
+
      output: {
+
        resource: "faucet",
+
        params: { view: { resource: "withdraw", params: { amount: "10" } } },
+
      },
+
      description: "Faucet Withdraw Route",
+
    },
+
    {
+
      input: "/cloudhead.eth",
+
      output: {
+
        resource: "profile",
+
        params: { addressOrName: "cloudhead.eth" },
+
      },
+
      description: "Profile Route",
+
    },
+
    {
+
      input: "/seeds/willow.radicle.garden",
+
      output: { resource: "seeds", params: { host: "willow.radicle.garden" } },
+
      description: "Seed View Route",
+
    },
+
    {
+
      input: "/registrations",
+
      output: {
+
        resource: "registrations",
+
        params: {
+
          view: { resource: "validateName" },
+
        },
+
      },
+
      description: "registrations Index Route",
+
    },
+
    {
+
      input: "/registrations/sebastinez",
+
      output: {
+
        resource: "registrations",
+
        params: {
+
          view: {
+
            resource: "view",
+
            params: { nameOrDomain: "sebastinez", retry: false },
+
          },
+
        },
+
      },
+
      description: "registrations View Route",
+
    },
+
    {
+
      input: "/registrations/sebastinez?retry=true",
+
      output: {
+
        resource: "registrations",
+
        params: {
+
          view: {
+
            resource: "view",
+
            params: { nameOrDomain: "sebastinez", retry: true },
+
          },
+
        },
+
      },
+
      description: "registrations View Route",
+
    },
+
    {
+
      input: "/registrations/sebastinez/checkNameAvailability",
+
      output: {
+
        resource: "registrations",
+
        params: {
+
          view: {
+
            resource: "checkNameAvailability",
+
            params: {
+
              nameOrDomain: "sebastinez",
+
              owner: null,
+
            },
+
          },
+
        },
+
      },
+
      description: "registrations Form Route",
+
    },
+
    {
+
      input: "/registrations/sebastinez/register",
+
      output: {
+
        resource: "registrations",
+
        params: {
+
          view: {
+
            resource: "register",
+
            params: { nameOrDomain: "sebastinez", owner: null },
+
          },
+
        },
+
      },
+
      description: "registrations Submit Route",
+
    },
+
    {
+
      input:
+
        "/seeds/willow.radicle.garden/rad:git:hnrkmg77m8tfzj4gi4pa4mbhgysfgzwntjpao",
+
      output: {
+
        resource: "projects",
+
        params: {
+
          view: { resource: "tree" },
+
          seed: "willow.radicle.garden",
+
          profile: undefined,
+
          peer: undefined,
+
          urn: "rad:git:hnrkmg77m8tfzj4gi4pa4mbhgysfgzwntjpao",
+
        },
+
      },
+
      description: "Seed Project Route",
+
    },
+
  ])("$description", (route: any) => {
+
    expect(testExports.pathToRoute(route.input)).toEqual(route.output);
+
  });
+
});
added src/router/index.ts
@@ -0,0 +1,416 @@
+
import type { ProjectsParams, Route, ProjectRoute } from "./definitions";
+
import type { Readable } from "svelte/store";
+

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

+
// This is only respected by Safari.
+
const documentTitle = "Radicle Interface";
+

+
export const historyStore = writable<Route[]>([{ resource: "home" }]);
+

+
export const activeRouteStore: Readable<Route> = derived(
+
  historyStore,
+
  store => {
+
    return store.slice(-1)[0];
+
  },
+
);
+

+
// 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,
+
      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 function updateProjectRoute(
+
  projectRouteParams: Partial<ProjectsParams>,
+
) {
+
  const activeRoute = get(activeRouteStore);
+

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

+
export const push = (newRoute: Route): void => {
+
  const history = get(historyStore);
+

+
  // 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));
+
  window.history.pushState(newRoute, documentTitle, routeToPath(newRoute));
+
};
+

+
export const pop = (): void => {
+
  const history = get(historyStore);
+
  const newRoute = history.pop();
+
  if (newRoute) {
+
    historyStore.set(history);
+
    window.history.back();
+
  }
+
};
+

+
export function replace(newRoute: Route): void {
+
  historyStore.set([newRoute]);
+
  window.history.replaceState(newRoute, documentTitle, routeToPath(newRoute));
+
}
+

+
export const initialize = () => {
+
  const { pathname, search, hash } = window.location;
+
  const url = pathname + search + hash;
+
  const route = pathToRoute(url);
+

+
  if (route) {
+
    replace(route);
+
  } else {
+
    replace({ resource: "404", params: { url } });
+
  }
+
};
+

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

+
  const url = new URL(path, window.origin);
+
  // Pathname starts usually with a "/", we remove it to avoid bad interpretations
+
  const segments = url.pathname.substring(1).split("/");
+

+
  const resource = segments.shift();
+
  switch (resource) {
+
    case "registrations": {
+
      const nameOrDomain = segments.shift();
+
      const view = segments.shift();
+
      const owner = url.searchParams.get("owner");
+
      const retry = url.searchParams.get("retry");
+

+
      if (nameOrDomain) {
+
        if (view === "checkNameAvailability" || view === "register") {
+
          return {
+
            resource: "registrations",
+
            params: {
+
              view: {
+
                resource: view,
+
                params: { nameOrDomain, owner },
+
              },
+
            },
+
          };
+
        } else if (!view) {
+
          return {
+
            resource: "registrations",
+
            params: {
+
              view: {
+
                resource: "view",
+
                params: { nameOrDomain, retry: retry === "true" },
+
              },
+
            },
+
          };
+
        }
+
      }
+
      return {
+
        resource: "registrations",
+
        params: { view: { resource: "validateName" } },
+
      };
+
    }
+
    case "faucet": {
+
      const view = segments.shift();
+
      if (view === "withdraw") {
+
        return {
+
          resource: "faucet",
+
          params: {
+
            view: {
+
              resource: "withdraw",
+
              params: { amount: url.searchParams.get("amount") },
+
            },
+
          },
+
        };
+
      }
+
      return { resource: "faucet", params: { view: { resource: "form" } } };
+
    }
+
    case "vesting":
+
      return { resource: "vesting" };
+
    case "seeds": {
+
      const host = segments.shift();
+
      if (host) {
+
        const urn = segments.shift();
+
        if (urn) {
+
          if (segments.length === 0) {
+
            return {
+
              resource: "projects",
+
              params: {
+
                view: { resource: "tree" },
+
                urn,
+
                peer: undefined,
+
                profile: undefined,
+
                seed: host,
+
              },
+
            };
+
          }
+
          const params = resolveProjectRoute(url, urn, segments);
+
          if (params) {
+
            return {
+
              resource: "projects",
+
              params: {
+
                ...params,
+
                search: url.search,
+
                seed: host,
+
                urn,
+
              },
+
            };
+
          }
+
          return null;
+
        }
+
        return { resource: "seeds", params: { host } };
+
      }
+
      return null;
+
    }
+
    case "":
+
      return { resource: "home" };
+
    default: {
+
      if (resource) {
+
        const urn = segments.shift();
+
        if (urn) {
+
          if (segments.length === 0) {
+
            return {
+
              resource: "projects",
+
              params: {
+
                view: { resource: "tree" },
+
                urn,
+
                peer: undefined,
+
                profile: resource,
+
                seed: undefined,
+
              },
+
            };
+
          } else {
+
            const params = resolveProjectRoute(url, urn, segments);
+
            if (params) {
+
              return {
+
                resource: "projects",
+
                params: {
+
                  ...params,
+
                  urn,
+
                  search: url.search,
+
                  profile: resource,
+
                },
+
              };
+
            }
+
          }
+
          return null;
+
        }
+
        return { resource: "profile", params: { addressOrName: resource } };
+
      }
+
      return { resource: "home" };
+
    }
+
  }
+
}
+

+
export function routeToPath(route: Route) {
+
  if (route.resource === "home") {
+
    return "/";
+
  } else if (route.resource === "faucet") {
+
    if (route.params.view.resource === "form") {
+
      return "/faucet";
+
    } else if (route.params.view.resource === "withdraw") {
+
      return `/faucet/withdraw?amount=${route.params.view.params.amount}`;
+
    }
+
  } else if (route.resource === "vesting") {
+
    return "/vesting";
+
  } else if (route.resource === "seeds") {
+
    return `/seeds/${route.params.host}`;
+
  } else if (route.resource === "projects") {
+
    let hostPrefix;
+
    if (route.params.profile) {
+
      hostPrefix = `/${route.params.profile}`;
+
    } else {
+
      hostPrefix = `/seeds/${route.params.seed}`;
+
    }
+

+
    const content = `/${route.params.view.resource}`;
+

+
    let peer = "";
+
    if (route.params.peer) {
+
      peer = `/remotes/${route.params.peer}`;
+
    }
+

+
    let suffix = "";
+
    if (!route.params.route) {
+
      if (route.params.revision) {
+
        suffix = `/${route.params.revision}`;
+
      }
+
      if (route.params.path && route.params.path !== "/") {
+
        suffix += `/${route.params.path}`;
+
      }
+
      if (route.params.hash) {
+
        suffix += `#${route.params.hash}`;
+
      }
+
      if (route.params.search) {
+
        suffix += `${route.params.search}`;
+
      }
+
    } else {
+
      suffix = `/${route.params.route}`;
+
      if (route.params.search) {
+
        suffix += `${route.params.search}`;
+
      }
+
      if (route.params.hash) {
+
        suffix += `#${route.params.hash}`;
+
      }
+
    }
+

+
    if (route.params.view.resource === "tree") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/tree${suffix}`;
+
    } else if (route.params.view.resource === "commits") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/commits${suffix}`;
+
    } else if (route.params.view.resource === "history") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/history${suffix}`;
+
    } else if (route.params.view.resource === "patches") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/patches${suffix}`;
+
    } else if (route.params.view.resource === "patch") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/patches/${route.params.view.params.patch}`;
+
    } else if (route.params.view.resource === "issues") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/issues${suffix}`;
+
    } else if (route.params.view.resource === "issue") {
+
      return `${hostPrefix}/${route.params.urn}${peer}/issues/${route.params.view.params.issue}`;
+
    } else {
+
      return `${hostPrefix}/${route.params.urn}${peer}${content}`;
+
    }
+
  } else if (route.resource === "registrations") {
+
    if (route.params.view.resource === "validateName") {
+
      return `/registrations`;
+
    } else if (route.params.view.resource === "view") {
+
      return `/registrations/${route.params.view.params.nameOrDomain}?retry=${route.params.view.params.retry}`;
+
    } else if (
+
      route.params.view.resource === "checkNameAvailability" ||
+
      route.params.view.resource === "register"
+
    ) {
+
      if (route.params.view.params.owner) {
+
        return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}?owner=${route.params.view.params.owner}`;
+
      }
+
      return `/registrations/${route.params.view.params.nameOrDomain}/${route.params.view.resource}`;
+
    }
+
  } else if (route.resource === "profile") {
+
    return `/${route.params.addressOrName}`;
+
  } else if (route.resource === "404") {
+
    return route.params.url;
+
  } else {
+
    unreachable(route);
+
  }
+
}
+

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

+
  if (content === "tree") {
+
    return {
+
      view: { resource: "tree" },
+
      urn,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      hash: url.hash.substring(1),
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "history") {
+
    return {
+
      view: { resource: "history" },
+
      urn,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "commits") {
+
    return {
+
      view: { resource: "commits" },
+
      urn,
+
      peer,
+
      path: undefined,
+
      revision: undefined,
+
      route: segments.join("/"),
+
    };
+
  } else if (content === "patches") {
+
    const patch = segments.shift();
+
    if (patch) {
+
      return {
+
        view: { resource: "patch", params: { patch } },
+
        urn,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    } else {
+
      return {
+
        view: { resource: "patches" },
+
        urn,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    }
+
  } else if (content === "issues") {
+
    const issue = segments.shift();
+
    if (issue) {
+
      return {
+
        view: { resource: "issue", params: { issue } },
+
        urn,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    } else {
+
      return {
+
        view: { resource: "issues" },
+
        urn,
+
        peer,
+
        path: undefined,
+
        revision: undefined,
+
      };
+
    }
+
  }
+

+
  return null;
+
}
+

+
export const testExports = { pathToRoute };
modified src/utils.ts
@@ -1,5 +1,4 @@
import { ethers } from "ethers";
-
import type { RouteLocation } from "@app/index";
import md5 from "md5";
import { BigNumber } from "ethers";
import katex from "katex";
@@ -282,15 +281,6 @@ export function isFulfilled<T>(
  return input.status === "fulfilled";
}

-
// Get search parameters from location.
-
export function getSearchParam(
-
  key: string,
-
  location: RouteLocation,
-
): string | null {
-
  const params = new URLSearchParams(location.search);
-
  return params.get(key);
-
}
-

// Get the explorer link of an address, eg. Etherscan.
export function explorerLink(addr: string, wallet: Wallet): string {
  if (wallet.network.name === "goerli") {
@@ -322,6 +312,11 @@ export function parseEmoji(input: string): string {
  return input;
}

+
export function scrollIntoView(id: string) {
+
  const lineElement = document.getElementById(id);
+
  if (lineElement) lineElement.scrollIntoView();
+
}
+

export function getSeedEmoji(seedHost: string): string {
  const seed = config.seeds.pinned.find(s => s.host === seedHost);

modified vite.config.ts
@@ -6,11 +6,7 @@ import pluginRewriteAll from "vite-plugin-rewrite-all";

const config: UserConfig = {
  optimizeDeps: {
-
    exclude: [
-
      "svelte-routing",
-
      "@pedrouid/environment",
-
      "@pedrouid/iso-crypto",
-
    ],
+
    exclude: ["@pedrouid/environment", "@pedrouid/iso-crypto"],
  },
  test: {
    deps: {