Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement repo home page
Open rudolfs opened 1 year ago
32 files changed +705 -60 865497ce bb38192b
modified crates/radicle-tauri/build.rs
@@ -2,7 +2,7 @@ use std::process::Command;

fn main() {
    let output = Command::new("git")
-
        .args(&["rev-parse", "--short", "HEAD"])
+
        .args(["rev-parse", "--short", "HEAD"])
        .output()
        .expect("failed to execute git");
    let git_head = String::from_utf8(output.stdout).unwrap();
modified crates/radicle-tauri/src/commands/repo.rs
@@ -36,6 +36,15 @@ pub fn repo_by_id(
}

#[tauri::command]
+
pub fn repo_readme(
+
    ctx: tauri::State<AppState>,
+
    rid: RepoId,
+
    sha: Option<git::Oid>,
+
) -> Result<Option<types::repo::Readme>, Error> {
+
    ctx.repo_readme(rid, sha)
+
}
+

+
#[tauri::command]
pub async fn diff_stats(
    ctx: tauri::State<'_, AppState>,
    rid: RepoId,
modified crates/radicle-tauri/src/lib.rs
@@ -56,6 +56,7 @@ pub fn run() {
            repo::list_repos,
            repo::repo_by_id,
            repo::repo_count,
+
            repo::repo_readme,
            startup::startup,
            startup::version,
            thread::create_issue_comment,
added crates/radicle-types/bindings/repo/Readme.ts
@@ -0,0 +1,3 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type Readme = { path: string; content: string; binary: boolean };
modified crates/radicle-types/bindings/repo/Visibility.ts
@@ -1,6 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Author } from "../cob/Author";

export type Visibility = { "type": "public" } | {
  "type": "private";
-
  allow?: Array<string>;
+
  allow?: Array<Author>;
};
modified crates/radicle-types/src/repo.rs
@@ -37,6 +37,16 @@ pub struct RepoInfo {
    pub last_commit_timestamp: i64,
}

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "repo/")]
+
pub struct Readme {
+
    pub path: String,
+
    pub content: String,
+
    pub binary: bool,
+
}
+

#[derive(Default, Serialize, TS)]
#[serde(rename_all = "camelCase", tag = "type")]
#[ts(export)]
@@ -47,25 +57,22 @@ pub enum Visibility {
    Public,
    /// Delegates plus the allowed DIDs.
    Private {
-
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
-
        #[ts(as = "Option<BTreeSet<String>>", optional)]
-
        allow: BTreeSet<identity::Did>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<Vec<Author>>", optional)]
+
        allow: Vec<Author>,
    },
}

-
impl From<identity::Visibility> for Visibility {
-
    fn from(value: identity::Visibility) -> Self {
-
        match value {
-
            identity::Visibility::Private { allow } => Self::Private { allow },
-
            identity::Visibility::Public => Self::Public,
-
        }
-
    }
-
}
-

impl From<Visibility> for identity::Visibility {
    fn from(value: Visibility) -> Self {
        match value {
-
            Visibility::Private { allow } => Self::Private { allow },
+
            Visibility::Private { allow } => {
+
                let did_set = allow
+
                    .iter()
+
                    .map(|author| *author.did())
+
                    .collect::<BTreeSet<identity::Did>>();
+
                Self::Private { allow: did_set }
+
            }
            Visibility::Public => Self::Public,
        }
    }
modified crates/radicle-types/src/traits/repo.rs
@@ -1,3 +1,4 @@
+
use base64::Engine;
use radicle_surf as surf;
use serde::{Deserialize, Serialize};

@@ -108,6 +109,49 @@ pub trait Repo: Profile {
        })
    }

+
    fn repo_readme(
+
        &self,
+
        rid: identity::RepoId,
+
        sha: Option<git::Oid>,
+
    ) -> Result<Option<repo::Readme>, Error> {
+
        let profile = self.profile();
+
        let repo = radicle_surf::Repository::open(storage::git::paths::repository(
+
            &profile.storage,
+
            &rid,
+
        ))?;
+

+
        let paths = [
+
            "README",
+
            "README.md",
+
            "README.markdown",
+
            "README.txt",
+
            "README.rst",
+
            "README.org",
+
            "Readme.md",
+
        ];
+

+
        let oid = sha.map_or_else(|| repo.head(), Ok)?;
+
        for path in paths
+
            .iter()
+
            .map(ToString::to_string)
+
            .chain(paths.iter().map(|p| p.to_lowercase()))
+
        {
+
            if let Ok(blob) = repo.blob(oid, &path) {
+
                let content = match std::str::from_utf8(blob.content()) {
+
                    Ok(s) => s.to_owned(),
+
                    Err(_) => base64::engine::general_purpose::STANDARD.encode(blob.content()),
+
                };
+

+
                return Ok(Some(repo::Readme {
+
                    path,
+
                    content,
+
                    binary: blob.is_binary(),
+
                }));
+
            }
+
        }
+
        Ok(None)
+
    }
