Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add `list_repo` command and repo listing
Open did:key:z6MkkfM3...sVz5 opened 1 year ago
24 files changed +359 -173 3edfcf91 b6711a56
modified package-lock.json
@@ -32,8 +32,7 @@
        "tslib": "^2.7.0",
        "typescript": "^5.2.2",
        "typescript-eslint": "^8.4.0",
-
        "vite": "^5.4.2",
-
        "zod": "^3.23.8"
+
        "vite": "^5.4.2"
      },
      "engines": {
        "node": "20.9.0"
@@ -3220,15 +3219,6 @@
      "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
      "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
      "dev": true
-
    },
-
    "node_modules/zod": {
-
      "version": "3.23.8",
-
      "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
-
      "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
-
      "dev": true,
-
      "funding": {
-
        "url": "https://github.com/sponsors/colinhacks"
-
      }
    }
  }
}
modified package.json
@@ -40,7 +40,6 @@
    "tslib": "^2.7.0",
    "typescript": "^5.2.2",
    "typescript-eslint": "^8.4.0",
-
    "vite": "^5.4.2",
-
    "zod": "^3.23.8"
+
    "vite": "^5.4.2"
  }
}
modified src-tauri/Cargo.toml
@@ -28,7 +28,7 @@ tauri = { version = "2.0.0-rc.0", features = ["isolation"] }
tauri-plugin-shell = { version = "2.0.0-rc.0" }
tauri-plugin-window-state = "2.0.0-rc.1"
thiserror = { version = "1.0.63" }
-
ts-rs = { version = "9.0.1", features = ["serde-json-impl"] }
+
ts-rs = { version = "9.0.1", features = ["serde-json-impl", "no-serde-warnings"] }

[features]
# by default Tauri runs in production mode
added src-tauri/bindings/Config.ts
@@ -0,0 +1,21 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
/**
+
 * Service configuration.
+
 */
+
export type Config = {
+
  /**
+
   * Node alias.
+
   */
+
  publicKey: string;
+
  /**
+
   * Node alias.
+
   */
+
  alias: string;
+
  /**
+
   * Default seeding policy.
+
   */
+
  seedingPolicy:
+
    | { default: "allow"; scope: "followed" | "all" }
+
    | { default: "block" };
+
};
added src-tauri/bindings/RepoInfo.ts
@@ -0,0 +1,14 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { SupportedPayloads } from "./SupportedPayloads";
+

+
/**
+
 * Repos info.
+
 */
+
export type RepoInfo = {
+
  payloads: SupportedPayloads;
+
  delegates: ({ id: string } | { id: string; alias?: string })[];
+
  threshold: number;
+
  visibility: { type: "public" } | { type: "private"; allow?: string[] };
+
  rid: string;
+
  seeding: number;
+
};
added src-tauri/bindings/SupportedPayloads.ts
@@ -0,0 +1,25 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type SupportedPayloads = {
+
  "xyz.radicle.project"?: {
+
    data: {
+
      defaultBranch: string;
+
      description: string;
+
      name: string;
+
    };
+
    meta: {
+
      head: string;
+
      issues: {
+
        open: number;
+
        closed: number;
+
      };
+
      patches: {
+
        open: number;
+
        draft: number;
+
        archived: number;
+
        merged: number;
+
      };
+
      lastCommit: number;
+
    };
+
  };
+
};
added src-tauri/bindings/serde_json/JsonValue.ts
@@ -0,0 +1,7 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+

+
export type JsonValue =
+
  | number
+
  | string
+
  | Array<JsonValue>
+
  | { [key: string]: JsonValue };
deleted src-tauri/src/auth.rs
@@ -1,27 +0,0 @@
-
use anyhow::anyhow;
-
use radicle::crypto::ssh;
-

-
use crate::{error::Error, AppState};
-

