Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Refactor project reactivity
Alexis Sellier committed 4 years ago
commit b9c4cda4cf31ec4a5cd2cbd64a6165cfb81a904d
parent 88cc74dc022c003ac2d5706afee40dab957d0f3a
16 files changed +367 -298
modified src/base/orgs/View/Projects.svelte
@@ -36,7 +36,7 @@
      proj.path({
        urn: project.urn,
        seed: seed?.host,
-
        addressOrName: profile?.name ?? profile?.address,
+
        profile: profile?.name ?? profile?.address,
        revision: project.head,
      })
    );
modified src/base/projects/BranchSelector.spec.ts
@@ -15,9 +15,7 @@ const defaultProps = {
      "hyn9diwfnytahjq8u3iw63h9jte1ydcatxax3saymwdxqu1zo645pe"
    ]
  },
-
  branches: [
-
    ["master", "e678629cd37c770c640a2cd997fc76303c815772"]
-
  ],
+
  branches: { "master": "e678629cd37c770c640a2cd997fc76303c815772" },
  revision: "e678629cd37c770c640a2cd997fc76303c815772",
};

@@ -25,8 +23,7 @@ describe('BranchSelector', function () {
  it("Render with commit = head and branch listing", () => {
    mount(BranchSelector, {
      props: defaultProps
-
    }, styles
-
    );
+
    }, styles);
    cy.findByText("master").should("exist");
    cy.get("div.commit > div.hash.desktop").should("have.text", "e678629");
  });
@@ -36,19 +33,18 @@ describe('BranchSelector', function () {
    cy.viewport(800, 300);
    mount(BranchSelector, {
      props: {
-
        ...defaultProps, branches: [
-
          ["master", "e678629cd37c770c640a2cd997fc76303c815772"],
-
          ["feature-branch", "29e8b7b0f3019b8e8a6d9bfb0964ee78f4ff12f5"],
-
          ["xyz", "debf82ef3623ec11751a993bda85bac2ff1c6f00"],
-
        ],
+
        ...defaultProps, branches: {
+
          "master": "e678629cd37c770c640a2cd997fc76303c815772",
+
          "feature-branch": "29e8b7b0f3019b8e8a6d9bfb0964ee78f4ff12f5",
+
          "xyz": "debf82ef3623ec11751a993bda85bac2ff1c6f00",
+
        },
        branchesDropdown: true,
      }, callbacks: {
-
        revisionChanged: cy.stub().as("revisionChanged")
+
        branchChanged: cy.stub().as("branchChanged")
      }
-
    }, styles
-
    );
+
    }, styles);
    cy.get("div.dropdown > div").first().click();
-
    cy.get("@revisionChanged")
+
    cy.get("@branchChanged")
      .should("be.calledOnce")
      .its("firstCall.args.0.detail")
      .should("equal", "feature-branch");
@@ -57,16 +53,14 @@ describe('BranchSelector', function () {
  it("Render with commit != head, passing a branch as rev and branch listing", () => {
    mount(BranchSelector, {
      props: {
-
        ...defaultProps, branches: [
-
          ["master", "e678629cd37c770c640a2cd997fc76303c815772"],
-
          ["feature-branch", "29e8b7b0f3019b8e8a6d9bfb0964ee78f4ff12f5"],
-
          ["xyz", "debf82ef3623ec11751a993bda85bac2ff1c6f00"],
-
          ["xyz", "8b18e8006849e36872cb928d87179e4e222fb689"]
-
        ],
+
        ...defaultProps, branches: {
+
          "master": "e678629cd37c770c640a2cd997fc76303c815772",
+
          "feature-branch": "29e8b7b0f3019b8e8a6d9bfb0964ee78f4ff12f5",
+
          "xyz": "debf82ef3623ec11751a993bda85bac2ff1c6f00",
+
        },
        revision: "feature-branch"
      }
-
    }, styles
-
    );
+
    }, styles);
    cy.findByText("feature-branch").should("exist");
    cy.get("div.commit > div.hash.desktop").should("have.text", "29e8b7b");
  });
@@ -87,12 +81,11 @@ describe('BranchSelector', function () {
      props: {
        ...defaultProps,
        revision: "e678629cd37c770c640a2cd997fc76303c815772",
-
        branches: [],
+
        branches: {},
      }
    }, styles
    );
-
    cy.findByText("master").should("exist");
-
    cy.get("div.commit > div.hash").should("have.text", "e678629");
+
    cy.get("div.commit > div.hash.desktop").should("have.text", "e678629cd37c770c640a2cd997fc76303c815772");
  });

  it("Render without branch listing, commit != head", () => {
@@ -100,10 +93,9 @@ describe('BranchSelector', function () {
      props: {
        ...defaultProps,
        revision: "6b84e519d3c535879eb2b9ee8457bb70ca275a75",
-
        branches: [],
+
        branches: {},
      }
-
    }, styles
-
    );
+
    }, styles);
    cy.get("div.commit > div.hash.desktop").should("have.text", "6b84e519d3c535879eb2b9ee8457bb70ca275a75");
  });
});
modified src/base/projects/BranchSelector.svelte
@@ -1,10 +1,10 @@
<script lang="ts">
  import { createEventDispatcher } from "svelte";
