Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Organize project components
Rūdolfs Ošiņš committed 2 years ago
commit 3bb56e6613a1b2629848e6a7c356e89ad1abe4fe
parent 5cd098187ced4fb3a8252e9264afd783302dbdac
32 files changed +1635 -1634
added src/components/ActivityDiagram.svelte
@@ -0,0 +1,139 @@
+
<script lang="ts">
+
  import type { WeeklyActivity } from "@app/lib/commit";
+

+
  import { onMount } from "svelte";
+

+
  export let activity: WeeklyActivity[];
+
  export let viewBoxHeight: number;
+

+
  const strokeWidth = 3;
+
  const viewBoxWidth = 600;
+

+
  // The path strings to be inserted into the svg <path>.
+
  let path = "";
+
  let areaPath = "";
+

+
  const heightWithPadding = viewBoxHeight + 16;
+

+
  // The latest point on the x axis, starting at 0 until `viewBoxWidth`.
+
  let lastWidthPoint = viewBoxWidth;
+

+
  // The amount of points on the x axis.
+
  const widthIteration = viewBoxWidth / 52;
+

+
  // The highest value on the y axis.
+
  const commitCountArray: number[] = [];
+

+
  // The minimal amplitude shown e.g. commitCount = 1 => `minimalHeight`
+
  // points of height in the SVG.
+
  const minimalHeight = 5;
+

+
  let week = 0;
+

+
  for (const point of activity) {
+
    if (point.week - week > 1) {
+
      commitCountArray.push(...new Array(point.week - week).fill(0));
+
    }
+
    commitCountArray.push(point.commits.length);
+
    week = point.week;
+
  }
+

+
  // Formats the points passed in, into a svg path string, without closing
+
  // the area.
+
  function createPath() {
+
    let i = 1;
+

+
    if (commitCountArray.length < 52) {
+
      commitCountArray.push(...new Array(52 - commitCountArray.length).fill(0));
+
    }
+

+
    const maxValue = Math.max(...commitCountArray);
+
    const minValue = Math.min(...commitCountArray);
+

+
    // Normalizes the values to the viewBox dimensions.
+
    const normalizedArray = commitCountArray.map(c => {
+
      // If we are not crossing the `viewBoxHeight` we want to return the
+
      // actual value, and don't want to normalize <`minimalHeight` commit
+
      // counts as huge spikes.
+
      if (maxValue < viewBoxHeight && c >= minimalHeight) {
+
        return c;
+
      }
+
      // If the value is 0..minimalHeight though we don't want to set it to
+
      // the minimalHeight.
+
      else if (c > 0 && c < minimalHeight) {
+
        return minimalHeight;
+
      }
+
      // If the count is 0 we have to make sure the normalization is not being
+
      // run since it would return NaN.
+
      else {
+
        return c === 0
+
          ? 0
+
          : ((viewBoxHeight - 0) * (c - minValue)) / (maxValue - minValue);
+
      }
+
    });
+

+
    const path = normalizedArray.slice(1).reduce(
+
      (acc, curr) => {
+
        const s = `${viewBoxWidth - widthIteration * i},${
+
          viewBoxHeight - curr
+
        }`;
+
        lastWidthPoint = viewBoxWidth - widthIteration * i;
+
        i += 1;
+
        return acc.concat(s);
+
      },
+
      [`M${viewBoxWidth},${viewBoxHeight - normalizedArray[0]}`],
+
    );
+
    return path.join();
+
  }
+

+
  onMount(() => {
+
    // Creates the stroke path with the array of points.
+
    path = createPath();
+
    // Concats a path closing for it to be the area under the stroke.
+
    areaPath = path.concat(
+
      `L${lastWidthPoint},${viewBoxHeight}L${viewBoxWidth},${viewBoxHeight}Z`,
+
    );
+
  });
+
</script>
+

+
<svg
+
  viewBox="0 0 {viewBoxWidth} {heightWithPadding}"
+
  xmlns="http://www.w3.org/2000/svg">
+
  <svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
+
    <defs>
+
      <linearGradient id="fillGradient" x1="0" y1="1" x2="0" y2="0">
+
        <stop offset="0%" stop-color="#ff55ff" stop-opacity="0" />
+
        <stop offset="100%" stop-color="#ff55ff" stop-opacity="0.2" />
+
      </linearGradient>
+
    </defs>
+
  </svg>
+
  <svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
+
    <defs>
+
      <linearGradient id="gradient" x1="0" y1="1" x2="0" y2="0">
+
        <stop offset="0%" stop-color="#ff55ff" stop-opacity="0.2" />
+
        <stop offset="50%" stop-color="#ff55ff" stop-opacity="0.8" />
+
        <stop offset="100%" stop-color="#ff55ff" stop-opacity="1" />
+
      </linearGradient>
+
    </defs>
+
  </svg>
