From ccfb54c7579e4150ae71c077b5b78e020f2e78bd Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Thu, 5 Sep 2024 10:57:06 +0200 Subject: [PATCH] Add `list_repo` command and repo listing --- package-lock.json | 16 ++- package.json | 3 +- src-tauri/Cargo.toml | 2 +- src-tauri/bindings/RepoInfo.ts | 15 +++ src-tauri/bindings/SupportedPayloads.ts | 25 +++++ src-tauri/bindings/serde_json/JsonValue.ts | 7 ++ src-tauri/src/commands.rs | 2 + src-tauri/src/{ => commands}/auth.rs | 0 src-tauri/src/commands/repos.rs | 30 +++++ src-tauri/src/json.rs | 19 ++++ src-tauri/src/lib.rs | 82 +++++++++++++- src-tauri/src/types/mod.rs | 1 + src-tauri/src/types/repo.rs | 49 +++++++++ src/App.svelte | 5 +- src/components/Border.svelte | 7 +- src/components/Icon.svelte | 22 ++++ src/components/RepoCard.svelte | 122 +++++++++++++++++++++ src/lib/router.ts | 8 ++ src/lib/router/definitions.ts | 20 ++++ src/lib/utils.ts | 76 +++++++++++++ src/views/Home.svelte | 1 + src/views/Repos.svelte | 36 ++++++ 22 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 src-tauri/bindings/RepoInfo.ts create mode 100644 src-tauri/bindings/SupportedPayloads.ts create mode 100644 src-tauri/bindings/serde_json/JsonValue.ts create mode 100644 src-tauri/src/commands.rs rename src-tauri/src/{ => commands}/auth.rs (100%) create mode 100644 src-tauri/src/commands/repos.rs create mode 100644 src-tauri/src/json.rs create mode 100644 src-tauri/src/types/mod.rs create mode 100644 src-tauri/src/types/repo.rs create mode 100644 src/components/RepoCard.svelte create mode 100644 src/views/Repos.svelte diff --git a/package-lock.json b/package-lock.json index 4b8b090..aba7fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@tauri-apps/api": "^2.0.0-beta.15", "@tauri-apps/plugin-shell": "^2.0.0-beta.8", - "@tauri-apps/plugin-window-state": "^2.0.0-rc.0" + "@tauri-apps/plugin-window-state": "^2.0.0-rc.0", + "bs58": "^6.0.0" }, "devDependencies": { "@eslint/js": "^9.9.1", @@ -1447,6 +1448,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base-x": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", + "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1481,6 +1487,14 @@ "node": ">=8" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "dependencies": { + "base-x": "^5.0.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", diff --git a/package.json b/package.json index 6dbefcf..3bf3a96 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "dependencies": { "@tauri-apps/api": "^2.0.0-beta.15", "@tauri-apps/plugin-shell": "^2.0.0-beta.8", - "@tauri-apps/plugin-window-state": "^2.0.0-rc.0" + "@tauri-apps/plugin-window-state": "^2.0.0-rc.0", + "bs58": "^6.0.0" }, "devDependencies": { "@eslint/js": "^9.9.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9bd39d8..08b3246 100644 --- a/src-tauri/Cargo.toml +++ b/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 diff --git a/src-tauri/bindings/RepoInfo.ts b/src-tauri/bindings/RepoInfo.ts new file mode 100644 index 0000000..cfec1d7 --- /dev/null +++ b/src-tauri/bindings/RepoInfo.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; +import type { SupportedPayloads } from "./SupportedPayloads"; + +/** + * Repos info. + */ +export type RepoInfo = { + payloads: SupportedPayloads; + delegates: Array; + threshold: number; + visibility: { type: "public" } | { type: "private"; allow?: string[] }; + rid: string; + seeding: number; +}; diff --git a/src-tauri/bindings/SupportedPayloads.ts b/src-tauri/bindings/SupportedPayloads.ts new file mode 100644 index 0000000..dad9ee1 --- /dev/null +++ b/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; + }; + }; +}; diff --git a/src-tauri/bindings/serde_json/JsonValue.ts b/src-tauri/bindings/serde_json/JsonValue.ts new file mode 100644 index 0000000..1431f9a --- /dev/null +++ b/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 + | { [key: string]: JsonValue }; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..e4ecea6 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod repos; diff --git a/src-tauri/src/auth.rs b/src-tauri/src/commands/auth.rs similarity index 100% rename from src-tauri/src/auth.rs rename to src-tauri/src/commands/auth.rs diff --git a/src-tauri/src/commands/repos.rs b/src-tauri/src/commands/repos.rs new file mode 100644 index 0000000..62ddbe2 --- /dev/null +++ b/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) -> Result, Error> { + let storage = &ctx.profile.storage; + let policies = ctx.profile.policies()?; + + let mut repos = storage.repositories()?.into_iter().collect::>(); + 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::>(); + + Ok::<_, Error>(infos) +} diff --git a/src-tauri/src/json.rs b/src-tauri/src/json.rs new file mode 100644 index 0000000..dbca949 --- /dev/null +++ b/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, }), + ) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 23af978..638fc1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,13 +1,86 @@ -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, repos}; +use types::repo::SupportedPayloads; + struct AppState { profile: radicle::Profile, } +impl AppState { + pub fn repo_info( + &self, + repo: &R, + doc: DocAt, + ) -> Result { + 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::>(); + let db = &self.profile.database()?; + let seeding = db.count(&rid).unwrap_or_default(); + + let project = doc + .payload + .get(&PayloadId::project()) + .map(|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(), + }, + })) + }) + .flatten(); + + 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 +103,10 @@ 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 + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/types/mod.rs b/src-tauri/src/types/mod.rs new file mode 100644 index 0000000..c426b23 --- /dev/null +++ b/src-tauri/src/types/mod.rs @@ -0,0 +1 @@ +pub mod repo; diff --git a/src-tauri/src/types/repo.rs b/src-tauri/src/types/repo.rs new file mode 100644 index 0000000..f350079 --- /dev/null +++ b/src-tauri/src/types/repo.rs @@ -0,0 +1,49 @@ +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, + pub delegates: Vec, + 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, +} diff --git a/src/App.svelte b/src/App.svelte index 493a4d6..83c783c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -7,9 +7,10 @@ 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"; + import Repos from "@app/views/Repos.svelte"; const activeRouteStore = router.activeRouteStore; void router.loadFromLocation(); @@ -41,6 +42,8 @@ {:else if $activeRouteStore.resource === "designSystem"} +{:else if $activeRouteStore.resource === "repos"} + {:else} {unreachable($activeRouteStore)} {/if} diff --git a/src/components/Border.svelte b/src/components/Border.svelte index b89b736..f837b71 100644 --- a/src/components/Border.svelte +++ b/src/components/Border.svelte @@ -2,6 +2,8 @@ export let variant: "primary" | "secondary" | "ghost"; export let stylePadding: string | undefined = undefined; export let styleHeight: string | undefined = undefined; + export let styleTextOverflow: boolean = false; + export let styleFontWeight: string = "txt-semibold"; $: style = `--button-color-1: var(--color-fill-${variant});`; @@ -146,7 +148,10 @@
-
+
diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index b30200b..ce061b9 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -12,8 +12,10 @@ | "diff" | "file" | "inbox" + | "issue" | "moon" | "offline" + | "patch" | "plus" | "repo" | "seedling" @@ -133,6 +135,18 @@ + {:else if name === "issue"} + + + + + + + + + + + {:else if name === "moon"} @@ -195,6 +209,14 @@ + {:else if name === "patch"} + + + + + + + {:else if name === "plus"} diff --git a/src/components/RepoCard.svelte b/src/components/RepoCard.svelte new file mode 100644 index 0000000..1db9246 --- /dev/null +++ b/src/components/RepoCard.svelte @@ -0,0 +1,122 @@ + + + + + +
+
+
+

{project.data.name}

+
+ + + + + {repoInfo.seeding} + +
+
+ {#if project.data.description} +

+ {project.data.description} +

+ {:else} +

No description

+ {/if} + + {utils.formatRepositoryId(repoInfo.rid)} + +
+
+
+ + {project.meta.issues.open} ยท + + + {project.meta.patches.open} + + + Updated {utils.formatTimestamp(project.meta.lastCommit)} + +
+
+
+
diff --git a/src/lib/router.ts b/src/lib/router.ts index f3d4720..5cc7f24 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -100,6 +100,9 @@ function setTitle(loadedRoute: LoadedRoute) { } else if (loadedRoute.resource === "designSystem") { title.push("Design System"); title.push("Radicle"); + } else if (loadedRoute.resource === "repos") { + title.push("Repos"); + title.push("Radicle"); } else { utils.unreachable(loadedRoute); } @@ -129,6 +132,9 @@ function urlToRoute(url: URL): Route | null { case "designSystem": { return { resource: "designSystem" }; } + case "repos": { + return { resource: "repos" }; + } default: { return null; } @@ -142,6 +148,8 @@ export function routeToPath(route: Route): string { return "/authenticationError"; } else if (route.resource === "designSystem") { return "/designSystem"; + } else if (route.resource === "repos") { + return "/repos"; } else if (route.resource === "booting") { return ""; } else { diff --git a/src/lib/router/definitions.ts b/src/lib/router/definitions.ts index 16722c5..11ae683 100644 --- a/src/lib/router/definitions.ts +++ b/src/lib/router/definitions.ts @@ -1,3 +1,6 @@ +import type { RepoInfo } from "@bindings/RepoInfo"; +import { invoke } from "@tauri-apps/api/core"; + interface BootingRoute { resource: "booting"; } @@ -18,21 +21,38 @@ interface DesignSystemRoute { resource: "designSystem"; } +interface ReposRoute { + resource: "repos"; +} + +interface LoadedReposRoute { + resource: "repos"; + params: { + repos: RepoInfo[]; + }; +} + export type Route = | BootingRoute | DesignSystemRoute | HomeRoute + | ReposRoute | AuthenticationErrorRoute; export type LoadedRoute = | BootingRoute | DesignSystemRoute | HomeRoute + | LoadedReposRoute | AuthenticationErrorRoute; export async function loadRoute( route: Route, _previousLoaded: LoadedRoute, ): Promise { + if (route.resource === "repos") { + const repos: RepoInfo[] = await invoke("list_repos"); + return { resource: "repos", params: { repos } }; + } return route; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 311b042..43dc123 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,79 @@ +import bs58 from "bs58"; + export const unreachable = (value: never): never => { throw new Error(`Unreachable code: ${value}`); }; + +export function absoluteTimestamp(time: number | undefined) { + return time ? new Date(time * 1000).toString() : undefined; +} + +export const formatTimestamp = ( + timestamp: number, + current = new Date().getTime(), +): string => { + const units: Record = { + year: 24 * 60 * 60 * 1000 * 365, + month: (24 * 60 * 60 * 1000 * 365) / 12, + day: 24 * 60 * 60 * 1000, + hour: 60 * 60 * 1000, + minute: 60 * 1000, + second: 1000, + }; + + // Multiplying timestamp with 1000 to convert from seconds to milliseconds + timestamp = timestamp * 1000; + const rtf = new Intl.RelativeTimeFormat("en", { + numeric: "auto", + style: "long", + }); + const elapsed = current - timestamp; + + if (elapsed > units["year"]) { + return "more than a year ago"; + } else if (elapsed < 0) { + return "now"; // If elapsed is a negative number we are dealing with an item from the future, and we return "now" + } + + for (const u in units) { + if (elapsed > units[u] || u === "second") { + // We convert the division result to a negative number to get "XX [unit] ago" + return rtf.format( + Math.round(elapsed / units[u]) * -1, + u as Intl.RelativeTimeFormatUnit, + ); + } + } + + return new Date(timestamp).toUTCString(); +}; + +export function parseRepositoryId( + rid: string, +): { prefix: string; pubkey: string } | undefined { + const match = /^(rad:)?(z[a-zA-Z0-9]+)$/.exec(rid); + if (match) { + const hex = bs58.decode(match[2].substring(1)); + if (hex.byteLength !== 20) { + return undefined; + } + + return { prefix: match[1] || "rad:", pubkey: match[2] }; + } + + return undefined; +} + +export function formatRepositoryId(id: string): string { + const parsedId = parseRepositoryId(id); + + if (parsedId) { + return `${parsedId.prefix}${truncateId(parsedId.pubkey)}`; + } + + return id; +} + +export function truncateId(pubkey: string): string { + return `${pubkey.substring(0, 6)}โ€ฆ${pubkey.slice(-6)}`; +} diff --git a/src/views/Home.svelte b/src/views/Home.svelte index 6815ad2..6111cd7 100644 --- a/src/views/Home.svelte +++ b/src/views/Home.svelte @@ -6,3 +6,4 @@
๐Ÿ‘‰ Design System +๐Ÿ‘‰ Repos diff --git a/src/views/Repos.svelte b/src/views/Repos.svelte new file mode 100644 index 0000000..399e571 --- /dev/null +++ b/src/views/Repos.svelte @@ -0,0 +1,36 @@ + + + + +
+ +
+

Repositories

+ +
+ {#each repos as repoInfo} + {#if repoInfo.payloads["xyz.radicle.project"]} + + {/if} + {/each} +
+