-
  import { ProjectInfo, getOid } from "@app/project";
+
  import { ProjectInfo, Branches, getOid } from "@app/project";
  import { formatCommit, isOid } from "@app/utils";
  import Dropdown from "@app/Dropdown.svelte";

-
  export let branches: [string, string][];
+
  export let branches: Branches;
  export let project: ProjectInfo;
  export let revision: string;
  export let toggleDropdown: (input: string) => void;
@@ -12,25 +12,17 @@

  const dispatch = createEventDispatcher();
  const switchBranch = (name: string) => {
-
    dispatch("revisionChanged", name);
+
    dispatch("branchChanged", name);
  };
-
  const showSelector = branches.length > 1;
+
  let branchLabel: string | null = null;

-
  // Sort branches array alphabetically
-
  const sortBranches = ([firstBranchName,]: [string, string], [secondBranchName,]: [string, string]) => {
-
    if (firstBranchName < secondBranchName) return -1;
-
    if (firstBranchName > secondBranchName) return 1;
-
    return 0;
-
  };
-

-
  let branchLabel: string;
-
  branches = branches.sort(sortBranches);
-

-
  $: commit = getOid(project.head, revision, branches);
-
  $: isLabel = commit == project.head || !isOid(revision);
-
  $: if (commit == project.head) {
+
  $: branchList = Object.keys(branches).sort();
+
  $: showSelector = branchList.length > 1;
+
  $: head = branches[project.defaultBranch];
+
  $: commit = getOid(revision, branches) || head;
+
  $: if (commit == head) {
    branchLabel = project.defaultBranch;
-
  } else if (!isOid(revision)) {
+
  } else if (! isOid(revision)) {
    branchLabel = revision;
  }
</script>
@@ -60,10 +52,10 @@
    color: var(--color-secondary);
    background-color: var(--color-secondary-background-darker);
    padding: 0.5rem 0.75rem;
-
    border-radius: inherit;
+
    border-radius: 0 0.25rem 0.25rem 0;
  }