+
  {#if activity.length > 0}
+
    <g>
+
      <path
+
        fill="transparent"
+
        stroke="url(#gradient)"
+
        stroke-width={strokeWidth}
+
        stroke-linejoin="round"
+
        d={path} />
+
      <path fill="url(#fillGradient)" stroke="transparent" d={areaPath} />
+
    </g>
+
  {:else}
+
    <!-- If no commits have been made in a year, we show a straight line -->
+
    <line
+
      x1="0"
+
      y1={viewBoxHeight}
+
      x2="600"
+
      y2={viewBoxHeight}
+
      stroke="#ff55ff"
+
      stroke-width={1} />
+
  {/if}
+
</svg>
modified src/components/ProjectCard.svelte
@@ -3,7 +3,7 @@

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

-
  import ActivityDiagram from "@app/views/projects/ActivityDiagram.svelte";
+
  import ActivityDiagram from "@app/components/ActivityDiagram.svelte";

  export let compact = false;
  export let description: string;
deleted src/views/projects/ActivityDiagram.svelte
@@ -1,139 +0,0 @@
-
<script lang="ts">
-
  import type { WeeklyActivity } from "@app/lib/commit";
-

-
  import { onMount } from "svelte";
-

-
  export let activity: WeeklyActivity[];
-
  export let viewBoxHeight: number;
-

-
  const strokeWidth = 3;
-
  const viewBoxWidth = 600;
-

-
  // The path strings to be inserted into the svg <path>.
-
  let path = "";
-
  let areaPath = "";
-

-
  const heightWithPadding = viewBoxHeight + 16;
-

-
  // The latest point on the x axis, starting at 0 until `viewBoxWidth`.
-
  let lastWidthPoint = viewBoxWidth;
-

-
  // The amount of points on the x axis.
-
  const widthIteration = viewBoxWidth / 52;
-

-
  // The highest value on the y axis.
-
  const commitCountArray: number[] = [];
-

-
  // The minimal amplitude shown e.g. commitCount = 1 => `minimalHeight`
-
  // points of height in the SVG.
-
  const minimalHeight = 5;
-

-
  let week = 0;
-

-
  for (const point of activity) {
-
    if (point.week - week > 1) {
-
      commitCountArray.push(...new Array(point.week - week).fill(0));
-
    }
-
    commitCountArray.push(point.commits.length);
-
    week = point.week;
-
  }
-

-
  // Formats the points passed in, into a svg path string, without closing
-
  // the area.
-
  function createPath() {
-
    let i = 1;
-

-
    if (commitCountArray.length < 52) {
-
      commitCountArray.push(...new Array(52 - commitCountArray.length).fill(0));
-
    }
-

-
    const maxValue = Math.max(...commitCountArray);
-
    const minValue = Math.min(...commitCountArray);
-

-
    // Normalizes the values to the viewBox dimensions.
-
    const normalizedArray = commitCountArray.map(c => {
-
      // If we are not crossing the `viewBoxHeight` we want to return the
-
      // actual value, and don't want to normalize <`minimalHeight` commit
-
      // counts as huge spikes.
-
      if (maxValue < viewBoxHeight && c >= minimalHeight) {
-
        return c;
-
      }
-
      // If the value is 0..minimalHeight though we don't want to set it to
-
      // the minimalHeight.
-
      else if (c > 0 && c < minimalHeight) {
-
        return minimalHeight;
-
      }
-
      // If the count is 0 we have to make sure the normalization is not being
-
      // run since it would return NaN.
-
      else {
-
        return c === 0
-
          ? 0
-
          : ((viewBoxHeight - 0) * (c - minValue)) / (maxValue - minValue);
-
      }
-
    });
-

-
    const path = normalizedArray.slice(1).reduce(
-
      (acc, curr) => {
-
        const s = `${viewBoxWidth - widthIteration * i},${
-
          viewBoxHeight - curr
-
        }`;
-
        lastWidthPoint = viewBoxWidth - widthIteration * i;
-
        i += 1;
-
        return acc.concat(s);
-
      },
-
      [`M${viewBoxWidth},${viewBoxHeight - normalizedArray[0]}`],
-
    );
-
    return path.join();
-
  }
-

-
  onMount(() => {
-
    // Creates the stroke path with the array of points.
-
    path = createPath();
-
    // Concats a path closing for it to be the area under the stroke.
-
    areaPath = path.concat(
-
      `L${lastWidthPoint},${viewBoxHeight}L${viewBoxWidth},${viewBoxHeight}Z`,
-
    );
-
  });
-
</script>
-

-
<svg
-
  viewBox="0 0 {viewBoxWidth} {heightWithPadding}"
-
  xmlns="http://www.w3.org/2000/svg">
-
  <svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
-
    <defs>
-
      <linearGradient id="fillGradient" x1="0" y1="1" x2="0" y2="0">
-
        <stop offset="0%" stop-color="#ff55ff" stop-opacity="0" />
-
        <stop offset="100%" stop-color="#ff55ff" stop-opacity="0.2" />
-
      </linearGradient>
-
    </defs>
-
  </svg>
-
  <svg style="height: 0; width: 0;" xmlns="http://www.w3.org/2000/svg">
-
    <defs>
-
      <linearGradient id="gradient" x1="0" y1="1" x2="0" y2="0">
-
        <stop offset="0%" stop-color="#ff55ff" stop-opacity="0.2" />
-
        <stop offset="50%" stop-color="#ff55ff" stop-opacity="0.8" />
-
        <stop offset="100%" stop-color="#ff55ff" stop-opacity="1" />
-
      </linearGradient>
-
    </defs>
-
  </svg>
-
  {#if activity.length > 0}
-
    <g>
-
      <path
-
        fill="transparent"
-
        stroke="url(#gradient)"
-
        stroke-width={strokeWidth}
-
        stroke-linejoin="round"
-
        d={path} />
-
      <path fill="url(#fillGradient)" stroke="transparent" d={areaPath} />
-
    </g>
-
  {:else}
-
    <!-- If no commits have been made in a year, we show a straight line -->
-
    <line
-
      x1="0"
-
      y1={viewBoxHeight}
-
      x2="600"
-
      y2={viewBoxHeight}
-
      stroke="#ff55ff"
-
      stroke-width={1} />
-
  {/if}
-
</svg>
deleted src/views/projects/Blob.svelte
@@ -1,281 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Blob } from "@httpd-client";
-

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

-
  import * as Syntax from "@app/lib/syntax";
-
  import { isMarkdownPath, twemoji } from "@app/lib/utils";
-
  import { lineNumbersGutter } from "@app/lib/syntax";
-

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

-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
-
  export let peer: string | undefined;
-
  export let revision: string | undefined;
-
  export let path: string;
-
  export let blob: Blob;
-
  export let highlighted: Syntax.Root | undefined;
-
  export let rawPath: string;
-

-
  $: lastCommit = blob.lastCommit;
-

-
  $: parentDir = blob.path
-
    .match(/^.*\/|/)
-
    ?.values()
-
    .next().value;
-

-
  $: content = highlighted ? lineNumbersGutter(highlighted) : undefined;
-

-
  let selectedLineId: string | undefined = undefined;
-
  $: {
-
    content;
-
    updateSelectedLineId();
-
  }
-

-
  function updateSelectedLineId() {
-
    const fragmentId = window.location.hash.substring(1);
-
    if (fragmentId && fragmentId.match(/^L\d+$/)) {
-
      selectedLineId = fragmentId;
-
    } else {
-
      selectedLineId = undefined;
-
    }
-
  }
-

-
  $: isMarkdown = isMarkdownPath(blob.path);
-
  $: showMarkdown = isMarkdown && selectedLineId === undefined;
-

-
  function toggleMarkdown() {
-
    window.location.hash = "";
-
    showMarkdown = !showMarkdown;
-
  }
-

-
  afterUpdate(() => {
-
    for (const item of document.getElementsByClassName("highlight")) {
-
      item.classList.remove("highlight");
-
    }
-
    if (selectedLineId) {
-
      const target = document.getElementById(selectedLineId);
-
      if (target) {
-
        target.classList.add("highlight");
-
        target.scrollIntoView();
-
      }
-
    }
-
  });
-

-
  onMount(async () => {
-
    window.addEventListener("hashchange", updateSelectedLineId);
-
  });
-

-
  onDestroy(() => {
-
    window.removeEventListener("hashchange", updateSelectedLineId);
-
  });
-
</script>
-

-
<style>
-
  header .file-header {
-
    display: flex;
-
    height: 3rem;
-
    align-items: center;
-
    justify-content: space-between;
-
    padding: 0 0.5rem 0 1rem;
-
    color: var(--color-foreground);
-
    border-width: 1px 1px 0 1px;
-
    border-color: var(--color-foreground-3);
-
    border-style: solid;
-
    border-top-left-radius: var(--border-radius-small);
-
    border-top-right-radius: var(--border-radius-small);
-
  }
-

-
  .file-header .right {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    justify-content: flex-end;
-
    overflow-x: hidden;
-
    text-overflow: ellipsis;
-
    width: 100%;
-
  }
-

-
  header .file-name {
-
    font-weight: var(--font-weight-normal);
-
    flex-shrink: 0;
-
    white-space: nowrap;
-
    overflow: hidden;
-
    text-overflow: ellipsis;
-
    margin-right: 1rem;
-
  }
-

-
  .last-commit {
-
    padding: 0.5rem 0.75rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    font-size: var(--font-size-tiny);
-
    border-radius: var(--border-radius-small);
-
    overflow-x: hidden;
-
    text-overflow: ellipsis;
-
    white-space: nowrap;
-
  }
-
  .last-commit .hash {
-
    font-weight: var(--font-weight-bold);
-
    font-family: var(--font-family-monospace);
-
    margin-right: 0.25rem;
-
  }
-

-
  .toggle {
-
    margin-right: 0.5rem;
-
  }
-

-
  .code :global(.line-number) {
-
    color: var(--color-foreground-4);
-
    text-align: right;
-
    padding: 0;
-
    user-select: none;
-
  }
-
  .code :global(.line-number a) {
-
    display: block;
-
    padding: 0 1rem;
-
  }
-
  .code :global(.line-number:hover) {
-
    cursor: pointer;
-
    color: var(--color-foreground);
-
  }
-

-
  .code :global(.content) {
-
    display: inline;
-
    font-family: var(--font-family-monospace);
-
    margin: 0;
-
  }
-

-
  .code :global(.line) {
-
    line-height: 22px; /* This seems to be the line-height of a pre code block */
-
  }
-
  .code :global(.highlight) {
-
    background-color: var(--color-caution-3);
-
  }
-
  .code :global(.highlight td a) {
-
    color: var(--color-foreground);
-
  }
-

-
  .code :global(.line-content) {
-
    padding: 0;
-
    width: 100%;
-
  }
-

-
  .code {
-
    width: 100%;
-
    border-spacing: 0;
-
    overflow-x: auto;
-
    font-size: var(--font-size-small);
-
    padding-top: 1rem;
-
    margin-bottom: 1.5rem;
-
  }
-

-
  .container {
-
    position: relative;
-
    display: flex;
-
    overflow-x: auto;
-
    border: 1px solid var(--color-foreground-3);
-
    border-top-style: dashed;
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
-
    background: var(--color-background-1);
-
  }
-

-
  .binary {
-
    display: flex;
-
    flex-direction: column;
-
    justify-content: center;
-
    align-items: center;
-
    width: 100%;
-
    height: 16rem;
-
    background-color: var(--color-foreground-1);
-
    color: var(--color-foreground-6);
-
    font-family: var(--font-family-monospace);
-
  }
-
  .binary > * {
-
    margin-bottom: 1rem;
-
  }
-

-
  .no-scrollbar {
-
    scrollbar-width: none;
-
  }
-

-
  .markdown {
-
    max-width: 64rem;
-
  }
-

-
  .no-scrollbar::-webkit-scrollbar {
-
    display: none;
-
  }
-

-
  @media (max-width: 960px) {
-
    .code {
-
      font-size: var(--font-size-small);
-
    }
-
  }
-

-
  @media (max-width: 720px) {
-
    .right {
-
      justify-content: center;
-
    }
-
  }
-
</style>
-

-
<div class:markdown={isMarkdown}>
-
  <header>
-
    <div class="file-header">
-
      <span class="file-name">
-
        <span style:color="var(--color-foreground-5)">{parentDir}</span>
-
        &#8203;
-
        <span>{blob.name}</span>
-
      </span>
-
      <div class="right">
-
        {#if isMarkdown}
-
          <div title="Toggle render method" class="toggle">
-
            <SquareButton clickable on:click={toggleMarkdown}>
-
              {showMarkdown ? "Plain" : "Markdown"}
-
            </SquareButton>
-
          </div>
-
        {/if}
-
        <a href="{rawPath}/{blob.path}" class="toggle">
-
          <SquareButton clickable>Raw</SquareButton>
-
        </a>
-
        <div class="last-commit" title={lastCommit.author.name} use:twemoji>
-
          <span class="hash">
-
            {lastCommit.id.slice(0, 7)}
-
          </span>
-
          {lastCommit.summary}
-
        </div>
-
      </div>
-
    </div>
-
  </header>
-
  <div class="container">
-
    {#if blob.binary}
-
      <div class="binary">
-
        <div use:twemoji>👀</div>
-
        <span class="txt-tiny">Binary content</span>
-
      </div>
-
    {:else if showMarkdown && blob.content}
-
      <Readme
-
        {baseUrl}
-
        {projectId}
-
        {peer}
-
        {revision}
-
        content={blob.content}
-
        {rawPath}
-
        {path} />
-
    {:else if content}
-
      <table class="code no-scrollbar">
-
        {@html toHtml(content)}
-
      </table>
-
    {:else}
-
      <div class="binary">
-
        <div use:twemoji>🍂</div>
-
        <span class="txt-tiny">Empty file</span>
-
      </div>
-
    {/if}
-
  </div>
-
</div>
deleted src/views/projects/BranchSelector.svelte
@@ -1,85 +0,0 @@
-
<script lang="ts">
-
  import * as utils from "@app/lib/utils";
-
  import { closeFocused } from "@app/components/Floating.svelte";
-

-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
-
  import Floating from "@app/components/Floating.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import type { Route } from "@app/lib/router";
-

-
  export let selectedBranch: string | undefined;
-
  export let selectedCommitId: string;
-
  export let branches: Array<{ name: string; route: Route }>;
-

-
  $: hideDropdown = branches.length <= 1;
-
  $: selectedCommitShortId = utils.formatCommit(selectedCommitId);
-
</script>
-

-
<style>
-
  .commit {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    line-height: initial;
-

-
    font-family: var(--font-family-monospace);
-
    color: var(--color-secondary);
-
  }
-
  .branch-name {
-
    height: 2rem;
-
    padding: 0.5rem 0.75rem;
-
    background-color: var(--color-secondary-2);
-
    border-radius: var(--border-radius-small) 0 0 var(--border-radius-small);
-
  }
-
  .branch-name.not-allowed {
-
    cursor: not-allowed;
-
  }
-
  .branch-name:hover:not(.not-allowed) {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .commit-id {
-
    height: 2rem;
-
    padding: 0.5rem 0.75rem;
-
    background-color: var(--color-secondary-1);
-
    border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
-
  }
-
  .commit-id.standalone {
-
    border-radius: var(--border-radius-small);
-
  }
-
</style>
-

-
<div class="commit" title="Current branch">
-
  {#if selectedBranch}
-
    <Floating disabled={hideDropdown}>
-
      <div
-
        slot="toggle"
-
        title="Change branch"
-
        class="branch-name"
-
        class:not-allowed={hideDropdown}>
-
        {selectedBranch}
-
      </div>
-
      <Dropdown slot="modal" items={branches}>
-
        <Link
-
          slot="item"
-
          let:item
-
          route={item.route}
-
          on:afterNavigate={() => closeFocused()}>
-
          <DropdownItem selected={item.name === selectedBranch} size="tiny">
-
            {item.name}
-
          </DropdownItem>
-
        </Link>
-
      </Dropdown>
-
    </Floating>
-
    <div class="commit-id">
-
      {selectedCommitShortId}
-
    </div>
-
  {:else}
-
    <div class="commit-id standalone layout-desktop">
-
      {selectedCommitId}
-
    </div>
-
    <div class="commit-id standalone layout-mobile">
-
      {selectedCommitShortId}
-
    </div>
-
  {/if}
-
</div>
added src/views/projects/Changeset.svelte
@@ -0,0 +1,96 @@
+
<script lang="ts">
+
  import type { BaseUrl, Diff } from "@httpd-client";
+

+
  import { pluralize } from "@app/lib/pluralize";
+

+
  import FileDiff from "@app/views/projects/Changeset/FileDiff.svelte";
+
  import FileLocationChange from "@app/views/projects/Changeset/FileLocationChange.svelte";
+

+
  export let diff: Diff;
+
  export let revision: string;
+
  export let baseUrl: BaseUrl;
+
  export let projectId: string;
+

+
  const diffDescription = ({
+
    modified,
+
    added,
+
    deleted,
+
    moved,
+
    copied,
+
  }: Diff): string => {
+
    const s = [];
+

+
    if (modified.length) {
+
      s.push(
+
        `${modified.length} ${pluralize("file", modified.length)} changed`,
+
      );
+
    }
+
    if (added.length) {
+
      s.push(`${added.length} ${pluralize("file", added.length)} added`);
+
    }
+
    if (deleted.length) {
+
      s.push(`${deleted.length} ${pluralize("file", deleted.length)} deleted`);
+
    }
+
    if (copied.length) {
+
      s.push(`${copied.length} ${pluralize("file", copied.length)} copied`);
+
    }
+
    if (moved.length) {
+
      s.push(`${moved.length} ${pluralize("file", moved.length)} moved`);
+
    }
+
    return s.join(", ");
+
  };
+
</script>
+

+
<style>
+
  .changeset-summary {
+
    padding-bottom: 1.5rem;
+
    margin-left: 1rem;
+
  }
+
  .changeset-summary .additions {
+
    color: var(--color-positive-6);
+
  }
+
  .changeset-summary .deletions {
+
    color: var(--color-negative-6);
+
  }
+
</style>
+

+
<div class="changeset-summary">
+
  <span>{diffDescription(diff)}</span>
+
  with
+
  <span class="additions">
+
    {diff.stats.insertions}
+
    {pluralize("insertion", diff.stats.insertions)}
+
  </span>
+
  and
+
  <span class="deletions">
+
    {diff.stats.deletions}
+
    {pluralize("deletion", diff.stats.deletions)}
+
  </span>
+
</div>
+
<div class="diff-listing">
+
  {#each diff.added as file}
+
    <FileDiff
+
      {projectId}
+
      {baseUrl}
+
      {file}
+
      {revision}
+
      headerBadgeCaption="added" />
+
  {/each}
+
  {#each diff.deleted as file}
+
    <FileDiff
+
      {projectId}
+
      {baseUrl}
+
      {file}
+
      {revision}
+
      headerBadgeCaption="deleted" />
+
  {/each}
+
  {#each diff.modified as file}
+
    <FileDiff {projectId} {baseUrl} {file} {revision} />
+
  {/each}
+
  {#each diff.moved as file}
+
    <FileLocationChange {projectId} {baseUrl} {file} {revision} mode="moved" />
+
  {/each}
+
  {#each diff.copied as file}
+
    <FileLocationChange {projectId} {baseUrl} {file} {revision} mode="copied" />
+
  {/each}
+
</div>
added src/views/projects/Changeset/FileDiff.svelte
@@ -0,0 +1,400 @@
+
<script lang="ts">
+
  import { onDestroy, onMount } from "svelte";
+
  import type {
+
    BaseUrl,
+
    DiffAddedDeletedModifiedChangeset,
+
    HunkLine,
+
  } from "@httpd-client";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+

+
  export let file: DiffAddedDeletedModifiedChangeset;
+
  export let revision: string;
+
  export let headerBadgeCaption: "added" | "deleted" | undefined = undefined;
+
  export let baseUrl: BaseUrl;
+
  export let projectId: string;
+

+
  let collapsed = false;
+
  let selection: Selection | undefined = undefined;
+

+
  onMount(() => {
+
    window.addEventListener("click", deselectHandler);
+
    window.addEventListener("hashchange", updateSelection);
+

+
    updateSelection();
+

+
    if (selection) {
+
      document
+
        .getElementById(
+
          [
+
            file.path,
+
            "H" + selection.startHunk,
+
            "L" + selection.startLine,
+
          ].join("-"),
+
        )
+
        ?.scrollIntoView();
+
    }
+
  });
+

+
  onDestroy(() => {
+
    window.removeEventListener("click", deselectHandler);
+
    window.removeEventListener("hashchange", updateSelection);
+
  });
+

+
  function deselectHandler(e: MouseEvent) {
+
    if (
+
      !(
+
        e.target instanceof HTMLElement &&
+
        e.target.closest("[data-file-diff-select]")
+
      )
+
    ) {
+
      updateHash("");
+
    }
+
  }
+

+
  function updateSelection() {
+
    const fragment = window.location.hash.substring(1);
+
    const match = fragment.match(/(.+):H(\d+)L(\d+)(H(\d+)L(\d+))?/);
+
    if (match && match[1] === file.path) {
+
      selection = {
+
        startHunk: parseInt(match[2]),
+
        startLine: parseInt(match[3]),
+
        endHunk: match[4] ? parseInt(match[5]) : undefined,
+
        endLine: match[4] ? parseInt(match[6]) : undefined,
+
      };
+
    } else {
+
      selection = undefined;
+
    }
+
  }
+

+
  function lineNumberR(line: HunkLine): string | number {
+
    switch (line.type) {
+
      case "addition": {
+
        return line.lineNo;
+
      }
+
      case "context": {
+
        return line.lineNoNew;
+
      }
+
      case "deletion": {
+
        return " ";
+
      }
+
    }
+
  }
+

+
  function lineNumberL(line: HunkLine): string | number {
+
    switch (line.type) {
+
      case "addition": {
+
        return " ";
+
      }
+
      case "context": {
+
        return line.lineNoOld;
+
      }
+
      case "deletion": {
+
        return line.lineNo;
+
      }
+
    }
+
  }
+

+
  function lineSign(line: HunkLine): string {
+
    switch (line.type) {
+
      case "addition": {
+
        return "+";
+
      }
+
      case "context": {
+
        return " ";
+
      }
+
      case "deletion": {
+
        return "-";
+
      }
+
    }
+
  }
+

+
  function isLineSelected(
+
    selection: Selection | undefined,
+
    hunkIdx: number,
+
    lineIdx: number,
+
  ): boolean {
+
    if (!selection) {
+
      return false;
+
    }
+

+
    if (selection.endHunk !== undefined && selection.endLine !== undefined) {
+
      return (
+
        hunkIdx >= selection.startHunk &&
+
        hunkIdx <= selection.endHunk &&
+
        (hunkIdx === selection.startHunk
+
          ? lineIdx >= selection.startLine
+
          : true) &&
+
        (hunkIdx === selection.endHunk ? lineIdx <= selection.endLine : true)
+
      );
+
    } else {
+
      return hunkIdx === selection.startHunk && lineIdx === selection.startLine;
+
    }
+
  }
+

+
  function hashFromSelection(
+
    hunkIdx: number,
+
    lineIdx: number,
+
    event: MouseEvent,
+
  ): string {
+
    const path = file.path;
+
    // single line selection
+
    if (!event.shiftKey) {
+
      return path + ":H" + hunkIdx + "L" + lineIdx;
+
    }
+

+
    if (!selection) {
+
      return "";
+
    }
+

+
    // range selection
+
    if (hunkIdx === selection.startHunk) {
+
      if (lineIdx >= selection.startLine) {
+
        return `${path}:H${hunkIdx}L${selection.startLine}H${hunkIdx}L${lineIdx}`;
+
      } else {
+
        return `${path}:H${hunkIdx}L${lineIdx}H${hunkIdx}L${selection.startLine}`;
+
      }
+
    } else if (hunkIdx < selection.startHunk) {
+
      return `${path}:H${hunkIdx}L${lineIdx}H${selection.startHunk}L${selection.startLine}`;
+
    } else {
+
      return `${path}:H${selection.startHunk}L${selection.startLine}H${hunkIdx}L${lineIdx}`;
+
    }
+
  }
+

+
  function selectLine(hunkIdx: number, lineIdx: number, event: MouseEvent) {
+
    updateHash(hashFromSelection(hunkIdx, lineIdx, event));
+
  }
+

+
  function updateHash(newHash: string) {
+
    if (newHash !== "") {
+
      window.location.hash = newHash;
+
    } else {
+
      window.history.replaceState(
+
        window.history.state,
+
        "",
+
        window.location.pathname + window.location.search,
+
      );
+
      selection = undefined;
+
    }
+
  }
+

+
  function hunkHeaderSelected(selection: Selection | undefined, hunk: number) {
+
    return (
+
      selection &&
+
      selection.endHunk !== undefined &&
+
      hunk > selection.startHunk &&
+
      hunk <= selection.endHunk
+
    );
+
  }
+

+
  interface Selection {
+
    startHunk: number;
+
    startLine: number;
+
    endHunk: number | undefined;
+
    endLine: number | undefined;
+
  }
+
</script>
+

+
<style>
+
  .wrapper {
+
    border: 1px solid var(--color-foreground-4);
+
    border-radius: var(--border-radius-small);
+
    margin-bottom: 2rem;
+
    line-height: 1.5rem;
+
  }
+
  .header {
+
    align-items: center;
+
    background: none;
+
    border-radius: 0;
+
    display: flex;
+
    flex-direction: row;
+
    height: 3rem;
+
    padding: 1rem;
+
  }
+
  main {
+
    font-size: var(--font-size-small);
+
    border-top: 1px dashed var(--color-foreground-4);
+
    background-color: var(--color-foreground-1);
+
    border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
+
    overflow-x: auto;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 1rem;
+
  }
+
  .placeholder {
+
    padding: 1rem;
+
    color: var(--color-foreground-5);
+
    text-align: center;
+
  }
+
  .browse {
+
    margin-left: auto;
+
    cursor: pointer;
+
  }
+
  .expand-button {
+
    cursor: pointer;
+
    user-select: none;
+
    margin-right: 0.5rem;
+
  }
+
  .diff {
+
    font-family: var(--font-family-monospace);
+
    table-layout: fixed;
+
    border-collapse: collapse;
+
    margin: 0.5rem 0;
+
  }
+
  .diff-line {
+
    vertical-align: top;
+
  }
+
  .diff-line.type-addition > * {
+
    color: var(--color-positive-6);
+
    background-color: var(--color-positive-2);
+
  }
+
  .diff-line.type-deletion > * {
+
    color: var(--color-negative-6);
+
    background-color: var(--color-negative-2);
+
  }
+
  .diff-line.selected > * {
+
    color: var(--color-foreground-6);
+
    background-color: var(--color-foreground-4);
+
  }
+
  .diff-line.selected.type-addition > * {
+
    color: var(--color-positive-6);
+
    background-color: var(--color-positive-4);
+
  }
+
  .diff-line.selected.type-deletion > * {
+
    color: var(--color-negative-6);
+
    background-color: var(--color-negative-4);
+
  }
+
  .diff-line.hunk-header.selected {
+
    background-color: var(--color-foreground-4);
+
  }
+
  .diff-line-number {
+
    text-align: right;
+
    user-select: none;
+
    line-height: 1.5rem;
+
    min-width: 3rem;
+
    cursor: pointer;
+
  }
+
  .diff-line-number.left {
+
    position: relative;
+
    padding: 0 0.5rem 0 0.75rem;
+
  }
+
  .selection-indicator {
+
    position: absolute;
+
    left: 0;
+
    top: 0;
+
    bottom: 0;
+
    width: 4px;
+
  }
+
  .diff-line.selected .selection-indicator {
+
    background: var(--color-primary);
+
  }
+
  .diff-line-number.right {
+
    padding: 0 0.75rem 0 0.5rem;
+
  }
+
  .diff-line-content {
+
    white-space: pre-wrap;
+
    overflow-wrap: anywhere;
+
    width: 100%;
+
    padding-right: 0.5rem;
+
  }
+
  .diff-line-type {
+
    text-align: center;
+
    padding-left: 0.75rem;
+
    padding-right: 0.75rem;
+
    user-select: none;
+
  }
+
  .diff-expand-header {
+
    padding-left: 0.5rem;
+
    color: var(--color-foreground-5);
+
  }
+
  .diff-line-number {
+
    color: var(--color-foreground-5);
+
  }
+
</style>
+

+
<div id={file.path} class="wrapper">
+
  <header class="header">
+
    <!-- svelte-ignore a11y-click-events-have-key-events -->
+
    <!-- svelte-ignore a11y-no-static-element-interactions -->
+
    <div class="expand-button" on:click={() => (collapsed = !collapsed)}>
+
      {#if collapsed}
+
        <Icon name="chevron-right" />
+
      {:else}
+
        <Icon name="chevron-down" />
+
      {/if}
+
    </div>
+
    <div class="actions">
+
      <p class="txt-regular">{file.path}</p>
+
      {#if headerBadgeCaption === "added"}
+
        <Badge variant="positive">added</Badge>
+
      {:else if headerBadgeCaption === "deleted"}
+
        <Badge variant="negative">deleted</Badge>
+
      {/if}
+
    </div>
+
    <div class="browse" title="View file">
+
      <Link
+
        route={{
+
          resource: "project.source",
+
          project: projectId,
+
          node: baseUrl,
+
          path: file.path,
+
          revision,
+
        }}>
+
        <Icon name="browse" />
+
      </Link>
+
    </div>
+
  </header>
+
  {#if !collapsed}
+
    <main>
+
      {#if file.diff.type === "plain"}
+
        {#if file.diff.hunks.length > 0}
+
          <table class="diff" data-file-diff-select>
+
            {#each file.diff.hunks as hunk, hunkIdx}
+
              <tr
+
                class="diff-line hunk-header"
+
                class:selected={hunkHeaderSelected(selection, hunkIdx)}>
+
                <td colspan={2} style:position="relative">
+
                  <div class="selection-indicator" />
+
                </td>
+
                <td colspan={6} class="diff-expand-header">
+
                  {hunk.header}
+
                </td>
+
              </tr>
+
              {#each hunk.lines as line, lineIdx}
+
                <tr
+
                  class={`diff-line type-${line.type}`}
+
                  class:selected={isLineSelected(selection, hunkIdx, lineIdx)}>
+
                  <td
+
                    id={[file.path, "H" + hunkIdx, "L" + lineIdx].join("-")}
+
                    class="diff-line-number left"
+
                    on:click={e => selectLine(hunkIdx, lineIdx, e)}>
+
                    <div class="selection-indicator" />
+
                    {lineNumberL(line)}
+
                  </td>
+
                  <td
+
                    class="diff-line-number right"
+
                    on:click={e => selectLine(hunkIdx, lineIdx, e)}>
+
                    {lineNumberR(line)}
+
                  </td>
+
                  <td class="diff-line-type" data-line-type={line.type}>
+
                    {lineSign(line)}
+
                  </td>
+
                  <td class="diff-line-content">{line.line}</td>
+
                </tr>
+
              {/each}
+
            {/each}
+
          </table>
+
        {:else}
+
          <div class="placeholder">Empty file</div>
+
        {/if}
+
      {:else}
+
        <div class="placeholder">Binary file</div>
+
      {/if}
+
    </main>
+
  {/if}
+
</div>
added src/views/projects/Changeset/FileLocationChange.svelte
@@ -0,0 +1,66 @@
+
<script lang="ts">
+
  import type { BaseUrl, DiffCopiedMovedChangeset } from "@httpd-client";
+

+
  import Badge from "@app/components/Badge.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+

+
  export let file: DiffCopiedMovedChangeset;
+
  export let revision: string;
+
  export let mode: "moved" | "copied";
+
  export let baseUrl: BaseUrl;
+
  export let projectId: string;
+
</script>
+

+
<style>
+
  .wrapper {
+
    border: 1px solid var(--color-foreground-4);
+
    border-radius: var(--border-radius-small);
+
    margin-bottom: 2rem;
+
    line-height: 1.5rem;
+
  }
+
  .header {
+
    align-items: center;
+
    background: none;
+
    border-radius: 0;
+
    display: flex;
+
    flex-direction: row;
+
    height: 3rem;
+
    padding: 1rem;
+
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    gap: 1rem;
+
  }
+
  .browse {
+
    margin-left: auto;
+
    cursor: pointer;
+
  }
+
</style>
+

+
<div id={file.newPath} class="wrapper">
+
  <header class="header">
+
    <div class="actions">
+
      <p class="txt-regular">{file.oldPath} → {file.newPath}</p>
+
      {#if mode === "moved"}
+
        <Badge variant="foreground">moved</Badge>
+
      {:else if mode === "copied"}
+
        <Badge variant="foreground">copied</Badge>
+
      {/if}
+
    </div>
+
    <div class="browse" title="View file">
+
      <Link
+
        route={{
+
          resource: "project.source",
+
          project: projectId,
+
          node: baseUrl,
+
          path: file.newPath,
+
          revision,
+
        }}>
+
        <Icon name="browse" />
+
      </Link>
+
    </div>
+
  </header>
+
</div>
deleted src/views/projects/CloneButton.svelte
@@ -1,87 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
-

-
  import { parseRepositoryId } from "@app/lib/utils";
-
  import { config } from "@app/lib/config";
-

-
  import Command from "@app/components/Command.svelte";
-
  import Floating from "@app/components/Floating.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let id: string;
-
  export let name: string;
-

-
  $: radCloneUrl = `rad clone ${id}`;
-
  $: portFragment =
-
    baseUrl.scheme === config.nodes.defaultHttpdScheme &&
-
    baseUrl.port === config.nodes.defaultHttpdPort
-
      ? ""
-
      : `:${baseUrl.port}`;
-
  $: gitCloneUrl = `git clone ${baseUrl.scheme}://${
-
    baseUrl.hostname
-
  }${portFragment}/${parseRepositoryId(id)?.pubkey ?? id}.git ${name}`;
-
</script>
-

-
<style>
-
  .clone-button {
-
    background-color: var(--color-caution-3);
-
    border-radius: var(--border-radius-small);
-
    color: var(--color-caution-6);
-
    font-family: var(--font-family-monospace);
-
    min-width: max-content;
-
    height: 2rem;
-
    line-height: initial;
-
    padding: 0.5rem 0.75rem;
-
  }
-
  .clone-button:hover {
-
    background-color: var(--color-caution-4);
-
  }
-
  .dropdown {
-
    background-color: var(--color-background-1);
-
    border-radius: var(--border-radius-small);
-
    box-shadow: var(--elevation-low);
-
    margin-top: 0.5rem;
-
    padding: 1rem;
-
    position: absolute;
-
    width: 24rem;
-
    z-index: 10;
-
  }
-
  @media (max-width: 720px) {
-
    .dropdown {
-
      width: auto;
-
      left: 2rem;
-
      right: 2rem;
-
      z-index: 10;
-
    }
-
  }
-
  label {
-
    color: var(--color-foreground-6);
-
    display: block;
-
    font-size: var(--font-size-tiny);
-
    padding: 0.5rem 0.5rem 0 0.25rem;
-
  }
-
</style>
-

-
<Floating>
-
  <div slot="toggle" class="clone-button" role="button">Clone</div>
-
  <svelte:fragment slot="modal">
-
    <div class="dropdown">
-
      <Command color="caution" command={radCloneUrl} />
-
      <label for="rad-clone-url">
-
        Use the <a
-
          target="_blank"
-
          rel="noreferrer"
-
          href="https://radicle.xyz/get-started.html"
-
          class="link">
-
          Radicle CLI
-
        </a>
-
        to clone this project.
-
      </label>
-
      <br />
-
      <Command color="caution" command={gitCloneUrl} />
-
      <label for="git-clone-url">
-
        Use Git to clone this repository from the URL above.
-
      </label>
-
    </div>
-
  </svelte:fragment>
-
</Floating>
modified src/views/projects/Commit.svelte
@@ -3,7 +3,7 @@

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

-
  import Changeset from "@app/views/projects/SourceBrowser/Changeset.svelte";
+
  import Changeset from "@app/views/projects/Changeset.svelte";
  import Clipboard from "@app/components/Clipboard.svelte";
  import CommitAuthorship from "@app/views/projects/Commit/CommitAuthorship.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
modified src/views/projects/Header.svelte
@@ -8,7 +8,7 @@
  import { isLocal } from "@app/lib/utils";
  import { pluralize } from "@app/lib/pluralize";

-
  import CloneButton from "@app/views/projects/CloneButton.svelte";
+
  import CloneButton from "@app/views/projects/Header/CloneButton.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
  import SquareButton from "@app/components/SquareButton.svelte";
added src/views/projects/Header/CloneButton.svelte
@@ -0,0 +1,87 @@
+
<script lang="ts">
+
  import type { BaseUrl } from "@httpd-client";
+

+
  import { parseRepositoryId } from "@app/lib/utils";
+
  import { config } from "@app/lib/config";
+

+
  import Command from "@app/components/Command.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let id: string;
+
  export let name: string;
+

+
  $: radCloneUrl = `rad clone ${id}`;
+
  $: portFragment =
+
    baseUrl.scheme === config.nodes.defaultHttpdScheme &&
+
    baseUrl.port === config.nodes.defaultHttpdPort
+
      ? ""
+
      : `:${baseUrl.port}`;
+
  $: gitCloneUrl = `git clone ${baseUrl.scheme}://${
+
    baseUrl.hostname
+
  }${portFragment}/${parseRepositoryId(id)?.pubkey ?? id}.git ${name}`;
+
</script>
+

+
<style>
+
  .clone-button {
+
    background-color: var(--color-caution-3);
+
    border-radius: var(--border-radius-small);
+
    color: var(--color-caution-6);
+
    font-family: var(--font-family-monospace);
+
    min-width: max-content;
+
    height: 2rem;
+
    line-height: initial;
+
    padding: 0.5rem 0.75rem;
+
  }
+
  .clone-button:hover {
+
    background-color: var(--color-caution-4);
+
  }
+
  .dropdown {
+
    background-color: var(--color-background-1);
+
    border-radius: var(--border-radius-small);
+
    box-shadow: var(--elevation-low);
+
    margin-top: 0.5rem;
+
    padding: 1rem;
+
    position: absolute;
+
    width: 24rem;
+
    z-index: 10;
+
  }
+
  @media (max-width: 720px) {
+
    .dropdown {
+
      width: auto;
+
      left: 2rem;
+
      right: 2rem;
+
      z-index: 10;
+
    }
+
  }
+
  label {
+
    color: var(--color-foreground-6);
+
    display: block;
+
    font-size: var(--font-size-tiny);
+
    padding: 0.5rem 0.5rem 0 0.25rem;
+
  }
+
</style>
+

+
<Floating>
+
  <div slot="toggle" class="clone-button" role="button">Clone</div>
+
  <svelte:fragment slot="modal">
+
    <div class="dropdown">
+
      <Command color="caution" command={radCloneUrl} />
+
      <label for="rad-clone-url">
+
        Use the <a
+
          target="_blank"
+
          rel="noreferrer"
+
          href="https://radicle.xyz/get-started.html"
+
          class="link">
+
          Radicle CLI
+
        </a>
+
        to clone this project.
+
      </label>
+
      <br />
+
      <Command color="caution" command={gitCloneUrl} />
+
      <label for="git-clone-url">
+
        Use Git to clone this repository from the URL above.
+
      </label>
+
    </div>
+
  </svelte:fragment>
+
</Floating>
modified src/views/projects/History.svelte
@@ -16,7 +16,7 @@
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
  import Layout from "./Layout.svelte";
  import Loading from "@app/components/Loading.svelte";
-
  import SourceBrowsingHeader from "./SourceBrowsingHeader.svelte";
+
  import Header from "./Source/Header.svelte";
  import { COMMITS_PER_PAGE } from "./router";

  export let baseUrl: BaseUrl;
@@ -110,7 +110,7 @@
</style>

<Layout {baseUrl} {project} {peer} activeTab="source">
-
  <SourceBrowsingHeader
+
  <Header
    node={baseUrl}
    {project}
    peers={peersWithRoute}
modified src/views/projects/Patch.svelte
@@ -40,7 +40,7 @@

  import Authorship from "@app/components/Authorship.svelte";
  import Badge from "@app/components/Badge.svelte";
-
  import Changeset from "@app/views/projects/SourceBrowser/Changeset.svelte";
+
  import Changeset from "@app/views/projects/Changeset.svelte";
  import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
  import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
  import Dropdown from "@app/components/Dropdown.svelte";
deleted src/views/projects/PeerSelector.svelte
@@ -1,130 +0,0 @@
-
<script lang="ts">
-
  import type { Remote } from "@httpd-client";
-
  import { type Route } from "@app/lib/router";
-

-
  import { closeFocused } from "@app/components/Floating.svelte";
-
  import { formatNodeId, truncateId } from "@app/lib/utils";
-
  import { pluralize } from "@app/lib/pluralize";
-

-
  import Avatar from "@app/components/Avatar.svelte";
-
  import Badge from "@app/components/Badge.svelte";
-
  import Dropdown from "@app/components/Dropdown.svelte";
-
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
-
  import Floating from "@app/components/Floating.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  export let peers: Array<{ remote: Remote; selected: boolean; route: Route }>;
-

-
  $: selectedPeer = peers.find(p => p.selected)?.remote;
-

-
  function createTitle(p: Remote): string {
-
    const nodeId = formatNodeId(p.id);
-
    return p.delegate
-
      ? `${nodeId} is a delegate of this project`
-
      : `${nodeId} is a peer tracked by this node`;
-
  }
-
</script>
-

-
<style>
-
  .selector {
-
    display: flex;
-
    align-items: center;
-
    justify-content: center;
-
    font-family: var(--font-family-monospace);
-
  }
-
  .selector .peer {
-
    padding: 0.5rem 0.75rem;
-
    color: var(--color-secondary);
-
    background-color: var(--color-secondary-2);
-
    border-radius: var(--border-radius-small);
-
  }
-
  .selector .peer.not-allowed {
-
    cursor: not-allowed;
-
  }
-
  .peer:hover {
-
    background-color: var(--color-foreground-2);
-
  }
-
  .prefix {
-
    display: inline-block;
-
    color: var(--color-secondary-6);
-
  }
-
  .stat {
-
    display: flex;
-
    align-items: center;
-
    font-family: var(--font-family-monospace);
-
    padding: 0.5rem;
-
    height: 2rem;
-
    line-height: initial;
-
    background: var(--color-foreground-1);
-
    gap: 0.5rem;
-
  }
-
  .avatar-id {
-
    display: flex;
-
    gap: 0.25rem;
-
  }
-
  .alias {
-
    color: var(--color-secondary-6);
-
  }
-
</style>
-

-
<Floating>
-
  <div slot="toggle" class="selector" title="Change peer">
-
    <div class="stat peer" class:not-allowed={!peers}>
-
      {#if selectedPeer}
-
        <span class="avatar-id">
-
          <Avatar nodeId={selectedPeer.id} inline />
-
          <!-- Ignore prettier to avoid getting a whitespace between
-
             did:key: and the nid due to a newline. -->
-
          <!-- prettier-ignore -->
-
          <span><span style:color="var(--color-secondary-5)">did:key:</span>{truncateId(selectedPeer.id)}</span>
-
          {#if selectedPeer.alias}
-
            <span class="alias">({selectedPeer.alias})</span>
-
          {/if}
-
        </span>
-
        {#if selectedPeer.delegate}
-
          <Badge variant="primary">delegate</Badge>
-
        {/if}
-
      {:else}
-
        <Icon size="small" name="fork" />{peers.length}
-
        {pluralize("remote", peers.length)}
-
      {/if}
-
    </div>
-
  </div>
-

-
  <svelte:fragment slot="modal">
-
    <Dropdown items={peers}>
-
      <svelte:fragment slot="item" let:item>
-
        <div class="dropdown-item">
-
          <Link on:afterNavigate={() => closeFocused()} route={item.route}>
-
            <DropdownItem
-
              selected={item.selected}
-
              title={createTitle(item.remote)}
-
              size="tiny">
-
              <span class="avatar-id">
-
                <Avatar nodeId={item.remote.id} inline />
-
                <div class="layout-desktop">
-
                  <!-- prettier-ignore -->
-
                  <span><span class="prefix">did:key:</span>{item.remote.id}</span>
-
                  {#if item.remote.alias}
-
                    <span class="alias">({item.remote.alias})</span>
-
                  {/if}
-
                </div>
-
                <div class="layout-mobile">
-
                  <!-- prettier-ignore -->
-
                  <span><span class="prefix">did:key:</span>{truncateId(item.remote.id)}</span>
-
                  {#if item.remote.alias}
-
                    <span class="alias">({item.remote.alias})</span>
-
                  {/if}
-
                </div>
-
              </span>
-
              {#if item.remote.delegate}
-
                <Badge variant="primary">delegate</Badge>
-
              {/if}
-
            </DropdownItem>
-
          </Link>
-
        </div>
-
      </svelte:fragment>
-
    </Dropdown>
-
  </svelte:fragment>
-
</Floating>
deleted src/views/projects/Readme.svelte
@@ -1,53 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl } from "@httpd-client";
-

-
  import Markdown from "@app/components/Markdown.svelte";
-
  import { routeToPath } from "@app/lib/router";
-

-
  export let projectId: string;
-
  export let peer: string | undefined;
-
  export let baseUrl: BaseUrl;
-
  export let revision: string | undefined;
-
  export let content: string;
-
  export let path: string;
-
  export let rawPath: string;
-

-
  let linkBaseUrl: string | undefined;
-

-
  $: {
-
    if (!path || path === "/") {
-
      // For the default root path, the `tree/<revision>` portion is omitted
-
      // from the URL. This means that links cannot be resolved with respect
-
      // to the current location. To work around this we provide path that
-
      // results a fully expanded URL with which we can resolve all links in the
-
      // Markdown.
-
      linkBaseUrl = new URL(
-
        routeToPath({
-
          resource: "project.source",
-
          project: projectId,
-
          node: baseUrl,
-
          peer,
-
          revision,
-
          path: "README.md",
-
        }),
-
        window.origin,
-
      ).href;
-
    } else {
-
      linkBaseUrl = undefined;
-
    }
-
  }
-
</script>
-

-
<style>
-
  article {
-
    padding: 2rem;
-
    width: 100%;
-
    background: var(--color-background-1);
-
    border-bottom-left-radius: var(--border-radius-small);
-
    border-bottom-right-radius: var(--border-radius-small);
-
  }
-
</style>
-

-
<article>
-
  <Markdown {linkBaseUrl} {content} {rawPath} {path} />
-
</article>
modified src/views/projects/Source.svelte
@@ -9,10 +9,10 @@
  import Button from "@app/components/Button.svelte";
  import Layout from "./Layout.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
-
  import SourceBrowsingHeader from "./SourceBrowsingHeader.svelte";
+
  import Header from "./Source/Header.svelte";

-
  import BlobComponent from "./Blob.svelte";
-
  import TreeComponent from "./Tree.svelte";
+
  import BlobComponent from "./Source/Blob.svelte";
+
  import TreeComponent from "./Source/Tree.svelte";

  export let baseUrl: BaseUrl;
  export let blobResult: BlobResult;
@@ -147,7 +147,7 @@
</style>

<Layout {baseUrl} {project} {peer} activeTab="source">
-
  <SourceBrowsingHeader
+
  <Header
    node={baseUrl}
    {project}
    peers={peersWithRoute}
added src/views/projects/Source/Blob.svelte
@@ -0,0 +1,281 @@
+
<script lang="ts">
+
  import type { BaseUrl, Blob } from "@httpd-client";
+

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

+
  import * as Syntax from "@app/lib/syntax";
+
  import { isMarkdownPath, twemoji } from "@app/lib/utils";
+
  import { lineNumbersGutter } from "@app/lib/syntax";
+

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

+
  export let baseUrl: BaseUrl;
+
  export let projectId: string;
+
  export let peer: string | undefined;
+
  export let revision: string | undefined;
+
  export let path: string;
+
  export let blob: Blob;
+
  export let highlighted: Syntax.Root | undefined;
+
  export let rawPath: string;
+

+
  $: lastCommit = blob.lastCommit;
+

+
  $: parentDir = blob.path
+
    .match(/^.*\/|/)
+
    ?.values()
+
    .next().value;
+

+
  $: content = highlighted ? lineNumbersGutter(highlighted) : undefined;
+

+
  let selectedLineId: string | undefined = undefined;
+
  $: {
+
    content;
+
    updateSelectedLineId();
+
  }
+

+
  function updateSelectedLineId() {
+
    const fragmentId = window.location.hash.substring(1);
+
    if (fragmentId && fragmentId.match(/^L\d+$/)) {
+
      selectedLineId = fragmentId;
+
    } else {
+
      selectedLineId = undefined;
+
    }
+
  }
+

+
  $: isMarkdown = isMarkdownPath(blob.path);
+
  $: showMarkdown = isMarkdown && selectedLineId === undefined;
+

+
  function toggleMarkdown() {
+
    window.location.hash = "";
+
    showMarkdown = !showMarkdown;
+
  }
+

+
  afterUpdate(() => {
+
    for (const item of document.getElementsByClassName("highlight")) {
+
      item.classList.remove("highlight");
+
    }
+
    if (selectedLineId) {
+
      const target = document.getElementById(selectedLineId);
+
      if (target) {
+
        target.classList.add("highlight");
+
        target.scrollIntoView();
+
      }
+
    }
+
  });
+

+
  onMount(async () => {
+
    window.addEventListener("hashchange", updateSelectedLineId);
+
  });
+

+
  onDestroy(() => {
+
    window.removeEventListener("hashchange", updateSelectedLineId);
+
  });
+
</script>
+

+
<style>
+
  header .file-header {
+
    display: flex;
+
    height: 3rem;
+
    align-items: center;
+
    justify-content: space-between;
+
    padding: 0 0.5rem 0 1rem;
+
    color: var(--color-foreground);
+
    border-width: 1px 1px 0 1px;
+
    border-color: var(--color-foreground-3);
+
    border-style: solid;
+
    border-top-left-radius: var(--border-radius-small);
+
    border-top-right-radius: var(--border-radius-small);
+
  }
+

+
  .file-header .right {
+
    display: flex;
+
    flex-direction: row;
+
    align-items: center;
+
    justify-content: flex-end;
+
    overflow-x: hidden;
+
    text-overflow: ellipsis;
+
    width: 100%;
+
  }
+

+
  header .file-name {
+
    font-weight: var(--font-weight-normal);
+
    flex-shrink: 0;
+
    white-space: nowrap;
+
    overflow: hidden;
+
    text-overflow: ellipsis;
+
    margin-right: 1rem;
+
  }
+

+
  .last-commit {
+
    padding: 0.5rem 0.75rem;
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-2);
+
    font-size: var(--font-size-tiny);
+
    border-radius: var(--border-radius-small);
+
    overflow-x: hidden;
+
    text-overflow: ellipsis;
+
    white-space: nowrap;
+
  }
+
  .last-commit .hash {
+
    font-weight: var(--font-weight-bold);
+
    font-family: var(--font-family-monospace);
+
    margin-right: 0.25rem;
+
  }
+

+
  .toggle {
+
    margin-right: 0.5rem;
+
  }
+

+
  .code :global(.line-number) {
+
    color: var(--color-foreground-4);
+
    text-align: right;
+
    padding: 0;
+
    user-select: none;
+
  }
+
  .code :global(.line-number a) {
+
    display: block;
+
    padding: 0 1rem;
+
  }
+
  .code :global(.line-number:hover) {
+
    cursor: pointer;
+
    color: var(--color-foreground);
+
  }
+

+
  .code :global(.content) {
+
    display: inline;
+
    font-family: var(--font-family-monospace);
+
    margin: 0;
+
  }
+

+
  .code :global(.line) {
+
    line-height: 22px; /* This seems to be the line-height of a pre code block */
+
  }
+
  .code :global(.highlight) {
+
    background-color: var(--color-caution-3);
+
  }
+
  .code :global(.highlight td a) {
+
    color: var(--color-foreground);
+
  }
+

+
  .code :global(.line-content) {
+
    padding: 0;
+
    width: 100%;
+
  }
+

+
  .code {
+
    width: 100%;
+
    border-spacing: 0;
+
    overflow-x: auto;
+
    font-size: var(--font-size-small);
+
    padding-top: 1rem;
+
    margin-bottom: 1.5rem;
+
  }
+

+
  .container {
+
    position: relative;
+
    display: flex;
+
    overflow-x: auto;
+
    border: 1px solid var(--color-foreground-3);
+
    border-top-style: dashed;
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
    background: var(--color-background-1);
+
  }
+

+
  .binary {
+
    display: flex;
+
    flex-direction: column;
+
    justify-content: center;
+
    align-items: center;
+
    width: 100%;
+
    height: 16rem;
+
    background-color: var(--color-foreground-1);
+
    color: var(--color-foreground-6);
+
    font-family: var(--font-family-monospace);
+
  }
+
  .binary > * {
+
    margin-bottom: 1rem;
+
  }
+

+
  .no-scrollbar {
+
    scrollbar-width: none;
+
  }
+

+
  .markdown {
+
    max-width: 64rem;
+
  }
+

+
  .no-scrollbar::-webkit-scrollbar {
+
    display: none;
+
  }
+

+
  @media (max-width: 960px) {
+
    .code {
+
      font-size: var(--font-size-small);
+
    }
+
  }
+

+
  @media (max-width: 720px) {
+
    .right {
+
      justify-content: center;
+
    }
+
  }
+
</style>
+

+
<div class:markdown={isMarkdown}>
+
  <header>
+
    <div class="file-header">
+
      <span class="file-name">
+
        <span style:color="var(--color-foreground-5)">{parentDir}</span>
+
        &#8203;
+
        <span>{blob.name}</span>
+
      </span>
+
      <div class="right">
+
        {#if isMarkdown}
+
          <div title="Toggle render method" class="toggle">
+
            <SquareButton clickable on:click={toggleMarkdown}>
+
              {showMarkdown ? "Plain" : "Markdown"}
+
            </SquareButton>
+
          </div>
+
        {/if}
+
        <a href="{rawPath}/{blob.path}" class="toggle">
+
          <SquareButton clickable>Raw</SquareButton>
+
        </a>
+
        <div class="last-commit" title={lastCommit.author.name} use:twemoji>
+
          <span class="hash">
+
            {lastCommit.id.slice(0, 7)}
+
          </span>
+
          {lastCommit.summary}
+
        </div>
+
      </div>
+
    </div>
+
  </header>
+
  <div class="container">
+
    {#if blob.binary}
+
      <div class="binary">
+
        <div use:twemoji>👀</div>
+
        <span class="txt-tiny">Binary content</span>
+
      </div>
+
    {:else if showMarkdown && blob.content}
+
      <Readme
+
        {baseUrl}
+
        {projectId}
+
        {peer}
+
        {revision}
+
        content={blob.content}
+
        {rawPath}
+
        {path} />
+
    {:else if content}
+
      <table class="code no-scrollbar">
+
        {@html toHtml(content)}
+
      </table>
+
    {:else}
+
      <div class="binary">
+
        <div use:twemoji>🍂</div>
+
        <span class="txt-tiny">Empty file</span>
+
      </div>
+
    {/if}
+
  </div>
+
</div>
added src/views/projects/Source/BranchSelector.svelte
@@ -0,0 +1,85 @@
+
<script lang="ts">
+
  import * as utils from "@app/lib/utils";
+
  import { closeFocused } from "@app/components/Floating.svelte";
+

+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import type { Route } from "@app/lib/router";
+

+
  export let selectedBranch: string | undefined;
+
  export let selectedCommitId: string;
+
  export let branches: Array<{ name: string; route: Route }>;
+

+
  $: hideDropdown = branches.length <= 1;
+
  $: selectedCommitShortId = utils.formatCommit(selectedCommitId);
+
</script>
+

+
<style>
+
  .commit {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    line-height: initial;
+

+
    font-family: var(--font-family-monospace);
+
    color: var(--color-secondary);
+
  }
+
  .branch-name {
+
    height: 2rem;
+
    padding: 0.5rem 0.75rem;
+
    background-color: var(--color-secondary-2);
+
    border-radius: var(--border-radius-small) 0 0 var(--border-radius-small);
+
  }
+
  .branch-name.not-allowed {
+
    cursor: not-allowed;
+
  }
+
  .branch-name:hover:not(.not-allowed) {
+
    background-color: var(--color-foreground-2);
+
  }
+
  .commit-id {
+
    height: 2rem;
+
    padding: 0.5rem 0.75rem;
+
    background-color: var(--color-secondary-1);
+
    border-radius: 0 var(--border-radius-small) var(--border-radius-small) 0;
+
  }
+
  .commit-id.standalone {
+
    border-radius: var(--border-radius-small);
+
  }
+
</style>
+

+
<div class="commit" title="Current branch">
+
  {#if selectedBranch}
+
    <Floating disabled={hideDropdown}>
+
      <div
+
        slot="toggle"
+
        title="Change branch"
+
        class="branch-name"
+
        class:not-allowed={hideDropdown}>
+
        {selectedBranch}
+
      </div>
+
      <Dropdown slot="modal" items={branches}>
+
        <Link
+
          slot="item"
+
          let:item
+
          route={item.route}
+
          on:afterNavigate={() => closeFocused()}>
+
          <DropdownItem selected={item.name === selectedBranch} size="tiny">
+
            {item.name}
+
          </DropdownItem>
+
        </Link>
+
      </Dropdown>
+
    </Floating>
+
    <div class="commit-id">
+
      {selectedCommitShortId}
+
    </div>
+
  {:else}
+
    <div class="commit-id standalone layout-desktop">
+
      {selectedCommitId}
+
    </div>
+
    <div class="commit-id standalone layout-mobile">
+
      {selectedCommitShortId}
+
    </div>
+
  {/if}
+
</div>
added src/views/projects/Source/Header.svelte
@@ -0,0 +1,82 @@
+
<script lang="ts">
+
  import type { BaseUrl, Project, Remote, Tree } from "@httpd-client";
+
  import { type Route } from "@app/lib/router";
+

+
  import { pluralize } from "@app/lib/pluralize";
+

+
  import BranchSelector from "./BranchSelector.svelte";
+
  import PeerSelector from "./PeerSelector.svelte";
+

+
  import Link from "@app/components/Link.svelte";
+
  import SquareButton from "@app/components/SquareButton.svelte";
+

+
  export let node: BaseUrl;
+
  export let branches: Array<{ name: string; route: Route }>;
+
  export let peers: Array<{ remote: Remote; selected: boolean; route: Route }>;
+
  export let historyLinkActive: boolean;
+
  export let revision: string | undefined;
+
  export let tree: Tree;
+
  export let project: Project;
+

+
  let selectedBranch: string | undefined;
+

+
  // Revision may be a commit ID, a branch name or `undefined` which means the
+
  // default branch. We assign `selectedBranch` accordingly.
+
  $: if (revision === commitId) {
+
    selectedBranch = undefined;
+
  } else {
+
    selectedBranch = revision || project.defaultBranch;
+
  }
+

+
  $: commitId = tree.lastCommit.id;
+
  $: peer = peers.find(p => p.selected)?.remote.id;
+
</script>
+

+
<style>
+
  .header {
+
    font-size: var(--font-size-tiny);
+
    padding: 0 2rem 0 8rem;
+
    display: flex;
+
    align-items: center;
+
    justify-content: left;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
    margin-bottom: 2rem;
+
  }
+

+
  @media (max-width: 960px) {
+
    .header {
+
      padding-left: 2rem;
+
    }
+
    .header {
+
      margin-bottom: 1.5rem;
+
    }
+
  }
+
</style>
+

+
<div class="header">
+
  {#if peers.length > 0}
+
    <PeerSelector {peers} />
+
  {/if}
+

+
  <BranchSelector {branches} selectedCommitId={commitId} {selectedBranch} />
+

+
  <Link
+
    route={{
+
      resource: "project.history",
+
      project: project.id,
+
      node: node,
+
      peer,
+
      revision,
+
    }}>
+
    <SquareButton active={historyLinkActive}>
+
      <span class="txt-bold">{tree.stats.commits}</span>
+
      {pluralize("commit", tree.stats.commits)}
+
    </SquareButton>
+
  </Link>
+

+
  <SquareButton hoverable={false}>
+
    <span class="txt-bold">{tree.stats.contributors}</span>
+
    {pluralize("contributor", tree.stats.contributors)}
+
  </SquareButton>
+
</div>
added src/views/projects/Source/PeerSelector.svelte
@@ -0,0 +1,130 @@
+
<script lang="ts">
+
  import type { Remote } from "@httpd-client";
+
  import { type Route } from "@app/lib/router";
+

+
  import { closeFocused } from "@app/components/Floating.svelte";
+
  import { formatNodeId, truncateId } from "@app/lib/utils";
+
  import { pluralize } from "@app/lib/pluralize";
+

+
  import Avatar from "@app/components/Avatar.svelte";
+
  import Badge from "@app/components/Badge.svelte";
+
  import Dropdown from "@app/components/Dropdown.svelte";
+
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
+
  import Floating from "@app/components/Floating.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Link from "@app/components/Link.svelte";
+

+
  export let peers: Array<{ remote: Remote; selected: boolean; route: Route }>;
+

+
  $: selectedPeer = peers.find(p => p.selected)?.remote;
+

+
  function createTitle(p: Remote): string {
+
    const nodeId = formatNodeId(p.id);
+
    return p.delegate
+
      ? `${nodeId} is a delegate of this project`
+
      : `${nodeId} is a peer tracked by this node`;
+
  }
+
</script>
+

+
<style>
+
  .selector {
+
    display: flex;
+
    align-items: center;
+
    justify-content: center;
+
    font-family: var(--font-family-monospace);
+
  }
+
  .selector .peer {
+
    padding: 0.5rem 0.75rem;
+
    color: var(--color-secondary);
+
    background-color: var(--color-secondary-2);
+
    border-radius: var(--border-radius-small);
+
  }
+
  .selector .peer.not-allowed {
+
    cursor: not-allowed;
+
  }
+
  .peer:hover {
+
    background-color: var(--color-foreground-2);
+
  }
+
  .prefix {
+
    display: inline-block;
+
    color: var(--color-secondary-6);
+
  }
+
  .stat {
+
    display: flex;
+
    align-items: center;
+
    font-family: var(--font-family-monospace);
+
    padding: 0.5rem;
+
    height: 2rem;
+
    line-height: initial;
+
    background: var(--color-foreground-1);
+
    gap: 0.5rem;
+
  }
+
  .avatar-id {
+
    display: flex;
+
    gap: 0.25rem;
+
  }
+
  .alias {
+
    color: var(--color-secondary-6);
+
  }
+
</style>
+

+
<Floating>
+
  <div slot="toggle" class="selector" title="Change peer">
+
    <div class="stat peer" class:not-allowed={!peers}>
+
      {#if selectedPeer}
+
        <span class="avatar-id">
+
          <Avatar nodeId={selectedPeer.id} inline />
+
          <!-- Ignore prettier to avoid getting a whitespace between
+
             did:key: and the nid due to a newline. -->
+
          <!-- prettier-ignore -->
+
          <span><span style:color="var(--color-secondary-5)">did:key:</span>{truncateId(selectedPeer.id)}</span>
+
          {#if selectedPeer.alias}
+
            <span class="alias">({selectedPeer.alias})</span>
+
          {/if}
+
        </span>
+
        {#if selectedPeer.delegate}
+
          <Badge variant="primary">delegate</Badge>
+
        {/if}
+
      {:else}
+
        <Icon size="small" name="fork" />{peers.length}
+
        {pluralize("remote", peers.length)}
+
      {/if}
+
    </div>
+
  </div>
+

+
  <svelte:fragment slot="modal">
+
    <Dropdown items={peers}>
+
      <svelte:fragment slot="item" let:item>
+
        <div class="dropdown-item">
+
          <Link on:afterNavigate={() => closeFocused()} route={item.route}>
+
            <DropdownItem
+
              selected={item.selected}
+
              title={createTitle(item.remote)}
+
              size="tiny">
+
              <span class="avatar-id">
+
                <Avatar nodeId={item.remote.id} inline />
+
                <div class="layout-desktop">
+
                  <!-- prettier-ignore -->
+
                  <span><span class="prefix">did:key:</span>{item.remote.id}</span>
+
                  {#if item.remote.alias}
+
                    <span class="alias">({item.remote.alias})</span>
+
                  {/if}
+
                </div>
+
                <div class="layout-mobile">
+
                  <!-- prettier-ignore -->
+
                  <span><span class="prefix">did:key:</span>{truncateId(item.remote.id)}</span>
+
                  {#if item.remote.alias}
+
                    <span class="alias">({item.remote.alias})</span>
+
                  {/if}
+
                </div>
+
              </span>
+
              {#if item.remote.delegate}
+
                <Badge variant="primary">delegate</Badge>
+
              {/if}
+
            </DropdownItem>
+
          </Link>
+
        </div>
+
      </svelte:fragment>
+
    </Dropdown>
+
  </svelte:fragment>
+
</Floating>
added src/views/projects/Source/Readme.svelte
@@ -0,0 +1,53 @@
+
<script lang="ts">
+
  import type { BaseUrl } from "@httpd-client";
+

+
  import Markdown from "@app/components/Markdown.svelte";
+
  import { routeToPath } from "@app/lib/router";
+

+
  export let projectId: string;
+
  export let peer: string | undefined;
+
  export let baseUrl: BaseUrl;
+
  export let revision: string | undefined;
+
  export let content: string;
+
  export let path: string;
+
  export let rawPath: string;
+

+
  let linkBaseUrl: string | undefined;
+

+
  $: {
+
    if (!path || path === "/") {
+
      // For the default root path, the `tree/<revision>` portion is omitted
+
      // from the URL. This means that links cannot be resolved with respect
+
      // to the current location. To work around this we provide path that
+
      // results a fully expanded URL with which we can resolve all links in the
+
      // Markdown.
+
      linkBaseUrl = new URL(
+
        routeToPath({
+
          resource: "project.source",
+
          project: projectId,
+
          node: baseUrl,
+
          peer,
+
          revision,
+
          path: "README.md",
+
        }),
+
        window.origin,
+
      ).href;
+
    } else {
+
      linkBaseUrl = undefined;
+
    }
+
  }
+
</script>
+

+
<style>
+
  article {
+
    padding: 2rem;
+
    width: 100%;
+
    background: var(--color-background-1);
+
    border-bottom-left-radius: var(--border-radius-small);
+
    border-bottom-right-radius: var(--border-radius-small);
+
  }
+
</style>
+

+
<article>
+
  <Markdown {linkBaseUrl} {content} {rawPath} {path} />
+
</article>
added src/views/projects/Source/Tree.svelte
@@ -0,0 +1,50 @@
+
<script lang="ts" strictEvents>
+
  import type { BaseUrl, Tree } from "@httpd-client";
+

+
  import { createEventDispatcher } from "svelte";
+

+
  import File from "./Tree/File.svelte";
+
  import Folder from "./Tree/Folder.svelte";
+
  import Link from "@app/components/Link.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
+
  export let path: string;
+
  export let peer: string | undefined;
+
  export let projectId: string;
+
  export let revision: string | undefined;
+
  export let tree: Tree;
+

+
  const dispatch = createEventDispatcher<{ select: string }>();
+
  const onSelect = ({ detail: path }: { detail: string }): void => {
+
    dispatch("select", path);
+
  };
+
</script>
+

+
{#each tree.entries as entry (entry.path)}
+
  {#if entry.kind === "tree"}
+
    <Folder
+
      currentPath={path}
+
      name={entry.name}
+
      on:select={onSelect}
+
      prefix={`${entry.path}/`}
+
      {baseUrl}
+
      {fetchTree}
+
      {peer}
+
      {projectId}
+
      {revision} />
+
  {:else}
+
    <Link
+
      route={{
+
        resource: "project.source",
+
        project: projectId,
+
        node: baseUrl,
+
        path: entry.path,
+
        peer,
+
        revision,
+
      }}
+
      on:afterNavigate={() => onSelect({ detail: entry.path })}>
+
      <File active={entry.path === path} name={entry.name} />
+
    </Link>
+
  {/if}
+
{/each}
added src/views/projects/Source/Tree/File.svelte
@@ -0,0 +1,40 @@
+
<script lang="ts">
+
  export let active: boolean;
+
  export let name: string;
+
</script>
+

+
<style>
+
  .file {
+
    color: var(--color-foreground-6);
+
    border-radius: var(--border-radius-small);
+
    cursor: pointer;
+
    display: flex;
+
    justify-content: space-between;
+
    line-height: 1.5em;
+
    margin: 0.125rem 0;
+
    padding: 0.25rem;
+
    width: 100%;
+
  }
+

+
  .file:hover {
+
    background-color: var(--color-foreground-1);
+
  }
+

+
  .file.active {
+
    color: var(--color-foreground) !important;
+
    background-color: var(--color-foreground-1);
+
  }
+

+
  .name {
+
    margin-left: 0.25rem;
+
    user-select: none;
+
    white-space: nowrap;
+
    text-overflow: ellipsis !important;
+
    overflow: hidden;
+
    max-width: 24ch;
+
  }
+
</style>
+

+
<div class="file" class:active>
+
  <span class="name">{name}</span>
+
</div>
added src/views/projects/Source/Tree/Folder.svelte
@@ -0,0 +1,116 @@
+
<script lang="ts" strictEvents>
+
  import type { BaseUrl, Tree } from "@httpd-client";
+

+
  import { createEventDispatcher } from "svelte";
+

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

+
  import File from "./File.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let currentPath: string;
+
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
+
  export let name: string;
+
  export let peer: string | undefined;
+
  export let prefix: string;
+
  export let projectId: string;
+
  export let revision: string | undefined;
+

+
  $: expanded = currentPath.indexOf(prefix) === 0;
+
  $: tree = expanded
+
    ? fetchTree(prefix).then(tree => {
+
        return tree;
+
      })
+
    : Promise.resolve(undefined);
+

+
  const dispatch = createEventDispatcher<{ select: string }>();
+
  const onSelectFile = ({ detail: path }: { detail: string }) =>
+
    dispatch("select", path);
+

+
  const onClick = () => {
+
    expanded = !expanded;
+

+
    tree = fetchTree(prefix).then(tree => {
+
      if (expanded) return tree;
+
    });
+
  };
+
</script>
+

+
<style>
+
  .folder {
+
    display: flex;
+
    cursor: pointer;
+
    padding: 0.25rem;
+
    margin: 0.125rem 0;
+
    color: var(--color-foreground-6);
+
    user-select: none;
+
    line-height: 1.5rem;
+
    white-space: nowrap;
+
  }
+
  .folder:hover {
+
    background-color: var(--color-foreground-1);
+
    border-radius: var(--border-radius-small);
+
  }
+

+
  .folder-name {
+
    margin-left: 0.25rem;
+
    color: var(--color-secondary-6);
+
  }
+

+
  .container {
+
    padding-left: 0.5rem;
+
    margin: 0;
+
  }
+

+
  .loading {
+
    display: inline-block;
+
    padding: 0.5rem 0;
+
  }
+
</style>
+

+
<!-- svelte-ignore a11y-click-events-have-key-events -->
+
<!-- svelte-ignore a11y-no-static-element-interactions -->
+
<div class="folder" on:click={onClick}>
+
  <span class="folder-name">{name}/</span>
+
</div>
+

+
<div class="container">
+
  {#if expanded}
+
    {#await tree}
+
      <span class="loading"><Loading noDelay small margins /></span>
+
    {:then tree}
+
      {#if tree}
+
        {#each tree.entries as entry (entry.path)}
+
          {#if entry.kind === "tree"}
+
            <!-- svelte:self doesn't check types, make sure to pass in all
+
            required props! -->
+
            <svelte:self
+
              name={entry.name}
+
              on:select={onSelectFile}
+
              prefix={`${entry.path}/`}
+
              {baseUrl}
+
              {currentPath}
+
              {fetchTree}
+
              {peer}
+
              {projectId}
+
              {revision} />
+
          {:else}
+
            <Link
+
              route={{
+
                resource: "project.source",
+
                project: projectId,
+
                node: baseUrl,
+
                path: entry.path,
+
                peer,
+
                revision,
+
              }}
+
              on:afterNavigate={() => onSelectFile({ detail: entry.path })}>
+
              <File active={entry.path === currentPath} name={entry.name} />
+
            </Link>
+
          {/if}
+
        {/each}
+
      {/if}
+
    {/await}
+
  {/if}
+
</div>
deleted src/views/projects/SourceBrowser/Changeset.svelte
@@ -1,96 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Diff } from "@httpd-client";
-

-
  import { pluralize } from "@app/lib/pluralize";
-

-
  import FileDiff from "@app/views/projects/SourceBrowser/FileDiff.svelte";
-
  import FileLocationChange from "@app/views/projects/SourceBrowser/FileLocationChange.svelte";
-

-
  export let diff: Diff;
-
  export let revision: string;
-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
-

-
  const diffDescription = ({
-
    modified,
-
    added,
-
    deleted,
-
    moved,
-
    copied,
-
  }: Diff): string => {
-
    const s = [];
-

-
    if (modified.length) {
-
      s.push(
-
        `${modified.length} ${pluralize("file", modified.length)} changed`,
-
      );
-
    }
-
    if (added.length) {
-
      s.push(`${added.length} ${pluralize("file", added.length)} added`);
-
    }
-
    if (deleted.length) {
-
      s.push(`${deleted.length} ${pluralize("file", deleted.length)} deleted`);
-
    }
-
    if (copied.length) {
-
      s.push(`${copied.length} ${pluralize("file", copied.length)} copied`);
-
    }
-
    if (moved.length) {
-
      s.push(`${moved.length} ${pluralize("file", moved.length)} moved`);
-
    }
-
    return s.join(", ");
-
  };
-
</script>
-

-
<style>
-
  .changeset-summary {
-
    padding-bottom: 1.5rem;
-
    margin-left: 1rem;
-
  }
-
  .changeset-summary .additions {
-
    color: var(--color-positive-6);
-
  }
-
  .changeset-summary .deletions {
-
    color: var(--color-negative-6);
-
  }
-
</style>
-

-
<div class="changeset-summary">
-
  <span>{diffDescription(diff)}</span>
-
  with
-
  <span class="additions">
-
    {diff.stats.insertions}
-
    {pluralize("insertion", diff.stats.insertions)}
-
  </span>
-
  and
-
  <span class="deletions">
-
    {diff.stats.deletions}
-
    {pluralize("deletion", diff.stats.deletions)}
-
  </span>
-
</div>
-
<div class="diff-listing">
-
  {#each diff.added as file}
-
    <FileDiff
-
      {projectId}
-
      {baseUrl}
-
      {file}
-
      {revision}
-
      headerBadgeCaption="added" />
-
  {/each}
-
  {#each diff.deleted as file}
-
    <FileDiff
-
      {projectId}
-
      {baseUrl}
-
      {file}
-
      {revision}
-
      headerBadgeCaption="deleted" />
-
  {/each}
-
  {#each diff.modified as file}
-
    <FileDiff {projectId} {baseUrl} {file} {revision} />
-
  {/each}
-
  {#each diff.moved as file}
-
    <FileLocationChange {projectId} {baseUrl} {file} {revision} mode="moved" />
-
  {/each}
-
  {#each diff.copied as file}
-
    <FileLocationChange {projectId} {baseUrl} {file} {revision} mode="copied" />
-
  {/each}
-
</div>
deleted src/views/projects/SourceBrowser/FileDiff.svelte
@@ -1,400 +0,0 @@
-
<script lang="ts">
-
  import { onDestroy, onMount } from "svelte";
-
  import type {
-
    BaseUrl,
-
    DiffAddedDeletedModifiedChangeset,
-
    HunkLine,
-
  } from "@httpd-client";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  export let file: DiffAddedDeletedModifiedChangeset;
-
  export let revision: string;
-
  export let headerBadgeCaption: "added" | "deleted" | undefined = undefined;
-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
-

-
  let collapsed = false;
-
  let selection: Selection | undefined = undefined;
-

-
  onMount(() => {
-
    window.addEventListener("click", deselectHandler);
-
    window.addEventListener("hashchange", updateSelection);
-

-
    updateSelection();
-

-
    if (selection) {
-
      document
-
        .getElementById(
-
          [
-
            file.path,
-
            "H" + selection.startHunk,
-
            "L" + selection.startLine,
-
          ].join("-"),
-
        )
-
        ?.scrollIntoView();
-
    }
-
  });
-

-
  onDestroy(() => {
-
    window.removeEventListener("click", deselectHandler);
-
    window.removeEventListener("hashchange", updateSelection);
-
  });
-

-
  function deselectHandler(e: MouseEvent) {
-
    if (
-
      !(
-
        e.target instanceof HTMLElement &&
-
        e.target.closest("[data-file-diff-select]")
-
      )
-
    ) {
-
      updateHash("");
-
    }
-
  }
-

-
  function updateSelection() {
-
    const fragment = window.location.hash.substring(1);
-
    const match = fragment.match(/(.+):H(\d+)L(\d+)(H(\d+)L(\d+))?/);
-
    if (match && match[1] === file.path) {
-
      selection = {
-
        startHunk: parseInt(match[2]),
-
        startLine: parseInt(match[3]),
-
        endHunk: match[4] ? parseInt(match[5]) : undefined,
-
        endLine: match[4] ? parseInt(match[6]) : undefined,
-
      };
-
    } else {
-
      selection = undefined;
-
    }
-
  }
-

-
  function lineNumberR(line: HunkLine): string | number {
-
    switch (line.type) {
-
      case "addition": {
-
        return line.lineNo;
-
      }
-
      case "context": {
-
        return line.lineNoNew;
-
      }
-
      case "deletion": {
-
        return " ";
-
      }
-
    }
-
  }
-

-
  function lineNumberL(line: HunkLine): string | number {
-
    switch (line.type) {
-
      case "addition": {
-
        return " ";
-
      }
-
      case "context": {
-
        return line.lineNoOld;
-
      }
-
      case "deletion": {
-
        return line.lineNo;
-
      }
-
    }
-
  }
-

-
  function lineSign(line: HunkLine): string {
-
    switch (line.type) {
-
      case "addition": {
-
        return "+";
-
      }
-
      case "context": {
-
        return " ";
-
      }
-
      case "deletion": {
-
        return "-";
-
      }
-
    }
-
  }
-

-
  function isLineSelected(
-
    selection: Selection | undefined,
-
    hunkIdx: number,
-
    lineIdx: number,
-
  ): boolean {
-
    if (!selection) {
-
      return false;
-
    }
-

-
    if (selection.endHunk !== undefined && selection.endLine !== undefined) {
-
      return (
-
        hunkIdx >= selection.startHunk &&
-
        hunkIdx <= selection.endHunk &&
-
        (hunkIdx === selection.startHunk
-
          ? lineIdx >= selection.startLine
-
          : true) &&
-
        (hunkIdx === selection.endHunk ? lineIdx <= selection.endLine : true)
-
      );
-
    } else {
-
      return hunkIdx === selection.startHunk && lineIdx === selection.startLine;
-
    }
-
  }
-

-
  function hashFromSelection(
-
    hunkIdx: number,
-
    lineIdx: number,
-
    event: MouseEvent,
-
  ): string {
-
    const path = file.path;
-
    // single line selection
-
    if (!event.shiftKey) {
-
      return path + ":H" + hunkIdx + "L" + lineIdx;
-
    }
-

-
    if (!selection) {
-
      return "";
-
    }
-

-
    // range selection
-
    if (hunkIdx === selection.startHunk) {
-
      if (lineIdx >= selection.startLine) {
-
        return `${path}:H${hunkIdx}L${selection.startLine}H${hunkIdx}L${lineIdx}`;
-
      } else {
-
        return `${path}:H${hunkIdx}L${lineIdx}H${hunkIdx}L${selection.startLine}`;
-
      }
-
    } else if (hunkIdx < selection.startHunk) {
-
      return `${path}:H${hunkIdx}L${lineIdx}H${selection.startHunk}L${selection.startLine}`;
-
    } else {
-
      return `${path}:H${selection.startHunk}L${selection.startLine}H${hunkIdx}L${lineIdx}`;
-
    }
-
  }
-

-
  function selectLine(hunkIdx: number, lineIdx: number, event: MouseEvent) {
-
    updateHash(hashFromSelection(hunkIdx, lineIdx, event));
-
  }
-

-
  function updateHash(newHash: string) {
-
    if (newHash !== "") {
-
      window.location.hash = newHash;
-
    } else {
-
      window.history.replaceState(
-
        window.history.state,
-
        "",
-
        window.location.pathname + window.location.search,
-
      );
-
      selection = undefined;
-
    }
-
  }
-

-
  function hunkHeaderSelected(selection: Selection | undefined, hunk: number) {
-
    return (
-
      selection &&
-
      selection.endHunk !== undefined &&
-
      hunk > selection.startHunk &&
-
      hunk <= selection.endHunk
-
    );
-
  }
-

-
  interface Selection {
-
    startHunk: number;
-
    startLine: number;
-
    endHunk: number | undefined;
-
    endLine: number | undefined;
-
  }
-
</script>
-

-
<style>
-
  .wrapper {
-
    border: 1px solid var(--color-foreground-4);
-
    border-radius: var(--border-radius-small);
-
    margin-bottom: 2rem;
-
    line-height: 1.5rem;
-
  }
-
  .header {
-
    align-items: center;
-
    background: none;
-
    border-radius: 0;
-
    display: flex;
-
    flex-direction: row;
-
    height: 3rem;
-
    padding: 1rem;
-
  }
-
  main {
-
    font-size: var(--font-size-small);
-
    border-top: 1px dashed var(--color-foreground-4);
-
    background-color: var(--color-foreground-1);
-
    border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
-
    overflow-x: auto;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    gap: 1rem;
-
  }
-
  .placeholder {
-
    padding: 1rem;
-
    color: var(--color-foreground-5);
-
    text-align: center;
-
  }
-
  .browse {
-
    margin-left: auto;
-
    cursor: pointer;
-
  }
-
  .expand-button {
-
    cursor: pointer;
-
    user-select: none;
-
    margin-right: 0.5rem;
-
  }
-
  .diff {
-
    font-family: var(--font-family-monospace);
-
    table-layout: fixed;
-
    border-collapse: collapse;
-
    margin: 0.5rem 0;
-
  }
-
  .diff-line {
-
    vertical-align: top;
-
  }
-
  .diff-line.type-addition > * {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-2);
-
  }
-
  .diff-line.type-deletion > * {
-
    color: var(--color-negative-6);
-
    background-color: var(--color-negative-2);
-
  }
-
  .diff-line.selected > * {
-
    color: var(--color-foreground-6);
-
    background-color: var(--color-foreground-4);
-
  }
-
  .diff-line.selected.type-addition > * {
-
    color: var(--color-positive-6);
-
    background-color: var(--color-positive-4);
-
  }
-
  .diff-line.selected.type-deletion > * {
-
    color: var(--color-negative-6);
-
    background-color: var(--color-negative-4);
-
  }
-
  .diff-line.hunk-header.selected {
-
    background-color: var(--color-foreground-4);
-
  }
-
  .diff-line-number {
-
    text-align: right;
-
    user-select: none;
-
    line-height: 1.5rem;
-
    min-width: 3rem;
-
    cursor: pointer;
-
  }
-
  .diff-line-number.left {
-
    position: relative;
-
    padding: 0 0.5rem 0 0.75rem;
-
  }
-
  .selection-indicator {
-
    position: absolute;
-
    left: 0;
-
    top: 0;
-
    bottom: 0;
-
    width: 4px;
-
  }
-
  .diff-line.selected .selection-indicator {
-
    background: var(--color-primary);
-
  }
-
  .diff-line-number.right {
-
    padding: 0 0.75rem 0 0.5rem;
-
  }
-
  .diff-line-content {
-
    white-space: pre-wrap;
-
    overflow-wrap: anywhere;
-
    width: 100%;
-
    padding-right: 0.5rem;
-
  }
-
  .diff-line-type {
-
    text-align: center;
-
    padding-left: 0.75rem;
-
    padding-right: 0.75rem;
-
    user-select: none;
-
  }
-
  .diff-expand-header {
-
    padding-left: 0.5rem;
-
    color: var(--color-foreground-5);
-
  }
-
  .diff-line-number {
-
    color: var(--color-foreground-5);
-
  }
-
</style>
-

-
<div id={file.path} class="wrapper">
-
  <header class="header">
-
    <!-- svelte-ignore a11y-click-events-have-key-events -->
-
    <!-- svelte-ignore a11y-no-static-element-interactions -->
-
    <div class="expand-button" on:click={() => (collapsed = !collapsed)}>
-
      {#if collapsed}
-
        <Icon name="chevron-right" />
-
      {:else}
-
        <Icon name="chevron-down" />
-
      {/if}
-
    </div>
-
    <div class="actions">
-
      <p class="txt-regular">{file.path}</p>
-
      {#if headerBadgeCaption === "added"}
-
        <Badge variant="positive">added</Badge>
-
      {:else if headerBadgeCaption === "deleted"}
-
        <Badge variant="negative">deleted</Badge>
-
      {/if}
-
    </div>
-
    <div class="browse" title="View file">
-
      <Link
-
        route={{
-
          resource: "project.source",
-
          project: projectId,
-
          node: baseUrl,
-
          path: file.path,
-
          revision,
-
        }}>
-
        <Icon name="browse" />
-
      </Link>
-
    </div>
-
  </header>
-
  {#if !collapsed}
-
    <main>
-
      {#if file.diff.type === "plain"}
-
        {#if file.diff.hunks.length > 0}
-
          <table class="diff" data-file-diff-select>
-
            {#each file.diff.hunks as hunk, hunkIdx}
-
              <tr
-
                class="diff-line hunk-header"
-
                class:selected={hunkHeaderSelected(selection, hunkIdx)}>
-
                <td colspan={2} style:position="relative">
-
                  <div class="selection-indicator" />
-
                </td>
-
                <td colspan={6} class="diff-expand-header">
-
                  {hunk.header}
-
                </td>
-
              </tr>
-
              {#each hunk.lines as line, lineIdx}
-
                <tr
-
                  class={`diff-line type-${line.type}`}
-
                  class:selected={isLineSelected(selection, hunkIdx, lineIdx)}>
-
                  <td
-
                    id={[file.path, "H" + hunkIdx, "L" + lineIdx].join("-")}
-
                    class="diff-line-number left"
-
                    on:click={e => selectLine(hunkIdx, lineIdx, e)}>
-
                    <div class="selection-indicator" />
-
                    {lineNumberL(line)}
-
                  </td>
-
                  <td
-
                    class="diff-line-number right"
-
                    on:click={e => selectLine(hunkIdx, lineIdx, e)}>
-
                    {lineNumberR(line)}
-
                  </td>
-
                  <td class="diff-line-type" data-line-type={line.type}>
-
                    {lineSign(line)}
-
                  </td>
-
                  <td class="diff-line-content">{line.line}</td>
-
                </tr>
-
              {/each}
-
            {/each}
-
          </table>
-
        {:else}
-
          <div class="placeholder">Empty file</div>
-
        {/if}
-
      {:else}
-
        <div class="placeholder">Binary file</div>
-
      {/if}
-
    </main>
-
  {/if}
-
</div>
deleted src/views/projects/SourceBrowser/FileLocationChange.svelte
@@ -1,66 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, DiffCopiedMovedChangeset } from "@httpd-client";
-

-
  import Badge from "@app/components/Badge.svelte";
-
  import Icon from "@app/components/Icon.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  export let file: DiffCopiedMovedChangeset;
-
  export let revision: string;
-
  export let mode: "moved" | "copied";
-
  export let baseUrl: BaseUrl;
-
  export let projectId: string;
-
</script>
-

-
<style>
-
  .wrapper {
-
    border: 1px solid var(--color-foreground-4);
-
    border-radius: var(--border-radius-small);
-
    margin-bottom: 2rem;
-
    line-height: 1.5rem;
-
  }
-
  .header {
-
    align-items: center;
-
    background: none;
-
    border-radius: 0;
-
    display: flex;
-
    flex-direction: row;
-
    height: 3rem;
-
    padding: 1rem;
-
  }
-
  .actions {
-
    display: flex;
-
    flex-direction: row;
-
    align-items: center;
-
    gap: 1rem;
-
  }
-
  .browse {
-
    margin-left: auto;
-
    cursor: pointer;
-
  }
-
</style>
-

-
<div id={file.newPath} class="wrapper">
-
  <header class="header">
-
    <div class="actions">
-
      <p class="txt-regular">{file.oldPath} → {file.newPath}</p>
-
      {#if mode === "moved"}
-
        <Badge variant="foreground">moved</Badge>
-
      {:else if mode === "copied"}
-
        <Badge variant="foreground">copied</Badge>
-
      {/if}
-
    </div>
-
    <div class="browse" title="View file">
-
      <Link
-
        route={{
-
          resource: "project.source",
-
          project: projectId,
-
          node: baseUrl,
-
          path: file.newPath,
-
          revision,
-
        }}>
-
        <Icon name="browse" />
-
      </Link>
-
    </div>
-
  </header>
-
</div>
deleted src/views/projects/SourceBrowsingHeader.svelte
@@ -1,81 +0,0 @@
-
<script lang="ts">
-
  import type { BaseUrl, Project, Remote, Tree } from "@httpd-client";
-
  import { type Route } from "@app/lib/router";
-

-
  import { pluralize } from "@app/lib/pluralize";
-

-
  import BranchSelector from "@app/views/projects/BranchSelector.svelte";
-
  import PeerSelector from "@app/views/projects/PeerSelector.svelte";
-
  import Link from "@app/components/Link.svelte";
-
  import SquareButton from "@app/components/SquareButton.svelte";
-

-
  export let node: BaseUrl;
-
  export let branches: Array<{ name: string; route: Route }>;
-
  export let peers: Array<{ remote: Remote; selected: boolean; route: Route }>;
-
  export let historyLinkActive: boolean;
-
  export let revision: string | undefined;
-
  export let tree: Tree;
-
  export let project: Project;
-

-
  let selectedBranch: string | undefined;
-

-
  // Revision may be a commit ID, a branch name or `undefined` which means the
-
  // default branch. We assign `selectedBranch` accordingly.
-
  $: if (revision === commitId) {
-
    selectedBranch = undefined;
-
  } else {
-
    selectedBranch = revision || project.defaultBranch;
-
  }
-

-
  $: commitId = tree.lastCommit.id;
-
  $: peer = peers.find(p => p.selected)?.remote.id;
-
</script>
-

-
<style>
-
  .header {
-
    font-size: var(--font-size-tiny);
-
    padding: 0 2rem 0 8rem;
-
    display: flex;
-
    align-items: center;
-
    justify-content: left;
-
    flex-wrap: wrap;
-
    gap: 0.5rem;
-
    margin-bottom: 2rem;
-
  }
-

-
  @media (max-width: 960px) {
-
    .header {
-
      padding-left: 2rem;
-
    }
-
    .header {
-
      margin-bottom: 1.5rem;
-
    }
-
  }
-
</style>
-

-
<div class="header">
-
  {#if peers.length > 0}
-
    <PeerSelector {peers} />
-
  {/if}
-

-
  <BranchSelector {branches} selectedCommitId={commitId} {selectedBranch} />
-

-
  <Link
-
    route={{
-
      resource: "project.history",
-
      project: project.id,
-
      node: node,
-
      peer,
-
      revision,
-
    }}>
-
    <SquareButton active={historyLinkActive}>
-
      <span class="txt-bold">{tree.stats.commits}</span>
-
      {pluralize("commit", tree.stats.commits)}
-
    </SquareButton>
-
  </Link>
-

-
  <SquareButton hoverable={false}>
-
    <span class="txt-bold">{tree.stats.contributors}</span>
-
    {pluralize("contributor", tree.stats.contributors)}
-
  </SquareButton>
-
</div>
deleted src/views/projects/Tree.svelte
@@ -1,50 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { BaseUrl, Tree } from "@httpd-client";
-

-
  import { createEventDispatcher } from "svelte";
-

-
  import File from "./Tree/File.svelte";
-
  import Folder from "./Tree/Folder.svelte";
-
  import Link from "@app/components/Link.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
-
  export let path: string;
-
  export let peer: string | undefined;
-
  export let projectId: string;
-
  export let revision: string | undefined;
-
  export let tree: Tree;
-

-
  const dispatch = createEventDispatcher<{ select: string }>();
-
  const onSelect = ({ detail: path }: { detail: string }): void => {
-
    dispatch("select", path);
-
  };
-
</script>
-

-
{#each tree.entries as entry (entry.path)}
-
  {#if entry.kind === "tree"}
-
    <Folder
-
      currentPath={path}
-
      name={entry.name}
-
      on:select={onSelect}
-
      prefix={`${entry.path}/`}
-
      {baseUrl}
-
      {fetchTree}
-
      {peer}
-
      {projectId}
-
      {revision} />
-
  {:else}
-
    <Link
-
      route={{
-
        resource: "project.source",
-
        project: projectId,
-
        node: baseUrl,
-
        path: entry.path,
-
        peer,
-
        revision,
-
      }}
-
      on:afterNavigate={() => onSelect({ detail: entry.path })}>
-
      <File active={entry.path === path} name={entry.name} />
-
    </Link>
-
  {/if}
-
{/each}
deleted src/views/projects/Tree/File.svelte
@@ -1,40 +0,0 @@
-
<script lang="ts">
-
  export let active: boolean;
-
  export let name: string;
-
</script>
-

-
<style>
-
  .file {
-
    color: var(--color-foreground-6);
-
    border-radius: var(--border-radius-small);
-
    cursor: pointer;
-
    display: flex;
-
    justify-content: space-between;
-
    line-height: 1.5em;
-
    margin: 0.125rem 0;
-
    padding: 0.25rem;
-
    width: 100%;
-
  }
-

-
  .file:hover {
-
    background-color: var(--color-foreground-1);
-
  }
-

-
  .file.active {
-
    color: var(--color-foreground) !important;
-
    background-color: var(--color-foreground-1);
-
  }
-

-
  .name {
-
    margin-left: 0.25rem;
-
    user-select: none;
-
    white-space: nowrap;
-
    text-overflow: ellipsis !important;
-
    overflow: hidden;
-
    max-width: 24ch;
-
  }
-
</style>
-

-
<div class="file" class:active>
-
  <span class="name">{name}</span>
-
</div>
deleted src/views/projects/Tree/Folder.svelte
@@ -1,116 +0,0 @@
-
<script lang="ts" strictEvents>
-
  import type { BaseUrl, Tree } from "@httpd-client";
-

-
  import { createEventDispatcher } from "svelte";
-

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

-
  import File from "./File.svelte";
-

-
  export let baseUrl: BaseUrl;
-
  export let currentPath: string;
-
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
-
  export let name: string;
-
  export let peer: string | undefined;
-
  export let prefix: string;
-
  export let projectId: string;
-
  export let revision: string | undefined;
-

-
  $: expanded = currentPath.indexOf(prefix) === 0;
-
  $: tree = expanded
-
    ? fetchTree(prefix).then(tree => {
-
        return tree;
-
      })
-
    : Promise.resolve(undefined);
-

-
  const dispatch = createEventDispatcher<{ select: string }>();
-
  const onSelectFile = ({ detail: path }: { detail: string }) =>
-
    dispatch("select", path);
-

-
  const onClick = () => {
-
    expanded = !expanded;
-

-
    tree = fetchTree(prefix).then(tree => {
-
      if (expanded) return tree;
-
    });
-
  };
-
</script>
-

-
<style>
-
  .folder {
-
    display: flex;
-
    cursor: pointer;
-
    padding: 0.25rem;
-
    margin: 0.125rem 0;
-
    color: var(--color-foreground-6);
-
    user-select: none;
-
    line-height: 1.5rem;
-
    white-space: nowrap;
-
  }
-
  .folder:hover {
-
    background-color: var(--color-foreground-1);
-
    border-radius: var(--border-radius-small);
-
  }
-

-
  .folder-name {
-
    margin-left: 0.25rem;
-
    color: var(--color-secondary-6);
-
  }
-

-
  .container {
-
    padding-left: 0.5rem;
-
    margin: 0;
-
  }
-

-
  .loading {
-
    display: inline-block;
-
    padding: 0.5rem 0;
-
  }
-
</style>
-

-
<!-- svelte-ignore a11y-click-events-have-key-events -->
-
<!-- svelte-ignore a11y-no-static-element-interactions -->
-
<div class="folder" on:click={onClick}>
-
  <span class="folder-name">{name}/</span>
-
</div>
-

-
<div class="container">
-
  {#if expanded}
-
    {#await tree}
-
      <span class="loading"><Loading noDelay small margins /></span>
-
    {:then tree}
-
      {#if tree}
-
        {#each tree.entries as entry (entry.path)}
-
          {#if entry.kind === "tree"}
-
            <!-- svelte:self doesn't check types, make sure to pass in all
-
            required props! -->
-
            <svelte:self
-
              name={entry.name}
-
              on:select={onSelectFile}
-
              prefix={`${entry.path}/`}
-
              {baseUrl}
-
              {currentPath}
-
              {fetchTree}
-
              {peer}
-
              {projectId}
-
              {revision} />
-
          {:else}
-
            <Link
-
              route={{
-
                resource: "project.source",
-
                project: projectId,
-
                node: baseUrl,
-
                path: entry.path,
-
                peer,
-
                revision,
-
              }}
-
              on:afterNavigate={() => onSelectFile({ detail: entry.path })}>
-
              <File active={entry.path === currentPath} name={entry.name} />
-
            </Link>
-
          {/if}
-
        {/each}
-
      {/if}
-
    {/await}
-
  {/if}
-
</div>