-
#[tauri::command]
-
pub fn authenticate(ctx: tauri::State<AppState>) -> Result<(), Error> {
-
    let profile = &ctx.profile;
-

-
    if !profile.keystore.is_encrypted()? {
-
        return Ok(());
-
    }
-
    match ssh::agent::Agent::connect() {
-
        Ok(mut agent) => {
-
            if agent.request_identities()?.contains(&profile.public_key) {
-
                Ok(())
-
            } else {
-
                Err(Error::WithHint {
-
                    err: anyhow!("Not able to find your keys in the ssh agent"),
-
                    hint: "Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.",
-
                })?
-
            }
-
        }
-
        Err(e) if e.is_not_running() => Err(Error::WithHint { err: anyhow!("SSH Agent is not running"), hint: "For now we require the user to have an ssh agent running, since we don't have passphrase inputs yet." })?, 
-
        Err(e) => Err(e)?,
-
    }
-
}
added src-tauri/src/commands.rs
@@ -0,0 +1,3 @@
+
pub mod auth;
+
pub mod profile;
+
pub mod repos;
added src-tauri/src/commands/auth.rs
@@ -0,0 +1,27 @@
+
use anyhow::anyhow;
+
use radicle::crypto::ssh;
+

+
use crate::{error::Error, AppState};
+

+
#[tauri::command]
+
pub fn authenticate(ctx: tauri::State<AppState>) -> Result<(), Error> {
+
    let profile = &ctx.profile;
+

+
    if !profile.keystore.is_encrypted()? {
+
        return Ok(());
+
    }
+
    match ssh::agent::Agent::connect() {
+
        Ok(mut agent) => {
+
            if agent.request_identities()?.contains(&profile.public_key) {
+
                Ok(())
+
            } else {
+
                Err(Error::WithHint {
+
                    err: anyhow!("Not able to find your keys in the ssh agent"),
+
                    hint: "Make sure to run <code>rad auth</code> in your terminal to add your keys to the ssh-agent.",
+
                })?
+
            }
+
        }
+
        Err(e) if e.is_not_running() => Err(Error::WithHint { err: anyhow!("SSH Agent is not running"), hint: "For now we require the user to have an ssh agent running, since we don't have passphrase inputs yet." })?, 
+
        Err(e) => Err(e)?,
+
    }
+
}
added src-tauri/src/commands/profile.rs
@@ -0,0 +1,15 @@
+
use crate::error::Error;
+
use crate::types::config::Config;
+
use crate::AppState;
+

+
/// Get active config.
+
#[tauri::command]
+
pub fn config(ctx: tauri::State<AppState>) -> Result<Config, Error> {
+
    let config = Config {
+
        public_key: ctx.profile.public_key,
+
        alias: ctx.profile.config.node.alias.clone(),
+
        seeding_policy: ctx.profile.config.node.seeding_policy,
+
    };
+

+
    Ok::<_, Error>(config)
+
}
added src-tauri/src/commands/repos.rs
@@ -0,0 +1,30 @@
+
use radicle::storage::ReadStorage;
+

+
use crate::error::Error;
+
use crate::types;
+
use crate::AppState;
+

+
/// List all repos.
+
#[tauri::command]
+
pub fn list_repos(ctx: tauri::State<AppState>) -> Result<Vec<types::repo::RepoInfo>, Error> {
+
    let storage = &ctx.profile.storage;
+
    let policies = ctx.profile.policies()?;
+

+
    let mut repos = storage.repositories()?.into_iter().collect::<Vec<_>>();
+
    repos.sort_by_key(|p| p.rid);
+

+
    let infos = repos
+
        .into_iter()
+
        .filter_map(|info| {
+
            if !policies.is_seeding(&info.rid).unwrap_or_default() {
+
                return None;
+
            }
+
            let (repo, doc) = ctx.repo(info.rid).ok()?;
+
            let repo_info = ctx.repo_info(&repo, doc).ok()?;
+

+
            Some(repo_info)
+
        })
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(infos)
+
}
added src-tauri/src/json.rs
@@ -0,0 +1,19 @@
+
use serde_json::{json, Value};
+

+
use radicle::identity;
+
use radicle::node::AliasStore;
+

+
pub(crate) struct Author<'a>(&'a identity::Did);
+

+
impl<'a> Author<'a> {
+
    pub fn new(did: &'a identity::Did) -> Self {
+
        Self(did)
+
    }
+

+
    pub fn as_json(&self, aliases: &impl AliasStore) -> Value {
+
        aliases.alias(self.0).map_or(
+
            json!({ "id": self.0 }),
+
            |alias| json!({ "id": self.0, "alias": alias, }),
+
        )
+
    }
+
}
modified src-tauri/src/lib.rs
@@ -1,13 +1,82 @@
-
mod auth;
+
mod commands;
mod error;
+
mod json;
+
mod types;