-
  .hidden {
-
    display: none;
+
  .commit .hash.unlabeled {
+
    border-radius: 0.25rem;
  }
  .stat {
    font-family: var(--font-family-monospace);
@@ -74,33 +66,34 @@

<div class="commit">
  <!-- Check for branches listing feature -->
-
  {#if branches.length > 0}
-
    <span>
-
      <div
-
        class="stat branch"
-
        class:not-allowed={!showSelector}
-
        class:hidden={!isLabel}
-
        on:click={() => showSelector && toggleDropdown("branch")}
-
      >
-
        {branchLabel}
-
      </div>
-
      <Dropdown
-
        items={branches.map(([name,]) => name)}
-
        visible={branchesDropdown}
-
        on:select={(e) => switchBranch(e.detail)} />
-
    </span>
-
    <div class="hash desktop">
-
      {#if isLabel}
+
  {#if branchList.length > 0}
+
    {#if branchLabel}
+
      <span>
+
        <div
+
          class="stat branch"
+
          class:not-allowed={!showSelector}
+
          on:click={() => showSelector && toggleDropdown("branch")}
+
        >
+
          {branchLabel}
+
        </div>
+
        <Dropdown
+
          items={branchList}
+
          visible={branchesDropdown}
+
          on:select={(e) => switchBranch(e.detail)} />
+
      </span>
+
      <div class="hash desktop">
        {formatCommit(commit)}
-
      {:else}
+
      </div>
+
    {:else}
+
      <div class="unlabeled hash desktop">
        {commit}
-
      {/if}
-
    </div>
+
      </div>
+
    {/if}
    <div class="hash mobile">
      {formatCommit(commit)}
    </div>
  <!-- If there is no branch listing available, show default branch name if commit is head and else show entire commit -->
-
  {:else if commit === project.head}
+
  {:else if commit === head}
    <div class="stat branch not-allowed">
      {project.defaultBranch}
    </div>
@@ -108,7 +101,7 @@
      {formatCommit(commit)}
    </div>
  {:else}
-
    <div class="hash desktop">
+
    <div class="unlabeled hash desktop">
      {commit}
    </div>
    <div class="hash mobile">
modified src/base/projects/Browser.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import { navigate } from 'svelte-routing';
+
  import type { Readable } from 'svelte/store';
  import * as proj from '@app/project';
  import Loading from '@app/Loading.svelte';
  import * as utils from '@app/utils';
@@ -13,25 +13,19 @@
    Loaded,
  }

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

  export let source: proj.Source;
  export let tree: proj.Tree;
-
  export let locator: string; // eg. "master/README.md"
-
  export let content: proj.ProjectContent;
-
  export let revision: string;
-
  export let path: string;
+
  export let browserStore: Readable<proj.Browser>;

-
  let { urn, addressOrName, seed, peer, project, branches } = source;
+
  const { urn, project } = source;

-
  // This is reactive to respond to path changes that don't originate from this
-
  // component, eg. when using the browser's "back" button.
-
  $: [revision_, path_] = proj.splitPrefixFromPath(locator, branches, project.head);
-
  // Bind content to file tree to trigger updates in parent components.
-
  $: content = proj.ProjectContent.Tree;
-
  $: revision = revision_;
-
  $: path = path_;
+
  $: browser = $browserStore;
+
  $: path = browser.path || "/";
+
  $: revision = browser.revision || browser.branches[project.head];

  // When the component is loaded the first time, the blob is yet to be loaded.
  let state: State = { status: Status.Loading, path };
@@ -63,29 +57,21 @@

    // Close mobile tree if user navigates to other file
    mobileFileTree = false;
-
    navigateBrowser(revision, newPath);
-
  };
-

-
  const navigateBrowser = (revision: string, path?: string) => {
-
    // Replaces path with current path if none passed.
-
    if (path === undefined) path = state.path;

-
    if (addressOrName) {
-
      navigate(proj.path({ peer, urn, addressOrName, revision, path }));
-
    } else {
-
      navigate(proj.path({ peer, urn, seed: seed.host, revision, path }));
+
    if (path) {
+
      proj.navigateTo({ path: newPath, revision }, source);
    }
  };

  const fetchTree = async (path: string) => {
-
    return proj.getTree(urn, commit, path, seed.api);
+
    return proj.getTree(urn, commit, path, source.seed.api);
  };

  const toggleMobileFileTree = () => {
    mobileFileTree = !mobileFileTree;
  };

-
  $: commit = proj.getOid(project.head, revision, branches);
+
  $: commit = proj.getOid(revision, browser.branches) || project.head;
  $: getBlob = loadBlob(path);
  $: loadingPath = state.status == Status.Loading ? state.path : null;
</script>
modified src/base/projects/Commit.svelte
@@ -1,31 +1,22 @@
<script lang="ts">
  import * as proj from "@app/project";
  import Changeset from "@app/base/projects/SourceBrowser/Changeset.svelte";
-
  import { navigate } from "svelte-routing";
  import { formatCommitTime } from "@app/commit";
  import { formatCommit } from "@app/utils";

-
  export let content: proj.ProjectContent;
-
  export let revision: string;
-
  export let locator: string;
  export let source: proj.Source;
+
  export let commit: string;

-
  const { addressOrName, peer, seed, project, urn, branches } = source;
+
  proj.browse({ content: proj.ProjectContent.Commit });

-
  const navigateCommit = (path: string, content?: proj.ProjectContent) => {
-
    // Replaces path with current path if none passed.
-
    if (path === undefined) path = "/";
-

-
    if (addressOrName) {
-
      navigate(proj.path({ content, peer, urn, addressOrName, revision, path }));
-
    } else {
-
      navigate(proj.path({ content, peer, urn, seed: seed.host, revision, path }));
-
    }
+
  const { seed, urn } = source;
+
  const onBrowse = (event: { detail: string }) => {
+
    proj.navigateTo({
+
      content: proj.ProjectContent.Tree,
+
      revision: commit,
+
      path: event.detail
+
    }, source);
  };
-

-
  $: [revision_,] = proj.splitPrefixFromPath(locator, branches, project.head);
-
  $: content = proj.ProjectContent.Commit;
-
  $: revision = revision_;
</script>

<style>
@@ -72,7 +63,7 @@
  }
</style>

-
{#await proj.getCommit(urn, revision, seed.api) then commit}
+
{#await proj.getCommit(urn, commit, seed.api) then commit}
  <div class="commit">
    <header>
      <div class="summary">
@@ -95,7 +86,7 @@
        <span class="font-mono email desktop-inline">&lt;{commit.header.author.email}&gt;</span>
      </div>
    </header>
-
    <Changeset stats={commit.stats} diff={commit.diff} on:browse={(event) => navigateCommit(event.detail)} />
+
    <Changeset stats={commit.stats} diff={commit.diff} on:browse={onBrowse} />
  </div>
{:catch err}
  <div class="commit">
modified src/base/projects/Header.svelte
@@ -1,24 +1,30 @@
<script lang="ts">
+
  import type { Writable } from 'svelte/store';
  import { navigate } from 'svelte-routing';
  import * as utils from '@app/utils';
-
  import { ProjectContent, getOid, Source } from '@app/project';
+
  import * as proj from '@app/project';
+
  import { Browser, ProjectContent, Source } from '@app/project';
  import AnchorBadge from '@app/base/profiles/AnchorBadge.svelte';
  import type { Tree } from "@app/project";
  import BranchSelector from './BranchSelector.svelte';
  import PeerSelector from './PeerSelector.svelte';
-
  import { createEventDispatcher } from 'svelte';
-

-
  const dispatch = createEventDispatcher();

  export let source: Source;
-
  export let path: string;
  export let tree: Tree;
-
  export let content: ProjectContent;
-
  export let revision: string;
-
  // If peerSelector should be showed.
-
  export let peerSelector: boolean;
+
  export let commit: string;
+
  export let browserStore: Writable<Browser>;
+
  export let peerSelector: boolean; // If peerSelector should be showed.
+
  export let branches: proj.Branches;
+

+
  let { urn, project, peers, seed, anchors } = source;

-
  let { urn, peer, project, branches, peers, seed, anchors } = source;
+
  $: browser = $browserStore;
+
  $: revision = browser.revision || commit;
+
  $: content = browser.content;
+

+
  $: if (Object.keys(browser.branches).length > 0) {
+
    branches = browser.branches;
+
  }

  let dropdownState: { [key: string]: boolean } = { clone: false, seed: false, branch: false, peer: false };
  function toggleDropdown(input: string) {
@@ -28,22 +34,22 @@
    });
  }

-
  // Switches between the browser and commit view
+
  // Switches between the browser and commit view.
  const toggleContent = (input: ProjectContent) => {
-
    dispatch("routeParamsChange", { urn, content: content === input ? ProjectContent.Tree : input, revision, peer, path });
+
    proj.navigateTo({
+
      content: content === input ? ProjectContent.Tree : input
+
    }, source);
  };

-
  const updatePeer = (newPeer: string) => {
+
  const updatePeer = (peer: string) => {
    dropdownState.peer = false;
-
    dispatch("routeParamsChange", { urn, content, revision, peer: newPeer, path });
+
    proj.navigateTo({ peer, revision: null }, source);
  };

-
  const updateRevision = (newRevision: string) => {
+
  const updateRevision = (revision: string) => {
    dropdownState.branch = false;
-
    dispatch("routeParamsChange", { urn, content, revision: newRevision, peer, path });
+
    proj.navigateTo({ revision }, source);
  };
-

-
  $: commit = getOid(project.head, revision, branches);
</script>

<style>
@@ -152,13 +158,13 @@

<header>
  {#if peers.length > 0 && peerSelector}
-
    <PeerSelector {peers} {toggleDropdown} {peer}
+
    <PeerSelector {peers} {toggleDropdown} peer={browser.peer}
      bind:peersDropdown={dropdownState.peer}
      on:peerChanged={(event) => updatePeer(event.detail)} />
  {/if}
  <BranchSelector {branches} {project} {revision} {toggleDropdown}
    bind:branchesDropdown={dropdownState.branch}
-
    on:revisionChanged={(event) => updateRevision(event.detail)} />
+
    on:branchChanged={(event) => updateRevision(event.detail)} />
  <div class="anchor">
    <AnchorBadge {commit} {anchors}
      head={project.head} on:click={(event) => updateRevision(event.detail)} />
modified src/base/projects/History.svelte
@@ -1,39 +1,22 @@
<script lang="ts">
  import CommitTeaser from "./Commit/CommitTeaser.svelte";
-
  import { getCommits, Source, getOid, ProjectContent, splitPrefixFromPath } from "@app/project";
+
  import { getCommits, Source, ProjectContent } from "@app/project";
  import * as proj from "@app/project";
  import Loading from "@app/Loading.svelte";
  import { groupCommitHistory, GroupedCommitsHistory } from "@app/commit";
-
  import { navigate } from "svelte-routing";

  export let source: Source;
-
  export let locator: string;
-
  export let content: ProjectContent;
-
  export let revision: string;
-
  export let path: string;
+
  export let commit: string;

-
  let { urn, seed, addressOrName, peer, project, branches } = source;
-

-
  // Bind content to commit history to trigger updates in parent components.
-
  $: [revision_,] = splitPrefixFromPath(locator, branches, project.head);
-
  $: content = ProjectContent.History;
-
  $: revision = revision_;
+
  let { urn, seed } = source;

  const navigateHistory = (revision: string, content?: ProjectContent) => {
-
    // Replaces path with current path if none passed.
-
    if (! path) path = "/";
-

-
    if (addressOrName) {
-
      navigate(proj.path({ content, peer, urn, addressOrName, revision, path }));
-
    } else {
-
      navigate(proj.path({ content, peer, urn, seed: seed.host, revision, path }));
-
    }
+
    proj.navigateTo({ content, revision }, source);
  };
-

-
  async function fetchCommits(revision: string): Promise<GroupedCommitsHistory> {
-
    const commitsQuery = await getCommits(urn, getOid(project.head, revision, branches), seed.api);
+
  const fetchCommits = async (parentCommit: string): Promise<GroupedCommitsHistory> => {
+
    const commitsQuery = await getCommits(urn, parentCommit, seed.api);
    return groupCommitHistory(commitsQuery);
-
  }
+
  };
</script>

<style>
@@ -70,7 +53,7 @@
  }
</style>

-
{#await fetchCommits(revision)}
+
{#await fetchCommits(commit)}
  <Loading center />
{:then history}
  <div class="history">
modified src/base/projects/PeerSelector.svelte
@@ -4,13 +4,12 @@
  import Dropdown from "@app/Dropdown.svelte";
  import { formatSeedId } from "@app/utils";

-
  export let peer: string;
+
  export let peer: string | null = null;
  export let peers: string[];
  export let toggleDropdown: (input: string) => void;
  export let peersDropdown = false;

  const dispatch = createEventDispatcher();
-

  const switchPeer = (peer: string) => {
    dispatch("peerChanged", peer);
  };
modified src/base/projects/ProjectContentRoutes.svelte
@@ -1,57 +1,65 @@
<script lang="ts">
-
  import type { ProjectContent, Source, Tree } from "@app/project";
+
  import type { Writable } from "svelte/store";
  import { Route, Router } from "svelte-routing";
+
  import type * as proj from "@app/project";
+
  import { ProjectContent } from "@app/project";
  import Browser from "./Browser.svelte";
  import Commit from "./Commit.svelte";
  import History from "./History.svelte";
+
  import RouteContext from "./RouteContext.svelte";

-
  export let source: Source;
-
  export let tree: Tree;
-
  export let content: ProjectContent;
-
  export let revision: string;
-
  export let path: string;
+
  export let source: proj.Source;
+
  export let tree: proj.Tree;
+
  export let browserStore: Writable<proj.Browser>;
+
  export let peer: string | null;
+
  export let branches: proj.Branches;

-
  let locator = source.anchors[0] || source.project.head;
+
  const project = source.project;
</script>

<Router>
  <!-- The default action is to render Browser with the default branch head -->
  <Route path="/">
-
    <Browser {source} {tree} {locator}
-
      bind:content={content}
-
      bind:path={path}
-
      bind:revision={revision} />
+
    <RouteContext {browserStore} {peer} {branches} {project}>
+
      <Browser {source} {tree} {browserStore} />
+
    </RouteContext>
  </Route>
  <Route path="/tree">
-
    <Browser {source} {tree} {locator}
-
      bind:content={content}
-
      bind:path={path}
-
      bind:revision={revision} />
+
    <RouteContext {browserStore} {peer} {branches} {project}>
+
      <Browser {source} {tree} {browserStore} />
+
    </RouteContext>
  </Route>
  <Route path="/tree/*" let:params>
-
    <Browser {source} {tree} locator={params["*"]}
-
      bind:content={content}
-
      bind:path={path}
-
      bind:revision={revision} />
+
    <RouteContext route={params["*"]} {browserStore} {peer} {branches} {project}>
+
      <Browser {source} {tree} {browserStore} />
+
    </RouteContext>
  </Route>
  <Route path="/history">
-
    <History {locator} {source} {path}
-
      bind:content={content}
-
      bind:revision={revision} />
+
    <RouteContext {browserStore} content={ProjectContent.History} {peer} {branches} {project} let:commit>
+
      {#if commit}
+
        <History {source} {commit} />
+
      {/if}
+
    </RouteContext>
  </Route>
  <Route path="/history/*" let:params>
-
    <History locator={params["*"]} {source} {path}
-
      bind:content={content}
-
      bind:revision={revision} />
+
    <RouteContext route={params["*"]} {browserStore} content={ProjectContent.History} {peer} {branches} {project} let:commit>
+
      {#if commit}
+
        <History {source} {commit} />
+
      {/if}
+
    </RouteContext>
  </Route>
-
  <Route path="/commit/:commit" let:params>
-
    <Commit {source} locator={params.commit}
-
      bind:content={content}
-
      bind:revision={revision} />
+
  <Route path="/commits/:commit" let:params>
+
    <RouteContext revision={params.commit} {browserStore} {project} content={ProjectContent.Commit} {peer} {branches} let:revision>
+
      {#if revision}
+
        <Commit {source} commit={revision} />
+
      {/if}
+
    </RouteContext>
  </Route>
-
  <Route path="/commit/*" let:params>
-
    <Commit {source} locator={params["*"]}
-
      bind:content={content}
-
      bind:revision={revision} />
+
  <Route path="/commits/*" let:params>
+
    <RouteContext route={params["*"]} {browserStore} {project} content={ProjectContent.Commit} {peer} {branches} let:revision>
+
      {#if revision}
+
        <Commit {source} commit={revision} />
+
      {/if}
+
    </RouteContext>
  </Route>
</Router>
added src/base/projects/RouteContext.svelte
@@ -0,0 +1,42 @@
+
<script lang="ts">
+
  import type { Writable } from 'svelte/store';
+
  import * as proj from '@app/project';
+

+
  export let browserStore: Writable<proj.Browser>;
+
  export let route: string | null = null;
+
  export let revision: string | null = null;
+
  export let peer: string | null;
+
  export let content: proj.ProjectContent = proj.ProjectContent.Tree;
+
  export let branches: proj.Branches;
+
  export let project: proj.ProjectInfo;
+

+
  const browse: any = { content, peer, branches, path: "/" };
+

+
  $: if (route) {
+
    const result = proj.splitPrefixFromPath(route, $browserStore.branches);
+

+
    console.log("RouteParser", route, result);
+

+
    if (result) {
+
      const [revision, path] = result;
+

+
      browse.revision = revision;
+
      browse.path = path;
+
    }
+
  } else if (revision) {
+
    browse.revision = revision;
+
  } else {
+
    browse.revision = branches[project.defaultBranch];
+
  }
+

+
  $: proj.browse(browse);
+
  $: browser = $browserStore;
+
  $: commit = browser.revision && proj.getOid(browser.revision, browser.branches);
+
</script>
+

+
<slot
+
  revision={browser.revision}
+
  peer={browser.peer}
+
  path={browser.path || "/"}
+
  {commit}
+
></slot>
modified src/base/projects/Routes.svelte
@@ -28,6 +28,6 @@
</Route>
<!-- End of eventual dropped routes -->

-
<Route path="/:addressOrName/:id/*" let:params>
-
  <View {config} addressOrName={params.addressOrName} id={params.id} />
+
<Route path="/:profile/:id/*" let:params>
+
  <View {config} profileName={params.profile} id={params.id} />
</Route>
modified src/base/projects/SourceBrowser/Changeset.svelte
@@ -59,6 +59,9 @@
    flex-direction: row;
    align-items: center;
  }
+
  .browse {
+
    display: flex;
+
  }
</style>

<div class="changeset-summary">
@@ -77,8 +80,9 @@
        <p class="bold">{path}</p>
        <span class="diff-type created">created</span>
      </div>
-
      <Icon class="clickable" name="browse" width={20} inline fill
-
        on:click={() => dispatch("browse", path)} />
+
      <div class="browse" on:click={() => dispatch("browse", path)}>
+
        <Icon name="browse" width={20} inline fill />
+
      </div>
    </header>
  {/each}
  {#each diff.deleted as path (path)}
@@ -87,8 +91,9 @@
        <p class="bold">{path}</p>
        <span class="diff-type deleted">deleted</span>
      </div>
-
      <Icon class="clickable" name="browse" width={20} inline fill
-
        on:click={() => dispatch("browse", path)} />
+
      <div class="browse" on:click={() => dispatch("browse", path)}>
+
        <Icon name="browse" width={20} inline fill />
+
      </div>
    </header>
  {/each}
</div>
modified src/base/projects/SourceBrowser/FileDiff.svelte
@@ -108,6 +108,9 @@
  .file-path {
    font-size: 1rem;
  }
+
  .browse {
+
    display: flex;
+
  }
</style>

<article id={file.path} class="changeset-file">
@@ -116,7 +119,9 @@
    <div class="actions">
      <p class="file-path">{file.path}</p>
    </div>
-
    <Icon name="browse" width={20} inline fill on:click={() => dispatch("browse", file.path)} />
+
    <div class="browse clickable" on:click|stopPropagation={() => dispatch("browse", file.path)}>
+
      <Icon name="browse" width={20} inline fill />
+
    </div>
  </header>
  {#if !collapsed}
    <main>
modified src/base/projects/View.svelte
@@ -1,13 +1,13 @@
<script lang="ts">
-
  import { navigate, Link } from 'svelte-routing';
+
  import { Link } from 'svelte-routing';
  import type { Config } from '@app/config';
  import * as proj from '@app/project';
  import Loading from '@app/Loading.svelte';
  import Avatar from '@app/Avatar.svelte';
  import { Profile, ProfileType } from '@app/profile';
  import type { ProjectInfo } from '@app/project';
-
  import { formatOrg, formatSeedId, isRadicleId, setOpenGraphMetaTag } from '@app/utils';
-
  import { getOid } from '@app/project';
+
  import { formatProfile, formatSeedId, isRadicleId, setOpenGraphMetaTag } from '@app/utils';
+
  import { browserStore } from '@app/project';
  import { Seed } from '@app/base/seeds/Seed';

  import Header from '@app/base/projects/Header.svelte';
@@ -15,26 +15,28 @@
  import NotFound from '@app/NotFound.svelte';

  export let id: string; // Project name or URN.
-
  export let addressOrName = "";
-
  export let seedHost = "";
-
  export let peer = "";
+
  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 config: Config;

-
  let parentName = formatOrg(addressOrName, config);
+
  // Nb. Once we move the content routing above this component, this should
+
  // no longer be necessary, but right now, we have the project header that
+
  // is rendered before the routes are parsed, so we have to set this here.
+
  proj.browse({ peer });
+

+
  let parentName = profileName ? formatProfile(profileName, config) : null;
  let pageTitle = parentName ? `${parentName}/${id}` : id;
  let projectInfo: ProjectInfo | null = null;
-
  let revision: string;
-
  let content: proj.ProjectContent;
-
  let path: string;
  let getProject = new Promise<{ profile?: Profile | null; seed?: Seed } | null>((resolve, reject) => {
-
    if (addressOrName) {
-
      Profile.get(addressOrName, ProfileType.Project, config).then(p => resolve({ profile: p })).catch(err => reject(err.message));
+
    if (profileName) {
+
      Profile.get(profileName, ProfileType.Project, config).then(p => resolve({ profile: p })).catch(err => reject(err.message));
    } else if (seedHost) {
      Seed.lookup(seedHost, config).then(s => resolve({ seed: s })).catch(err => reject(err.message));
    } else {
      resolve(null);
    }
-
  }).then(async (result) => {
+
  }).then(async (result): Promise<proj.Source> => {
    if (! result) {
      throw new Error("Couldn't load project");
    }
@@ -50,23 +52,27 @@
    const urn = isRadicleId(id) ? id : info.urn;
    const anchors = profile ? await profile.confirmedProjectAnchors(urn, config) : [];

-
    let branches = Array([info.defaultBranch, info.head]) as [string, string][];
-
    let peers: proj.Peer[] = [];
+
    // Older versions of http-api don't include the URN.
+
    if (! info.urn) info.urn = urn;

    projectInfo = info;

-
    // Checks for delegates returned from seed node, as feature check of the seed node
-
    if (info.delegates) {
-
      // Check for selected peer to override available branches.
-
      if (peer) {
-
        const branchesByPeer = await proj.getBranchesByPeer(urn, peer || info.delegates[0], seed.api);
-
        branches = [...Object.entries(branchesByPeer.heads)];
-
      }
-
      peers = await proj.getPeers(urn, seed.api);
-
    }
-
    return { urn, addressOrName, seed, peer, project: info, branches, peers, config, profile, anchors };
+
    const peers: proj.PeerId[] = info.delegates
+
      ? await proj.getRemotes(urn, seed.api)
+
      : [];
+

+
    return { urn, seed, project: info, peers, profile, anchors };
  });

+
  function rootPath(source: proj.Source): string {
+
    return proj.pathTo({
+
      content: proj.ProjectContent.Tree,
+
      peer: null,
+
      path: "/",
+
      revision: null,
+
    }, source);
+
  }
+

  $: if (projectInfo) {
    const baseName = parentName
      ? `${parentName}/${projectInfo.name}`
@@ -84,26 +90,6 @@
      { prop: "og:url", content: window.location.href }
    ]);
  }
-

-
  function updateRouteParams({ detail: newParams }: { detail: { urn: string; path: string; revision: string; peer: string; content: proj.ProjectContent } }) {
-
    const newLocation = proj.path({
-
      addressOrName,
-
      seed: seedHost,
-
      urn: newParams.urn,
-
      content: newParams.content,
-
      peer: newParams.peer,
-
      revision: newParams.revision,
-
      path: newParams.path
-
    });
-

-
    if (newLocation !== window.location.pathname) {
-
      navigate(newLocation);
-
    }
-
    if (content !== newParams.content) content = newParams.content;
-
    if (revision !== newParams.revision) revision = newParams.revision;
-
    if (path !== newParams.path) path = newParams.path;
-
    if (peer !== newParams.peer) peer = newParams.peer;
-
  }
</script>

<style>
@@ -185,7 +171,7 @@
          </a>
          <span class="divider">/</span>
        {/if}
-
        <Link to={proj.path({ urn: result.urn, addressOrName, seed: result.seed.host })}>{result.project.name}</Link>
+
        <Link to={rootPath(result)}>{result.project.name}</Link>
        {#if peer}
          <span class="divider" title={peer}>/ {formatSeedId(peer)}</span>
        {/if}
@@ -194,16 +180,10 @@
      <div class="description">{result.project.description}</div>
    </header>

-
    {#await proj.getTree(result.urn, getOid(result.project.head, revision, result.branches), "/", result.seed.api) then tree}
-
      <Header {tree} {revision} {content} {path}
-
        source={result}
-
        peerSelector={!!seedHost}
-
        on:routeParamsChange={updateRouteParams} />
-
      <ProjectContentRoutes {tree}
-
        source={result}
-
        bind:content={content}
-
        bind:revision={revision}
-
        bind:path={path} />
+
    <!-- TODO: Should reivision be null? -->
+
    {#await proj.getRoot(result.project, null, peer, result.seed.api) then { tree, branches, commit }}
+
      <Header {tree} {branches} {commit} {browserStore} source={result} peerSelector={!!seedHost} />
+
      <ProjectContentRoutes {tree} {peer} {branches} {browserStore} source={result} />
    {:catch err}
      <div class="container center-content">
        <div class="error error-message text-xsmall">
modified src/project.ts
@@ -1,3 +1,5 @@
+
import { navigate } from 'svelte-routing';
+
import { get, writable } from 'svelte/store';
import * as api from '@app/api';
import type { Commit, CommitHeader, CommitsHistory } from '@app/commit';
import { isOid } from '@app/utils';
@@ -5,8 +7,8 @@ import type { Profile } from '@app/profile';
import type { Seed } from '@app/base/seeds/Seed';

export type Urn = string;
-
export type Peer = string;
-
export type Branch = { [key: string]: string };
+
export type PeerId = string;
+
export type Branches = { [key: string]: string };

export interface Anchor {
  confirmed: true;
@@ -29,13 +31,10 @@ export interface PendingAnchor {
// Params to render correctly source code related views
export interface Source {
  urn: string;
-
  addressOrName: string;
-
  peer: string;
  project: ProjectInfo;
-
  peers: Peer[];
+
  peers: PeerId[];
  anchors: string[];
  seed: Seed;
-
  branches: [string, string][];
  profile?: Profile | null;
}

@@ -53,7 +52,7 @@ export interface ProjectInfo {
  description: string;
  defaultBranch: string;
  maintainers: Urn[];
-
  delegates: Peer[];
+
  delegates: PeerId[];
}

export interface Tree {
@@ -92,8 +91,68 @@ export interface Blob {
  info: EntryInfo;
}

-
export interface Branches {
-
  heads: Branch;
+
export interface Remote {
+
  heads: Branches;
+
}
+

+
export interface Browser {
+
  content: ProjectContent;
+
  branches: Branches;
+
  revision: string | null;
+
  peer: string | null;
+
  path: string | null;
+
}
+

+
export const browserStore = writable({
+
  content: ProjectContent.Tree,
+
  branches: {},
+
  revision: null,
+
  peer: null,
+
  path: null,
+
} as Browser);
+

+
export interface BrowseTo {
+
    content?: ProjectContent;
+
    revision?: string | null;
+
    path?: string | null;
+
    peer?: string | null;
+
    branches?: Branches;
+
}
+

+
export interface PathOptions {
+
  urn: string;
+
  content?: ProjectContent;
+
  profile?: string | null;
+
  seed?: string | null;
+
  peer?: string | null;
+
  revision?: string | null;
+
  path?: string | null;
+
}
+

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

+
export function pathTo(browse: BrowseTo, source: Source): string {
+
  const browser = get(browserStore);
+
  const options: PathOptions = {
+
    urn: source.urn,
+
    ...browser,
+
    ...browse
+
  };
+

+
  if (source.profile) {
+
    options.profile = source.profile?.nameOrAddress;
+
  } else {
+
    options.seed = source.seed.host;
+
  }
+

+
  return path(options);
+
}
+

+
export function navigateTo(browse: BrowseTo, source: Source): void {
+
  navigate(pathTo(browse, source));
}

export async function getInfo(nameOrUrn: string, host: api.Host): Promise<ProjectInfo> {
@@ -121,14 +180,41 @@ export async function getDelegateProjects(delegate: string, host: api.Host): Pro
  return api.get(`delegates/${delegate}/projects`, {}, host);
}

-
export async function getBranchesByPeer(urn: string, peer: string, host: api.Host): Promise<Branches> {
+
export async function getRemote(urn: string, peer: string, host: api.Host): Promise<Remote> {
  return api.get(`projects/${urn}/remotes/${peer}`, {}, host);
}

-
export async function getPeers(urn: string, host: api.Host): Promise<Peer[]> {
+
export async function getRemotes(urn: string, host: api.Host): Promise<PeerId[]> {
  return api.get(`projects/${urn}/remotes`, {}, host);
}

+
export async function getRoot(
+
  project: ProjectInfo,
+
  revision: string | null,
+
  peer: string | null,
+
  host: api.Host
+
): Promise<{ tree: Tree; branches: Branches; commit: string }> {
+
  const urn = project.urn;
+

+
  let remote: Remote = {
+
    heads: { [project.defaultBranch]: project.head }
+
  };
+

+
  if (peer) {
+
    remote = await getRemote(urn, peer, host);
+
  }
+

+
  const head = remote.heads[project.defaultBranch];
+
  const commit = revision ? getOid(revision, remote.heads) : head;
+

+
  if (! commit) {
+
    throw new Error(`Revision ${revision} not found`);
+
  }
+
  const tree = await getTree(urn, commit, "/", host);
+

+
  return { tree, branches: remote.heads, commit };
+
}
+

export async function getTree(
  urn: string,
  commit: string,
@@ -157,22 +243,12 @@ export async function getReadme(
  return api.get(`projects/${urn}/readme/${commit}`, {}, host);
}

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

-
  if (addressOrName) {
-
    result.push(addressOrName);
+
  if (profile) {
+
    result.push(profile);
  } else if (seed) {
    result.push("seeds", seed);
  }
@@ -188,7 +264,7 @@ export function path(
      break;

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

    default:
@@ -198,8 +274,6 @@ export function path(

  if (revision) {
    result.push(revision);
-
  } else if (path) {
-
    result.push("head");
  }

  // Avoids appending a slash when the path is the root directory.
@@ -211,24 +285,29 @@ export function path(

// We need a SHA1 commit in some places, so we return early if the revision is a SHA and else we look into branches.
// As fallback we use the head commit.
-
export function getOid(head: string, revision: string, branches?: [string, string][]): string {
+
export function getOid(revision: string, branches?: Branches): string | null {
  if (isOid(revision)) return revision;
+

  if (branches) {
-
    const branch = branches.find(([name,]) => name === revision);
-
    return branch ? branch[1] : head;
+
    const oid = branches[revision];
+

+
    if (oid) {
+
      return oid;
+
    }
  }
-
  return head;
+
  return null;
}

// Splits the path consisting of a revision (eg. branch or commit) and file path into a tuple [revision, file-path]
-
export function splitPrefixFromPath(input: string, branches: [string, string][], head: string): [string, string] {
-
  const branch = branches.find(([branchName,]) => input.startsWith(branchName));
+
export function splitPrefixFromPath(input: string, branches: Branches): [string, string] | null {
+
  const branch = Object.entries(branches).find(([branchName,]) => input.startsWith(branchName));
  const commitPath = [input.slice(0, 40), input.slice(41)];
+

  if (branch) {
    const [rev, path] = [input.slice(0, branch[0].length), input.slice(branch[0].length + 1)];
    return [rev, path ? path : "/"];
  } else if (isOid(commitPath[0])) {
    return [commitPath[0], commitPath[1] ? commitPath[1] : "/"];
  }
-
  return [head, "/"];
+
  return null;
}
modified src/utils.ts
@@ -131,7 +131,7 @@ export function formatCommit(oid: string): string {
  return oid.substring(0, 7);
}

-
export function formatOrg(input: string, config: Config): string {
+
export function formatProfile(input: string, config: Config): string {
  if (isAddress(input)) {
    return ethers.utils.getAddress(input);
  } else {
@@ -397,7 +397,7 @@ export async function getSafe(address: string, config: Config): Promise<Safe | n
  // For the subgraph we need to pass a lowercase address
  const query = await querySubgraph(config.orgs.subgraph, GetSafe, { addr: address.toLowerCase() });

-
  if (! query.safe) {
+
  if (! query?.safe) {
    return null;
  }