+

    fn repo_by_id(&self, rid: identity::RepoId) -> Result<repo::RepoInfo, Error> {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
@@ -176,7 +220,15 @@ pub trait Repo: Profile {
            payloads: repo::SupportedPayloads { project },
            delegates,
            threshold: doc.threshold(),
-
            visibility: doc.visibility().clone().into(),
+
            visibility: match doc.visibility().clone() {
+
                identity::Visibility::Public => repo::Visibility::Public,
+
                identity::Visibility::Private { allow } => repo::Visibility::Private {
+
                    allow: allow
+
                        .iter()
+
                        .map(|did| cobs::Author::new(did, &aliases))
+
                        .collect(),
+
                },
+
            },
            rid: repo.id,
            seeding,
            last_commit_timestamp: commit.time().seconds() * 1000,
modified public/index.css
@@ -257,3 +257,51 @@ body {
    0 calc(100% - 6px)
  );
}
+

+
/*
+
  Breakpoints
+
  ===========
+
    mobile             0px -  719.98px
+
    small desktop    720px - 1010.98px
+
    medium desktop  1011px - 1349.98px
+
    desktop         1350px -      ∞ px
+
*/
+

+
@media (max-width: 719.98px) {
+
  body {
+
    min-width: 0;
+
  }
+
  .global-hide-on-mobile-down {
+
    display: none !important;
+
  }
+
}
+

+
@media (max-width: 1010.98px) {
+
  .global-hide-on-small-desktop-down {
+
    display: none !important;
+
  }
+
}
+

+
@media (max-width: 1349.98px) {
+
  .global-hide-on-medium-desktop-down {
+
    display: none !important;
+
  }
+
}
+

+
@media (min-width: 720px) {
+
  .global-hide-on-small-desktop-up {
+
    display: none !important;
+
  }
+
}
+

+
@media (min-width: 1011px) {
+
  .global-hide-on-medium-desktop-up {
+
    display: none !important;
+
  }
+
}
+

+
@media (min-width: 1350px) {
+
  .global-hide-on-desktop-up {
+
    display: none !important;
+
  }
+
}
modified public/typography.css
@@ -136,6 +136,11 @@ p {
.txt-missing {
  color: var(--color-foreground-dim);
}
+
.txt-selectable {
+
  -webkit-touch-callout: initial;
+
  -webkit-user-select: text;
+
  user-select: text;
+
}
.txt-emoji {
  height: 1em;
  width: 1em;
modified scripts/check-js
@@ -2,6 +2,6 @@
set -e

npx tsc --noEmit
-
npx svelte-check --tsconfig tsconfig.json --fail-on-warnings --compiler-warnings missing-custom-element-compile-options:ignore
+
npx svelte-check --tsconfig tsconfig.json --fail-on-warnings --compiler-warnings options_missing_custom_element:ignore
npx eslint --cache --cache-location node_modules/.cache/eslint --max-warnings 0 .
npx prettier "**/*.@(ts|js|svelte|json|css|html|yml)" "!crates/**/*" --ignore-path .gitignore --check --cache
modified src/App.svelte
@@ -27,6 +27,7 @@
  import Issues from "@app/views/repo/Issues.svelte";
  import Patch from "@app/views/repo/Patch.svelte";
  import Patches from "@app/views/repo/Patches.svelte";
+
  import RepoHome from "@app/views/repo/RepoHome.svelte";
  import Repos from "@app/views/home/Repos.svelte";

  const activeRouteStore = router.activeRouteStore;
@@ -109,6 +110,8 @@
  <Repos {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "inbox"}
  <Inbox {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "repo.home"}
+
  <RepoHome {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.createIssue"}
  <CreateIssue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issue"}
modified src/components/Clipboard.svelte
@@ -1,3 +1,5 @@
+
<svelte:options customElement="radicle-clipboard" />
+

<script lang="ts">
  import debounce from "lodash/debounce";

modified src/components/File.svelte
@@ -8,8 +8,9 @@

  interface Props {
    children: Snippet;
-
    expanded: boolean;
-
    leftHeader: Snippet;
+
    expandable?: boolean;
+
    expanded?: boolean;
+
    leftHeader?: Snippet;
    rightHeader?: Snippet;
    sticky?: boolean;
  }
@@ -17,10 +18,11 @@
  /* eslint-disable prefer-const */
  let {
    children,
-
    expanded,
+
    expanded = true,
    leftHeader,
    rightHeader,
    sticky = true,
+
    expandable = true,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -36,6 +38,7 @@
    z-index: 2;
    font-size: var(--font-size-small);
    background-color: var(--color-background-default);
+
    position: relative;
  }
  .header::after {
    position: absolute;
@@ -84,19 +87,21 @@

<div class="header" class:sticky class:collapsed={!expanded} bind:this={header}>
  <div class="left">
-
    <NakedButton
-
      stylePadding="0 4px"
-
      variant="ghost"
-
      onclick={async () => {
-
        expanded = !expanded;
-
        if (!expanded && header) {
-
          await tick();
-
          header.scrollIntoView({ behavior: "smooth", block: "nearest" });
-
        }
-
      }}>
-
      <Icon name={expanded ? "chevron-down" : "chevron-right"} />
-
    </NakedButton>
-
    {@render leftHeader()}
+
    {#if expandable}
+
      <NakedButton
+
        stylePadding="0 4px"
+
        variant="ghost"
+
        onclick={async () => {
+
          expanded = !expanded;
+
          if (!expanded && header) {
+
            await tick();
+
            header.scrollIntoView({ behavior: "smooth", block: "nearest" });
+
          }
+
        }}>
+
        <Icon name={expanded ? "chevron-down" : "chevron-right"} />
+
      </NakedButton>
+
    {/if}
+
    {@render leftHeader?.()}
  </div>
  {#if rightHeader}
    <div
modified src/components/IssuesSecondColumn.svelte
@@ -58,8 +58,19 @@

<div class="container">
  <div>
-
    <div style:margin-bottom="1rem" style:padding-left="0.75rem">
-
      <RepoTeaser name={project.data.name} seeding={repo.seeding} />
+
    <div style:margin-bottom="0.75rem">
+
      <Link
+
        styleWidth="100%"
+
        underline={false}
+
        route={{ resource: "repo.home", rid: repo.rid }}>
+
        <div
+
          class="tab"
+
          style:color="var(--color-foreground-contrast)"
+
          style:padding-right="0.5rem"
+
          style:padding-left="0.75rem">
+
          <RepoTeaser name={project.data.name} seeding={repo.seeding} />
+
        </div>
+
      </Link>
    </div>

    <Border
modified src/components/Markdown.svelte
@@ -141,6 +141,15 @@
      const treeChanges: Promise<void>[] = [];

      for (const node of nodes) {
+
        const preElement = node.parentElement as HTMLElement;
+
        const copyButton = document.createElement("radicle-clipboard");
+
        copyButton.setAttribute("text", node.textContent || "");
+
        const preWrapper = document.createElement("div");
+
        preWrapper.classList.add("pre-wrapper");
+
        preElement.parentNode?.insertBefore(preWrapper, preElement);
+
        preWrapper.appendChild(preElement);
+
        preWrapper.appendChild(copyButton);
+

        const className = Array.from(node.classList).find(name =>
          name.startsWith(prefix),
        );
@@ -217,8 +226,8 @@
  .markdown :global(radicle-clipboard) {
    display: none;
    position: absolute;
-
    right: 0.5rem;
-
    top: 0.5rem;
+
    right: 0.75rem;
+
    top: 0.75rem;
  }

  .markdown :global(radicle-clipboard) {
modified src/components/PatchesSecondColumn.svelte
@@ -63,8 +63,19 @@

<div class="container">
  <div>
-
    <div style:margin-bottom="1rem" style:padding-left="0.75rem">
-
      <RepoTeaser name={project.data.name} seeding={repo.seeding} />
+
    <div style:margin-bottom="0.75rem">
+
      <Link
+
        styleWidth="100%"
+
        underline={false}
+
        route={{ resource: "repo.home", rid: repo.rid }}>
+
        <div
+
          class="tab"
+
          style:color="var(--color-foreground-contrast)"
+
          style:padding-right="0.5rem"
+
          style:padding-left="0.75rem">
+
          <RepoTeaser name={project.data.name} seeding={repo.seeding} />
+
        </div>
+
      </Link>
    </div>

    <div style:margin-bottom="0.5rem">
added src/components/RepoHomeSecondColumn.svelte
@@ -0,0 +1,105 @@
+
<script lang="ts">
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+

+
  import Border from "./Border.svelte";
+
  import Icon from "./Icon.svelte";
+
  import Link from "./Link.svelte";
+
  import RepoTeaser from "./RepoTeaser.svelte";
+
  import Settings from "./Settings.svelte";
+

+
  interface Props {
+
    repo: RepoInfo;
+
  }
+

+
  const { repo }: Props = $props();
+

+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
+
</script>
+

+
<style>
+
  .container {
+
    display: flex;
+
    flex-direction: column;
+
    height: 100%;
+
    justify-content: space-between;
+
  }
+
  .tab {
+
    align-items: center;
+
    background-color: var(--color-background-float);
+
    clip-path: var(--1px-corner-fill);
+
    display: flex;
+
    font-size: var(--font-size-small);
+
    justify-content: space-between;
+
    padding: 0.5rem 0.25rem 0.5rem 0.5rem;
+
    width: 100%;
+
  }
+
  .tab:not(.active) {
+
    color: var(--color-foreground-dim);
+
  }
+
  .tab:not(.active):hover {
+
    background-color: var(--color-fill-float-hover);
+
  }
+
  .active {
+
    background-color: var(--color-background-default);
+
    font-weight: var(--font-weight-semibold);
+
    padding: 0.25rem 0.25rem 0.25rem 0.5rem;
+
  }
+
</style>
+

+
<div class="container">
+
  <div>
+
    <div style:margin-bottom="0.75rem">
+
      <Border
+
        variant="ghost"
+
        styleBackgroundColor="var(--color-background-default)">
+
        <div class="tab active" style:color="var(--color-foreground-contrast)">
+
          <RepoTeaser name={project.data.name} seeding={repo.seeding} />
+
        </div>
+
      </Border>
+
    </div>
+

+
    <div style:margin-bottom="0.5rem">
+
      <Link
+
        styleWidth="100%"
+
        underline={false}
+
        route={{ resource: "repo.issues", rid: repo.rid, status: "open" }}>
+
        <div
+
          class="tab"
+
          style:color="var(--color-foreground-contrast)"
+
          style:padding-left="0.75rem">
+
          <div class="global-flex"><Icon name="issue" />Issues</div>
+
          <div class="global-counter">
+
            {project.meta.issues.open + project.meta.issues.closed}
+
          </div>
+
        </div>
+
      </Link>
+
    </div>
+

+
    <div style:margin-top="0.5rem">
+
      <Link
+
        styleWidth="100%"
+
        underline={false}
+
        route={{ resource: "repo.patches", rid: repo.rid, status: "open" }}>
+
        <div
+
          class="tab"
+
          style:color="var(--color-foreground-contrast)"
+
          style:padding-left="0.75rem">
+
          <div class="global-flex"><Icon name="patch" />Patches</div>
+
          <div class="global-counter">
+
            {project.meta.patches.draft +
+
              project.meta.patches.open +
+
              project.meta.patches.archived +
+
              project.meta.patches.merged}
+
          </div>
+
        </div>
+
      </Link>
+
    </div>
+
  </div>
+

+
  <Settings
+
    compact={false}
+
    popoverProps={{
+
      popoverPositionBottom: "3rem",
+
      popoverPositionLeft: "0",
+
    }} />
+
</div>
added src/components/RepoMetadata.svelte
@@ -0,0 +1,128 @@
+
<script lang="ts">
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+

+
  import sortBy from "lodash/sortBy.js";
+

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

+
  import Border from "@app/components/Border.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import VisibilityBadge from "@app/components/VisibilityBadge.svelte";
+
  import Icon from "./Icon.svelte";
+

+
  interface Props {
+
    horizontal?: boolean;
+
    repo: RepoInfo;
+
  }
+

+
  const { horizontal = false, repo }: Props = $props();
+

+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
+
</script>
+

+
<style>
+
  .list {
+
    flex-direction: column;
+
    align-items: flex-start;
+
  }
+
  .horizontal {
+
    flex-direction: row;
+
    flex-wrap: wrap;
+
  }
+
  .metadata-divider {
+
    width: 2px;
+
    background-color: var(--color-fill-ghost);
+
    height: calc(100% + 4px);
+
    top: 0;
+
    position: relative;
+
  }
+
  .metadata-divider-horizontal {
+
    height: 2px;
+
    background-color: var(--color-fill-ghost);
+
    width: calc(100% + 4px);
+
    top: 0;
+
    left: -2px;
+
    position: relative;
+
  }
+
  .metadata-section {
+
    padding: 0.5rem;
+
    font-size: var(--font-size-small);
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-start;
+
    flex: 1;
+
    white-space: nowrap;
+
  }
+
  .metadata-section-title {
+
    margin-bottom: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
</style>
+

+
<Border
+
  variant="ghost"
+
  styleGap="0"
+
  styleFlexDirection="column"
+
  styleAlignItems="flex-start">
+
  <div class="metadata-section" style:flex="1" style:width="100%">
+
    <div class="metadata-section-title">Visibility</div>
+
    <VisibilityBadge type={repo.visibility.type} />
+
  </div>
+

+
  {#if repo.visibility.type === "private"}
+
    <div class="metadata-divider-horizontal"></div>
+

+
    <div class="metadata-section" style:flex="1" style:width="100%">
+
      <div class="metadata-section-title">Allow</div>
+
      {#if repo.visibility.allow}
+
        <div class="global-flex list" class:horizontal>
+
          {#each repo.visibility.allow as node}
+
            <NodeId {...authorForNodeId(node)} />
+
          {/each}
+
        </div>
+
      {:else}
+
        <div class="global-flex txt-missing" style:gap="0.25rem">
+
          <Icon name="none" /> None
+
        </div>
+
      {/if}
+
    </div>
+
  {/if}
+

+
  <div class="metadata-divider-horizontal"></div>
+

+
  <div class="metadata-section" style:flex="1" style:width="100%">
+
    <div class="metadata-section-title">Delegates</div>
+
    <div class="global-flex list" class:horizontal>
+
      {#each sortBy(repo.delegates, d => {
+
        return d.alias?.toLowerCase();
+
      }) as delegate}
+
        <NodeId {...authorForNodeId(delegate)} />
+
      {/each}
+
    </div>
+
  </div>
+

+
  <div class="metadata-divider-horizontal"></div>
+

+
  <div
+
    class="global-flex"
+
    style:gap="0"
+
    style:flex-direction="row"
+
    style:width="100%">
+
    <div class="metadata-section">
+
      <div class="metadata-section-title">Default branch</div>
+
      <span>
+
        <span class="txt-selectable">{project.data.defaultBranch}</span>
+
        ->
+
        <Id id={project.meta.head} variant="commit" />
+
      </span>
+
    </div>
+

+
    <div class="metadata-divider"></div>
+

+
    <div class="metadata-section">
+
      <div class="metadata-section-title">Threshold</div>
+
      {repo.threshold}
+
    </div>
+
  </div>
+
</Border>
modified src/components/RepoTeaser.svelte
@@ -12,7 +12,7 @@
<style>
  .teaser {
    align-items: center;
-
    min-height: 2.5rem;
+
    width: 100%;
  }

  .seeding {
modified src/components/Sidebar.svelte
@@ -39,7 +39,16 @@

<div class="global-flex" style:flex-direction="column" style:gap="0.5rem">
  <div class="global-flex" style:height="2.5rem">
-
    <Icon name="repo" />
+
    <button
+
      class="sidebar-button"
+
      onclick={() => {
+
        void router.push({
+
          resource: "repo.home",
+
          rid,
+
        });
+
      }}>
+
      <Icon name="repo" />
+
    </button>
  </div>
  {#if activeTab === "issues"}
    <Border
added src/components/VisibilityBadge.svelte
@@ -0,0 +1,37 @@
+
<script lang="ts">
+
  import type { Visibility } from "@bindings/repo/Visibility";
+

+
  import capitalize from "lodash/capitalize.js";
+

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

+
  interface Props {
+
    type: Visibility["type"];
+
  }
+

+
  const { type }: Props = $props();
+
</script>
+

+
<style>
+
  .badge {
+
    gap: 0.375rem;
+
    padding-right: 0.625rem;
+
  }
+

+
  .public {
+
    background-color: var(--color-fill-diff-green-light);
+
    color: var(--color-foreground-success);
+
  }
+
  .private {
+
    background-color: var(--color-fill-private);
+
    color: var(--color-foreground-yellow);
+
  }
+
</style>
+

+
<span
+
  class="global-counter badge"
+
  class:public={type === "public"}
+
  class:private={type === "private"}>
+
  <Icon name={type === "public" ? "seedling" : "lock"} />
+
  {capitalize(type)}
+
</span>
modified src/lib/router.ts
@@ -125,6 +125,7 @@ export function routeToPath(route: Route): string {
  } else if (route.resource === "inbox") {
    return "/inbox";
  } else if (
+
    route.resource === "repo.home" ||
    route.resource === "repo.createIssue" ||
    route.resource === "repo.issue" ||
    route.resource === "repo.issues" ||
modified src/lib/router/definitions.ts
@@ -15,6 +15,7 @@ import {
  loadIssues,
  loadPatch,
  loadPatches,
+
  loadRepoHome,
} from "@app/views/repo/router";

export type HomeReposTab = "delegate" | "private" | "contributor";
@@ -153,6 +154,8 @@ export async function loadRoute(
        config,
      },
    };
+
  } else if (route.resource === "repo.home") {
+
    return loadRepoHome(route);
  } else if (route.resource === "repo.issue") {
    return loadIssue(route);
  } else if (route.resource === "repo.createIssue") {
modified src/views/home/Repos.svelte
@@ -128,9 +128,8 @@
            onSubmit={async () => {
              if (searchResults.length === 1) {
                await router.push({
-
                  resource: "repo.issues",
+
                  resource: "repo.home",
                  rid: searchResults[0].obj.repo.rid,
-
                  status: "open",
                });
              }
            }}
@@ -161,9 +160,8 @@
              selfDid={didFromPublicKey(config.publicKey)}
              onclick={() => {
                void router.push({
-
                  resource: "repo.issues",
+
                  resource: "repo.home",
                  rid: result.obj.repo.rid,
-
                  status: "open",
                });
              }} />
          {/each}
modified src/views/repo/Issues.svelte
@@ -17,7 +17,6 @@
  import Icon from "@app/components/Icon.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import IssuesSecondColumn from "@app/components/IssuesSecondColumn.svelte";
-
  import Sidebar from "@app/components/Sidebar.svelte";
  import TextInput from "@app/components/TextInput.svelte";

  interface Props {
@@ -92,10 +91,6 @@
    <CopyableId id={repo.rid} />
  {/snippet}

-
  {#snippet sidebar()}
-
    <Sidebar activeTab="issues" rid={repo.rid} />
-
  {/snippet}
-

  {#snippet secondColumn()}
    <IssuesSecondColumn {status} {repo} />
  {/snippet}
modified src/views/repo/Patch.svelte
@@ -565,9 +565,9 @@
          <Popover
            bind:expanded={checkoutPopoverExpanded}
            popoverPositionRight="0"
-
            popoverPositionTop="2.5rem">
+
            popoverPositionTop="3rem">
            {#snippet toggle(onclick)}
-
              <Button styleHeight="2rem" variant="secondary" {onclick}>
+
              <Button styleHeight="2.5rem" variant="secondary" {onclick}>
                <Icon name="checkout" />Checkout<Icon name="chevron-down" />
              </Button>
            {/snippet}
modified src/views/repo/Patches.svelte
@@ -18,7 +18,6 @@
  import Layout from "./Layout.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import PatchesSecondColumn from "@app/components/PatchesSecondColumn.svelte";
-
  import Sidebar from "@app/components/Sidebar.svelte";
  import TextInput from "@app/components/TextInput.svelte";

  interface Props {
@@ -125,10 +124,6 @@
    <CopyableId id={repo.rid} />
  {/snippet}

-
  {#snippet sidebar()}
-
    <Sidebar activeTab="patches" rid={repo.rid} />
-
  {/snippet}
-

  {#snippet secondColumn()}
    <PatchesSecondColumn {project} {status} {repo} />
  {/snippet}
added src/views/repo/RepoHome.svelte
@@ -0,0 +1,154 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/config/Config";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
  import type { Readme } from "@bindings/repo/Readme";
+

+
  import Border from "@app/components/Border.svelte";
+
  import Button from "@app/components/Button.svelte";
+
  import Command from "@app/components/Command.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import File from "@app/components/File.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import Path from "@app/components/Path.svelte";
+
  import Popover from "@app/components/Popover.svelte";
+
  import RepoHomeSecondColumn from "@app/components/RepoHomeSecondColumn.svelte";
+
  import RepoMetadata from "@app/components/RepoMetadata.svelte";
+

+
  interface Props {
+
    config: Config;
+
    readme: Readme | null;
+
    repo: RepoInfo;
+
  }
+

+
  const { config, readme, repo }: Props = $props();
+

+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
+

+
  let checkoutPopoverExpanded: boolean = $state(false);
+
</script>
+

+
<style>
+
  .content {
+
    padding: 1rem 1rem 1rem 0;
+
  }
+
  .container {
+
    display: grid;
+
    grid-template-columns: 1fr min-content;
+
    grid-template-areas: "main-content right-sidebar";
+
    margin-top: 2rem;
+
  }
+
</style>
+

+
<Layout
+
  publicKey={config.publicKey}
+
  hideSidebar
+
  styleSecondColumnOverflow="visible">
+
  {#snippet headerCenter()}
+
    <CopyableId id={repo.rid} />
+
  {/snippet}
+

+
  {#snippet secondColumn()}
+
    <RepoHomeSecondColumn {repo} />
+
  {/snippet}
+

+
  <div class="content">
+
    <div class="global-flex">
+
      <div
+
        class="txt-medium txt-selectable"
+
        style:font-weight="var(--font-weight-medium)">
+
        {project.data.name}
+
      </div>
+
      <div class="global-flex txt-small" style:margin-left="auto">
+
        <Popover
+
          bind:expanded={checkoutPopoverExpanded}
+
          popoverPositionRight="0"
+
          popoverPositionTop="3rem">
+
          {#snippet toggle(onclick)}
+
            <Button styleHeight="2.5rem" variant="secondary" {onclick}>
+
              <Icon name="checkout" />Checkout<Icon name="chevron-down" />
+
            </Button>
+
          {/snippet}
+

+
          {#snippet popover()}
+
            <Border
+
              styleAlignItems="flex-start"
+
              styleBackgroundColor="var(--color-background-float)"
+
              styleFlexDirection="column"
+
              styleGap="0.5rem"
+
              stylePadding="1rem"
+
              styleWidth="max-content"
+
              variant="ghost">
+
              To checkout a working copy of this repo, run:
+
              <Command command={`rad checkout ${repo.rid}`} styleWidth="100%" />
+
            </Border>
+
          {/snippet}
+
        </Popover>
+
      </div>
+
    </div>
+

+
    {#if project.data.description !== ""}
+
      <Markdown rid={repo.rid} breaks content={project.data.description} />
+
    {/if}
+

+
    <div class="global-hide-on-desktop-up" style:margin-top="1rem">
+
      <RepoMetadata {repo} horizontal />
+
    </div>
+

+
    <div class="container">
+
      <div style:grid-area="main-content" style:min-width="0">
+
        {#if readme === null}
+
          <Border
+
            variant="ghost"
+
            stylePadding="1rem"
+
            styleAlignItems="center"
+
            styleMinHeight="10rem">
+
            <div
+
              class="global-flex txt-missing"
+
              style:width="100%"
+
              style:justify-content="center">
+
              <Icon name="none" />README not found
+
            </div>
+
          </Border>
+
        {:else}
+
          <File expandable={false} sticky={false}>
+
            {#snippet leftHeader()}
+
              <div style:margin-left="0.5rem">
+
                <Path fullPath={readme.path} />
+
              </div>
+
            {/snippet}
+

+
            <div style:padding="1rem">
+
              {#if readme.binary}
+
                <div
+
                  class="global-flex txt-missing"
+
                  style:width="100%"
+
                  style:justify-content="center">
+
                  <Icon name="binary" />Binary file
+
                </div>
+
              {:else if readme.content.trim() === ""}
+
                <div
+
                  class="global-flex txt-missing"
+
                  style:width="100%"
+
                  style:justify-content="center">
+
                  <Icon name="none" />Empty file
+
                </div>
+
              {:else}
+
                <Markdown rid={repo.rid} content={readme.content} />
+
              {/if}
+
            </div>
+
          </File>
+
        {/if}
+
      </div>
+

+
      <div
+
        class="global-hide-on-medium-desktop-down"
+
        style:grid-area="right-sidebar"
+
        style:margin-left="1rem"
+
        style:min-width="20rem">
+
        <RepoMetadata {repo} />
+
      </div>
+
    </div>
+
  </div>
+
</Layout>
modified src/views/repo/router.ts
@@ -5,6 +5,7 @@ import type { Issue } from "@bindings/cob/issue/Issue";
import type { Operation } from "@bindings/cob/Operation";
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
import type { Patch } from "@bindings/cob/patch/Patch";
+
import type { Readme } from "@bindings/repo/Readme";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Review } from "@bindings/cob/patch/Review";
import type { Revision } from "@bindings/cob/patch/Revision";
@@ -17,6 +18,11 @@ export type IssueStatus = "all" | Issue["state"]["status"];

export const DEFAULT_TAKE = 20;

+
export interface RepoHomeRoute {
+
  resource: "repo.home";
+
  rid: string;
+
}
+

export interface RepoIssueRoute {
  resource: "repo.issue";
  rid: string;
@@ -30,6 +36,15 @@ export interface RepoCreateIssueRoute {
  status: IssueStatus;
}

+
export interface LoadedRepoHomeRoute {
+
  resource: "repo.home";
+
  params: {
+
    repo: RepoInfo;
+
    config: Config;
+
    readme: Readme | null;
+
  };
+
}
+

export interface LoadedRepoIssueRoute {
  resource: "repo.issue";
  params: {
@@ -110,12 +125,14 @@ export interface LoadedRepoPatchesRoute {
}

export type RepoRoute =
+
  | RepoHomeRoute
  | RepoCreateIssueRoute
  | RepoIssueRoute
  | RepoIssuesRoute
  | RepoPatchRoute
  | RepoPatchesRoute;
export type LoadedRepoRoute =
+
  | LoadedRepoHomeRoute
  | LoadedRepoCreateIssueRoute
  | LoadedRepoIssueRoute
  | LoadedRepoIssuesRoute
@@ -191,6 +208,25 @@ export async function loadPatches(
  };
}

+
export async function loadRepoHome(
+
  route: RepoHomeRoute,
+
): Promise<LoadedRepoHomeRoute> {
+
  const [config, repo, readme] = await Promise.all([
+
    invoke<Config>("config"),
+
    invoke<RepoInfo>("repo_by_id", {
+
      rid: route.rid,
+
    }),
+
    invoke<Readme | null>("repo_readme", {
+
      rid: route.rid,
+
    }),
+
  ]);
+

+
  return {
+
    resource: "repo.home",
+
    params: { repo, config, readme },
+
  };
+
}
+

export async function loadCreateIssue(
  route: RepoCreateIssueRoute,
): Promise<LoadedRepoCreateIssueRoute> {
@@ -275,7 +311,10 @@ export function repoRouteToPath(route: RepoRoute): string {
  const pathSegments = ["/repos", route.rid];
  const searchParams = new URLSearchParams();

-
  if (route.resource === "repo.issue") {
+
  if (route.resource === "repo.home") {
+
    const url = [...pathSegments, "home"].join("/");
+
    return url;
+
  } else if (route.resource === "repo.issue") {
    let url = [...pathSegments, "issues", route.issue].join("/");
    searchParams.set("status", route.status);
    url += `?${searchParams}`;
@@ -322,7 +361,9 @@ export function repoUrlToRoute(
  const resource = segments.shift();

  if (rid) {
-
    if (resource === "issues") {
+
    if (resource === "home") {
+
      return { resource: "repo.home", rid };
+
    } else if (resource === "issues") {
      const idOrAction = segments.shift();
      if (idOrAction) {
        const status = (searchParams.get("status") ?? "all") as IssueStatus;
modified tests/e2e/repo/issue.spec.ts
@@ -10,6 +10,7 @@ test("navigate single issue", async ({ page }) => {
test("correct order of threads", async ({ page }) => {
  await page.goto("/repos");
  await page.getByRole("button", { name: "cobs" }).click();
+
  await page.getByRole("link", { name: "icon-issue Issues" }).click();
  await page.getByText("This title has **markdown**").click();
  const body = page.locator(".issue-body");
  await expect(body.getByText("This is a description")).toBeVisible();
@@ -30,6 +31,7 @@ test("correct order of threads", async ({ page }) => {
test("creation of top level comments", async ({ page }) => {
  await page.goto("/repos");
  await page.getByRole("button", { name: "cobs" }).click();
+
  await page.getByRole("link", { name: "icon-issue Issues" }).click();
  await page.getByRole("button", { name: "New" }).click();
  await page
    .getByPlaceholder("Title")
modified tests/e2e/repos.spec.ts
@@ -3,6 +3,7 @@ import { expect, test } from "@tests/support/fixtures.js";
test("navigate to repo issues", async ({ page }) => {
  await page.goto("/repos");
  await page.getByRole("button", { name: "cobs" }).click();
+
  await page.getByRole("link", { name: "icon-issue Issues" }).click();
  await page.getByText("This title has **markdown**").click();
  await expect(
    page.getByText("This title has **markdown**").nth(1),
modified vite.config.ts
@@ -9,7 +9,16 @@ export default defineConfig({
    include: ["tests/unit/**/*.test.ts"],
    reporters: "verbose",
  },
-
  plugins: [svelte()],
+
  plugins: [
+
    svelte({
+
      // Reference: https://github.com/sveltejs/vite-plugin-svelte/issues/270#issuecomment-1033190138
+
      dynamicCompileOptions({ filename }) {
+
        if (path.basename(filename) === "Clipboard.svelte") {
+
          return { customElement: true };
+
        }
+
      },
+
    }),
+
  ],
  build: {
    outDir: "build",
  },