-
use auth::authenticate;
+
use serde_json::json;
use tauri::Manager;

+
use radicle::identity::doc::PayloadId;
+
use radicle::identity::DocAt;
+
use radicle::identity::RepoId;
+
use radicle::issue::cache::Issues;
+
use radicle::node::routing::Store;
+
use radicle::patch::cache::Patches;
+
use radicle::storage::git::Repository;
+
use radicle::storage::{ReadRepository, ReadStorage};
+

+
use commands::{auth, profile, repos};
+
use types::repo::SupportedPayloads;
+

struct AppState {
    profile: radicle::Profile,
}

+
impl AppState {
+
    pub fn repo_info<R: ReadRepository + radicle::cob::Store>(
+
        &self,
+
        repo: &R,
+
        doc: DocAt,
+
    ) -> Result<types::repo::RepoInfo, error::Error> {
+
        let DocAt { doc, .. } = doc;
+
        let rid = repo.id();
+

+
        let aliases = self.profile.aliases();
+
        let delegates = doc
+
            .delegates
+
            .into_iter()
+
            .map(|did| json::Author::new(&did).as_json(&aliases))
+
            .collect::<Vec<_>>();
+
        let db = &self.profile.database()?;
+
        let seeding = db.count(&rid).unwrap_or_default();
+

+
        let project = doc.payload.get(&PayloadId::project()).and_then(|payload| {
+
            let (_, head) = repo.head().ok()?;
+
            let commit = repo.commit(head).ok()?;
+
            let patches = self.profile.patches(repo).ok()?;
+
            let patches = patches.counts().ok()?;
+
            let issues = self.profile.issues(repo).ok()?;
+
            let issues = issues.counts().ok()?;
+

+
            Some(json!({
+
                "data": payload,
+
                "meta": {
+
                    "issues": issues,
+
                    "patches": patches,
+
                    "head": head,
+
                    "lastCommit": commit.time().seconds(),
+
                },
+
            }))
+
        });
+

+
        Ok(types::repo::RepoInfo {
+
            payloads: SupportedPayloads { project },
+
            delegates,
+
            threshold: doc.threshold,
+
            visibility: doc.visibility,
+
            rid,
+
            seeding,
+
        })
+
    }
+

+
    /// Get a repository by RID, checking to make sure we're allowed to view it.
+
    pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> {
+
        let repo = self.profile.storage.repository(rid)?;
+
        let doc = repo.identity_doc()?;
+
        Ok((repo, doc))
+
    }
+
}
+

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
@@ -30,7 +99,11 @@ pub fn run() {
        })
        .plugin(tauri_plugin_shell::init())
        .plugin(tauri_plugin_window_state::Builder::default().build())
-
        .invoke_handler(tauri::generate_handler![authenticate])
