diff --git a/crates/radicle-tauri/build.rs b/crates/radicle-tauri/build.rs index 6a57222bc..37a1933e7 100644 --- a/crates/radicle-tauri/build.rs +++ b/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(); diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index d48fa0ca9..476ca7bc4 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/crates/radicle-tauri/src/commands/repo.rs @@ -35,6 +35,15 @@ pub fn repo_by_id( ctx.repo_by_id(rid) } +#[tauri::command] +pub fn repo_readme( + ctx: tauri::State, + rid: RepoId, + sha: Option, +) -> Result, Error> { + ctx.repo_readme(rid, sha) +} + #[tauri::command] pub async fn diff_stats( ctx: tauri::State<'_, AppState>, diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 54b4a943f..6246b2a09 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/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, diff --git a/crates/radicle-types/bindings/repo/Readme.ts b/crates/radicle-types/bindings/repo/Readme.ts new file mode 100644 index 000000000..2ad71dd97 --- /dev/null +++ b/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 }; diff --git a/crates/radicle-types/bindings/repo/Visibility.ts b/crates/radicle-types/bindings/repo/Visibility.ts index b840d940e..06070f5c9 100644 --- a/crates/radicle-types/bindings/repo/Visibility.ts +++ b/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; + allow?: Array; }; diff --git a/crates/radicle-types/src/repo.rs b/crates/radicle-types/src/repo.rs index cf4d2daf5..5f47701e1 100644 --- a/crates/radicle-types/src/repo.rs +++ b/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>", optional)] - allow: BTreeSet, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[ts(as = "Option>", optional)] + allow: Vec, }, } -impl From for Visibility { - fn from(value: identity::Visibility) -> Self { - match value { - identity::Visibility::Private { allow } => Self::Private { allow }, - identity::Visibility::Public => Self::Public, - } - } -} - impl From 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::>(); + Self::Private { allow: did_set } + } Visibility::Public => Self::Public, } } diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index f53cb3064..4ea62f5ed 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/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, + ) -> Result, 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 { 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, diff --git a/public/index.css b/public/index.css index d45683c19..a0a0c579f 100644 --- a/public/index.css +++ b/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; + } +} diff --git a/public/typography.css b/public/typography.css index 95bc22fec..363edb1d3 100644 --- a/public/typography.css +++ b/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; diff --git a/scripts/check-js b/scripts/check-js index 53dfabfd4..dad8e9e4c 100755 --- a/scripts/check-js +++ b/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 diff --git a/src/App.svelte b/src/App.svelte index bf3d82da8..ee8c48cea 100644 --- a/src/App.svelte +++ b/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 @@ {:else if $activeRouteStore.resource === "inbox"} +{:else if $activeRouteStore.resource === "repo.home"} + {:else if $activeRouteStore.resource === "repo.createIssue"} {:else if $activeRouteStore.resource === "repo.issue"} diff --git a/src/components/Clipboard.svelte b/src/components/Clipboard.svelte index cf80f72e4..09f561fc9 100644 --- a/src/components/Clipboard.svelte +++ b/src/components/Clipboard.svelte @@ -1,3 +1,5 @@ + + + + + +
+
+
+ +
+ +
+
+
+ +
+ +
+
Issues
+
+ {project.meta.issues.open + project.meta.issues.closed} +
+
+ +
+ +
+ +
+
Patches
+
+ {project.meta.patches.draft + + project.meta.patches.open + + project.meta.patches.archived + + project.meta.patches.merged} +
+
+ +
+
+ + +
diff --git a/src/components/RepoMetadata.svelte b/src/components/RepoMetadata.svelte new file mode 100644 index 000000000..20e3066d3 --- /dev/null +++ b/src/components/RepoMetadata.svelte @@ -0,0 +1,128 @@ + + + + + + + + {#if repo.visibility.type === "private"} + + + + {/if} + + + + + + + +
+ + + + + +
+
diff --git a/src/components/RepoTeaser.svelte b/src/components/RepoTeaser.svelte index 9146df71d..7d540eaec 100644 --- a/src/components/RepoTeaser.svelte +++ b/src/components/RepoTeaser.svelte @@ -12,7 +12,7 @@ + + + + {capitalize(type)} + diff --git a/src/lib/router.ts b/src/lib/router.ts index a5dc9e22d..d1af1b379 100644 --- a/src/lib/router.ts +++ b/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" || diff --git a/src/lib/router/definitions.ts b/src/lib/router/definitions.ts index 19693f044..9e38fde33 100644 --- a/src/lib/router/definitions.ts +++ b/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") { diff --git a/src/views/home/Repos.svelte b/src/views/home/Repos.svelte index 06e3667d5..99848f234 100644 --- a/src/views/home/Repos.svelte +++ b/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} diff --git a/src/views/repo/Issues.svelte b/src/views/repo/Issues.svelte index 8d5d7df46..8b2206968 100644 --- a/src/views/repo/Issues.svelte +++ b/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 @@ {/snippet} - {#snippet sidebar()} - - {/snippet} - {#snippet secondColumn()} {/snippet} diff --git a/src/views/repo/Patch.svelte b/src/views/repo/Patch.svelte index 319b81b78..01c9c857e 100644 --- a/src/views/repo/Patch.svelte +++ b/src/views/repo/Patch.svelte @@ -565,9 +565,9 @@ + popoverPositionTop="3rem"> {#snippet toggle(onclick)} - {/snippet} diff --git a/src/views/repo/Patches.svelte b/src/views/repo/Patches.svelte index cb549b9c6..61dd05979 100644 --- a/src/views/repo/Patches.svelte +++ b/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 @@ {/snippet} - {#snippet sidebar()} - - {/snippet} - {#snippet secondColumn()} {/snippet} diff --git a/src/views/repo/RepoHome.svelte b/src/views/repo/RepoHome.svelte new file mode 100644 index 000000000..4abbc5e96 --- /dev/null +++ b/src/views/repo/RepoHome.svelte @@ -0,0 +1,154 @@ + + + + + + {#snippet headerCenter()} + + {/snippet} + + {#snippet secondColumn()} + + {/snippet} + +
+
+
+ {project.data.name} +
+
+ + {#snippet toggle(onclick)} + + {/snippet} + + {#snippet popover()} + + To checkout a working copy of this repo, run: + + + {/snippet} + +
+
+ + {#if project.data.description !== ""} + + {/if} + +
+ +
+ +
+
+ {#if readme === null} + +
+ README not found +
+
+ {:else} + + {#snippet leftHeader()} +
+ +
+ {/snippet} + +
+ {#if readme.binary} +
+ Binary file +
+ {:else if readme.content.trim() === ""} +
+ Empty file +
+ {:else} + + {/if} +
+
+ {/if} +
+ +
+ +
+
+
+
diff --git a/src/views/repo/router.ts b/src/views/repo/router.ts index fbb698028..e690e9d46 100644 --- a/src/views/repo/router.ts +++ b/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 { + const [config, repo, readme] = await Promise.all([ + invoke("config"), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("repo_readme", { + rid: route.rid, + }), + ]); + + return { + resource: "repo.home", + params: { repo, config, readme }, + }; +} + export async function loadCreateIssue( route: RepoCreateIssueRoute, ): Promise { @@ -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; diff --git a/tests/e2e/repo/issue.spec.ts b/tests/e2e/repo/issue.spec.ts index dd2225668..69c32a434 100644 --- a/tests/e2e/repo/issue.spec.ts +++ b/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") diff --git a/tests/e2e/repos.spec.ts b/tests/e2e/repos.spec.ts index 352536ce1..116849573 100644 --- a/tests/e2e/repos.spec.ts +++ b/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), diff --git a/vite.config.ts b/vite.config.ts index 9ed65489e..b572c6eec 100644 --- a/vite.config.ts +++ b/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", },