+
        .invoke_handler(tauri::generate_handler![
+
            auth::authenticate,
+
            repos::list_repos,
+
            profile::config,
+
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
added src-tauri/src/types/config.rs
@@ -0,0 +1,23 @@
+
use radicle::crypto::PublicKey;
+
use serde::Serialize;
+
use ts_rs::TS;
+

+
use radicle::node::config::DefaultSeedingPolicy;
+
use radicle::node::Alias;
+

+
/// Service configuration.
+
#[derive(Debug, Clone, TS, Serialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
pub struct Config {
+
    /// Node Public Key in NID format.
+
    #[ts(as = "String")]
+
    pub public_key: PublicKey,
+
    /// Node alias.
+
    #[ts(as = "String")]
+
    pub alias: Alias,
+
    /// Default seeding policy.
+
    #[serde(default)]
+
    #[ts(type = "{ default: 'allow', scope: 'followed' | 'all' } | { default: 'block' }")]
+
    pub seeding_policy: DefaultSeedingPolicy,
+
}
added src-tauri/src/types/mod.rs
@@ -0,0 +1,2 @@
+
pub mod config;
+
pub mod repo;
added src-tauri/src/types/repo.rs
@@ -0,0 +1,50 @@
+
use serde::Serialize;
+
use serde_json::Value;
+
use ts_rs::TS;
+

+
use radicle::identity::RepoId;
+

+
/// Repos info.
+
#[derive(Serialize, TS)]
+
#[ts(export)]
+
pub struct RepoInfo {
+
    pub payloads: SupportedPayloads,
+
    #[ts(type = "({ id: string } | { id: string, alias?: string })[]")]
+
    pub delegates: Vec<Value>,
+
    pub threshold: usize,
+
    #[ts(type = "{ type: 'public' } | { type: 'private', allow?: string[] }")]
+
    pub visibility: radicle::identity::Visibility,
+
    #[ts(as = "String")]
+
    pub rid: RepoId,
+
    pub seeding: usize,
+
}
+

+
#[derive(Serialize, TS)]
+
#[ts(export)]
+
pub struct SupportedPayloads {
+
    #[serde(rename = "xyz.radicle.project")]
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    #[ts(type = r#"{
+
  data: {
+
    defaultBranch: string,
+
    description: string,
+
    name: string,
+
  },
+
  meta: {
+
    head: string,
+
    issues: {
+
      open: number,
+
      closed: number,
+
    },
+
    patches: {
+
      open: number,
+
      draft: number,
+
      archived: number,
+
      merged: number,
+
    }
+
    lastCommit: number,
+
  }
+
}"#)]
+
    pub project: Option<Value>,
+
}
modified src/App.svelte
@@ -7,9 +7,9 @@
  import { theme } from "@app/components/ThemeSwitch.svelte";
  import { unreachable } from "@app/lib/utils";

+
  import AuthenticationError from "@app/views/AuthenticationError.svelte";
  import DesignSystem from "@app/views/DesignSystem.svelte";
  import Home from "@app/views/Home.svelte";
-
  import AuthenticationError from "@app/views/AuthenticationError.svelte";

  const activeRouteStore = router.activeRouteStore;
  void router.loadFromLocation();
@@ -36,7 +36,7 @@
{#if $activeRouteStore.resource === "booting"}
  <!-- Don't show anything -->
{:else if $activeRouteStore.resource === "home"}
-
  <Home />
+
  <Home {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "authenticationError"}
  <AuthenticationError {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "designSystem"}
modified src/components/RepoCard.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
-
  import type { Repo } from "@app/lib/api/repo";
+
  import type { RepoInfo } from "@bindings/RepoInfo";

  import { formatRepositoryId, formatTimestamp } from "@app/lib/utils";

@@ -7,13 +7,10 @@
  import Fill from "./Fill.svelte";
  import Icon from "./Icon.svelte";

-
  // TODO: Pass this via repo.
-
  export let updatedAt: number = 1725360130;
-

-
  export let repo: Repo;
+
  export let repo: RepoInfo;
  export let selfDid: string;

-
  $: project = repo.payloads["xyz.radicle.project"];
+
  $: project = repo.payloads["xyz.radicle.project"]!;
</script>

<style>
@@ -66,17 +63,26 @@
      </div>
    </div>

-
    <div class="title">Radicle Heartwood Protocol & Stack</div>
-

+
    <div class="title">
+
      {#if project.data.description}
+
        {project.data.description}
+
      {:else}
+
        No description
+
      {/if}
+
    </div>
    <div class="global-oid">{formatRepositoryId(repo.rid)}</div>

    <div class="global-flex footer">
      <div class="global-flex">
-
        <div class="global-flex" style:gap="4px"><Icon name="issue" /> 4</div>
-
        <div class="global-flex" style:gap="4px"><Icon name="patch" /> 6</div>
+
        <div class="global-flex" style:gap="4px">
+
          <Icon name="issue" />{project.meta.issues.open}
+
        </div>
+
        <div class="global-flex" style:gap="4px">
+
          <Icon name="patch" />{project.meta.patches.open}
+
        </div>
      </div>
      <span style:color="var(--color-fill-gray)">
-
        Updated {formatTimestamp(updatedAt)}
+
        Updated {formatTimestamp(project.meta.lastCommit)}
      </span>
    </div>
  </div>
deleted src/lib/api/author.ts
@@ -1,8 +0,0 @@
-
import { object, string, z } from "zod";
-

-
export type Author = z.infer<typeof authorSchema>;
-

-
export const authorSchema = object({
-
  id: string(),
-
  alias: string().optional(),
-
});
deleted src/lib/api/fixtures/heartwood-repo.json
@@ -1,36 +0,0 @@
-
{
-
  "rid": "rad:z3trNYnLWS11cJWC6BbxDs5niGo82",
-
  "payloads": {
-
    "xyz.radicle.project": {
-
      "data": {
-
        "name": "rips",
-
        "description": "Radicle Improvement Proposals (RIPs)",
-
        "defaultBranch": "master"
-
      },
-
      "meta": {
-
        "head": "329dee9a4b65169ea3889a7da239892b705d0d68",
-
        "patches": {
-
          "open": 0,
-
          "draft": 2,
-
          "archived": 0,
-
          "merged": 0
-
        },
-
        "issues": {
-
          "open": 1,
-
          "closed": 0
-
        }
-
      }
-
    }
-
  },
-
  "delegates": [
-
    {
-
      "id": "did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
-
      "alias": "cloudhead"
-
    }
-
  ],
-
  "threshold": 1,
-
  "visibility": {
-
    "type": "private"
-
  },
-
  "seeding": 29
-
}
deleted src/lib/api/repo.ts
@@ -1,48 +0,0 @@
-
import {
-
  array,
-
  literal,
-
  number,
-
  object,
-
  optional,
-
  string,
-
  union,
-
  z,
-
} from "zod";
-

-
import { authorSchema } from "./author";
-

-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
-
const repoSchema = object({
-
  rid: string(),
-
  payloads: object({
-
    "xyz.radicle.project": object({
-
      data: object({
-
        name: string(),
-
        description: string(),
-
        defaultBranch: string(),
-
      }),
-
      meta: object({
-
        head: string(),
-
        patches: object({
-
          open: number(),
-
          draft: number(),
-
          archived: number(),
-
          merged: number(),
-
        }),
-
        issues: object({
-
          open: number(),
-
          closed: number(),
-
        }),
-
      }),
-
    }),
-
  }),
-
  delegates: array(authorSchema),
-
  threshold: number(),
-
  visibility: union([
-
    object({ type: literal("public") }),
-
    object({ type: literal("private"), allow: optional(array(string())) }),
-
  ]),
-
  seeding: number(),
-
});
-

-
export type Repo = z.infer<typeof repoSchema>;
modified src/lib/router/definitions.ts
@@ -1,3 +1,8 @@
+
import type { RepoInfo } from "@bindings/RepoInfo";
+
import type { Config } from "@bindings/Config";
+

+
import { invoke } from "@tauri-apps/api/core";
+

interface BootingRoute {
  resource: "booting";
}
@@ -14,6 +19,11 @@ interface HomeRoute {
  resource: "home";
}

+
interface LoadedHomeRoute {
+
  resource: "home";
+
  params: { repos: RepoInfo[]; config: Config };
+
}
+

interface DesignSystemRoute {
  resource: "designSystem";
}
@@ -27,12 +37,17 @@ export type Route =
export type LoadedRoute =
  | BootingRoute
  | DesignSystemRoute
-
  | HomeRoute
+
  | LoadedHomeRoute
  | AuthenticationErrorRoute;

export async function loadRoute(
  route: Route,
  _previousLoaded: LoadedRoute,
): Promise<LoadedRoute> {
+
  if (route.resource === "home") {
+
    const repos: RepoInfo[] = await invoke("list_repos");
+
    const config: Config = await invoke("config");
+
    return { resource: "home", params: { repos, config } };
+
  }
  return route;
}
modified src/views/Home.svelte
@@ -1,13 +1,13 @@
<script lang="ts">
-
  import type { Repo } from "@app/lib/api/repo";
-

-
  import repoFixture from "@app/lib/api/fixtures/heartwood-repo.json";
+
  import type { Config } from "@bindings/Config";
+
  import type { RepoInfo } from "@bindings/RepoInfo";

  import Header from "@app/components/Header.svelte";
  import Link from "@app/components/Link.svelte";
  import RepoCard from "@app/components/RepoCard.svelte";

-
  const selfDid = "did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT";
+
  export let repos: RepoInfo[];
+
  export let config: Config;
</script>

<style>
@@ -24,25 +24,11 @@
<Header currentPage="Repositories" />
<div class="layout">
  <div class="repo-grid">
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-

-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-

-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-

-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
-
    <RepoCard repo={repoFixture as Repo} {selfDid} />
+
    {#each repos as repo}
+
      {#if repo.payloads["xyz.radicle.project"]}
+
        <RepoCard {repo} selfDid={`did:key:${config.publicKey}`} />
+
      {/if}
+
    {/each}
  </div>

  <div style:margin-top="1rem">