Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add single issue view
Rūdolfs Ošiņš committed 1 year ago
commit 6b8cb9156716bf5a35213a6c113039b437ad71fb
parent c316caec04964c5f1c5c82d6baa61b5c06b4cc70
12 files changed +296 -48
modified src-tauri/src/commands/cobs.rs
@@ -4,7 +4,6 @@ use radicle::cob::ObjectId;
use radicle::git::Oid;
use radicle::identity::RepoId;
use radicle::issue::cache::Issues;
-
use radicle::issue::IssueId;
use radicle::patch::cache::Patches;

use crate::error::Error;
@@ -41,14 +40,14 @@ pub fn list_issues(
pub fn issues_by_id(
    ctx: tauri::State<AppState>,
    rid: RepoId,
-
    id: IssueId,
+
    id: Oid,
) -> Result<Option<cobs::Issue>, Error> {
    let (repo, _) = ctx.repo(rid)?;
    let issues = ctx.profile.issues(&repo)?;
-
    let issue = issues.get(&id)?;
+
    let issue = issues.get(&id.into())?;

    let aliases = &ctx.profile.aliases();
-
    let issue = issue.map(|issue| cobs::Issue::new(id, issue, aliases));
+
    let issue = issue.map(|issue| cobs::Issue::new(id.into(), issue, aliases));

    Ok::<_, Error>(issue)
}
modified src/App.svelte
@@ -2,6 +2,7 @@
  import { onMount } from "svelte";

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

  import * as router from "@app/lib/router";
  import { nodeRunning } from "@app/lib/events";
@@ -10,9 +11,9 @@

  import AuthenticationError from "@app/views/AuthenticationError.svelte";
  import Home from "@app/views/Home.svelte";
+
  import Issue from "@app/views/repo/Issue.svelte";
  import Issues from "@app/views/repo/Issues.svelte";
  import Patches from "@app/views/repo/Patches.svelte";
-
  import { listen } from "@tauri-apps/api/event";

  const activeRouteStore = router.activeRouteStore;

@@ -51,6 +52,8 @@
  <!-- Don't show anything -->
{:else if $activeRouteStore.resource === "home"}
  <Home {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "repo.issue"}
+
  <Issue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issues"}
  <Issues {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patches"}
modified src/components/InlineTitle.svelte
@@ -16,6 +16,7 @@
    font-family: var(--font-family-monospace);
    padding: 0.125rem 0.25rem;
    background-color: var(--color-fill-ghost);
+
    font-size: inherit;
  }
</style>

modified src/components/IssueTeaser.svelte
@@ -1,22 +1,20 @@
<script lang="ts">
  import type { Issue } from "@bindings/Issue";

-
  import { formatOid, formatTimestamp } from "@app/lib/utils";
+
  import {
+
    formatOid,
+
    formatTimestamp,
+
    issueStatusBackgroundColor,
+
    issueStatusColor,
+
  } from "@app/lib/utils";
+
  import { push } from "@app/lib/router";

  import Icon from "./Icon.svelte";
  import InlineTitle from "./InlineTitle.svelte";
  import NodeId from "./NodeId.svelte";

  export let issue: Issue;
-

-
  const statusColor: Record<Issue["state"]["status"], string> = {
-
    open: "var(--color-fill-success)",
-
    closed: "var(--color-foreground-red)",
-
  };
-
  const statusBackgroundColor: Record<Issue["state"]["status"], string> = {
-
    open: "var(--color-fill-diff-green)",
-
    closed: "var(--color-fill-diff-red)",
-
  };
+
  export let rid: string;
</script>

<style>
@@ -49,12 +47,19 @@
  }
</style>

-
<div class="issue-teaser">
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
  tabindex="0"
+
  role="button"
+
  class="issue-teaser"
+
  onclick={() => {
+
    void push({ resource: "repo.issue", rid, issue: issue.id });
+
  }}>
  <div class="global-flex">
    <div
      class="global-counter status"
-
      style:color={statusColor[issue.state.status]}
-
      style:background-color={statusBackgroundColor[issue.state.status]}>
+
      style:color={issueStatusColor[issue.state.status]}
+
      style:background-color={issueStatusBackgroundColor[issue.state.status]}>
      <Icon name="issue" />
    </div>
    <div
modified src/lib/router.ts
@@ -118,6 +118,7 @@ export function routeToPath(route: Route): string {
  } else if (route.resource === "authenticationError") {
    return "/authenticationError";
  } else if (
+
    route.resource === "repo.issue" ||
    route.resource === "repo.issues" ||
    route.resource === "repo.patches"
  ) {
modified src/lib/router/definitions.ts
@@ -1,15 +1,17 @@
import type { Config } from "@bindings/Config";
import type { RepoInfo } from "@bindings/RepoInfo";
import type {
-
  RepoIssuesRoute,
-
  RepoPatchesRoute,
+
  LoadedRepoIssueRoute,
  LoadedRepoIssuesRoute,
  LoadedRepoPatchesRoute,
+
  RepoIssueRoute,
+
  RepoIssuesRoute,
+
  RepoPatchesRoute,
} from "@app/views/repo/router";

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

-
import { loadIssues, loadPatches } from "@app/views/repo/router";
+
import { loadIssues, loadIssue, loadPatches } from "@app/views/repo/router";

interface BootingRoute {
  resource: "booting";
@@ -36,6 +38,7 @@ export type Route =
  | AuthenticationErrorRoute
  | BootingRoute
  | HomeRoute
+
  | RepoIssueRoute
  | RepoIssuesRoute
  | RepoPatchesRoute;

@@ -43,6 +46,7 @@ export type LoadedRoute =
  | AuthenticationErrorRoute
  | BootingRoute
  | LoadedHomeRoute
+
  | LoadedRepoIssueRoute
  | LoadedRepoIssuesRoute
  | LoadedRepoPatchesRoute;

@@ -54,6 +58,8 @@ export async function loadRoute(
    const repos: RepoInfo[] = await invoke("list_repos");
    const config: Config = await invoke("config");
    return { resource: "home", params: { repos, config } };
+
  } else if (route.resource === "repo.issue") {
+
    return loadIssue(route);
  } else if (route.resource === "repo.issues") {
    return loadIssues(route);
  } else if (route.resource === "repo.patches") {
modified src/lib/utils.ts
@@ -1,3 +1,5 @@
+
import type { Issue } from "@bindings/Issue";
+

import bs58 from "bs58";
import twemojiModule from "twemoji";

@@ -100,3 +102,16 @@ export function scrollIntoView(id: string, options?: ScrollIntoViewOptions) {
  const lineElement = document.getElementById(id);
  if (lineElement) lineElement.scrollIntoView(options);
}
+

+
export const issueStatusColor: Record<Issue["state"]["status"], string> = {
+
  open: "var(--color-fill-success)",
+
  closed: "var(--color-foreground-red)",
+
};
+

+
export const issueStatusBackgroundColor: Record<
+
  Issue["state"]["status"],
+
  string
+
> = {
+
  open: "var(--color-fill-diff-green)",
+
  closed: "var(--color-fill-diff-red)",
+
};
added src/views/repo/Issue.svelte
@@ -0,0 +1,144 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/Config";
+
  import type { Issue } from "@bindings/Issue";
+
  import type { RepoInfo } from "@bindings/RepoInfo";
+

+
  import { formatTimestamp, formatOid, issueStatusColor } from "@app/lib/utils";
+

+
  import Border from "@app/components/Border.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import InlineTitle from "@app/components/InlineTitle.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  export let repo: RepoInfo;
+
  export let issue: Issue;
+
  export let issues: Issue[];
+
  export let config: Config;
+

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

+
<style>
+
  .title {
+
    font-size: var(--font-size-medium);
+
    font-weight: var(--font-weight-medium);
+
    -webkit-user-select: text;
+
    user-select: text;
+
    margin-bottom: 1rem;
+
    margin-top: 0.35rem;
+
  }
+
  .issue-teaser {
+
    max-width: 11rem;
+
    white-space: nowrap;
+
  }
+
  .issue-list {
+
    margin-top: 0.5rem;
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
  }
+
  .content {
+
    padding: 0 1rem 1rem 1rem;
+
  }
+

+
  .body {
+
    background-color: var(--color-background-float);
+
    padding: 1rem;
+
  }
+
</style>
+

+
<Layout {repo} selfDid={`did:key:${config.publicKey}`}>
+
  <svelte:fragment slot="breadcrumbs">
+
    <Link route={{ resource: "home" }}>
+
      <NodeId
+
        nodeId={config.publicKey}
+
        alias={config.alias}
+
        styleFontFamily="var(--font-family-sans-serif)"
+
        styleFontSize="var(--font-size-tiny)" />
+
    </Link>
+
    <Link route={{ resource: "repo.issues", rid: repo.rid, status: "open" }}>
+
      <div class="global-flex">
+
        <Icon name="chevron-right" />
+
        {project.data.name}
+
      </div>
+
    </Link>
+
    <Icon name="chevron-right" />
+
    Issues
+
  </svelte:fragment>
+

+
  <svelte:fragment slot="header-center">
+
    <CopyableId id={issue.id} />
+
  </svelte:fragment>
+

+
  <svelte:fragment slot="sidebar">
+
    <Border
+
      hoverable={false}
+
      variant="ghost"
+
      styleWidth="100%"
+
      styleHeight="32px">
+
      <div style:margin-left="0.5rem">
+
        <Icon name="issue" />
+
      </div>
+
      <span class="txt-small txt-semibold">Issues</span>
+
      <div class="global-flex txt-small" style:margin-left="auto">
+
        <div
+
          class="global-counter"
+
          style:padding="0 6px"
+
          style:background-color="var(--color-fill-ghost)"
+
          style:gap="4px">
+
          {project.meta.issues.open + project.meta.issues.closed}
+
        </div>
+
      </div>
+
    </Border>
+

+
    <div class="issue-list">
+
      {#each issues as sidebarIssue}
+
        <Link
+
          variant="tab"
+
          route={{
+
            resource: "repo.issue",
+
            rid: repo.rid,
+
            issue: sidebarIssue.id,
+
          }}>
+
          <div class="global-flex">
+
            <div
+
              style:color={issueStatusColor[sidebarIssue.state.status]}
+
              style:margin-left="2px">
+
              <Icon name="issue" />
+
            </div>
+
            <span class="txt-small issue-teaser txt-overflow">
+
              <InlineTitle content={sidebarIssue.title} fontSize="small" />
+
            </span>
+
          </div>
+
        </Link>
+
      {/each}
+
    </div>
+
  </svelte:fragment>
+

+
  <div class="content">
+
    <div class="title">
+
      <InlineTitle content={issue.title} fontSize="medium" />
+
    </div>
+
    <div class="txt-small body">
+
      {#if issue.discussion[0].edits.slice(-1)[0].body !== ""}
+
        <Markdown
+
          breaks
+
          content={issue.discussion[0].edits.slice(-1)[0].body} />
+
      {:else}
+
        <span class="txt-missing">No description.</span>
+
      {/if}
+
      <div class="global-flex txt-small" style:margin-top="1.5rem">
+
        <NodeId
+
          nodeId={issue.author.did.replace("did:key:", "")}
+
          alias={issue.author.alias} />
+
        opened
+
        <div class="global-oid">{formatOid(issue.id)}</div>
+
        {formatTimestamp(issue.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
</Layout>
modified src/views/repo/Issues.svelte
@@ -6,10 +6,13 @@

  import Layout from "./Layout.svelte";

+
  import Border from "@app/components/Border.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";
+
  import RepoHeader from "@app/components/RepoHeader.svelte";

  export let repo: RepoInfo;
  export let issues: Issue[];
@@ -28,7 +31,7 @@
  }
</style>

-
<Layout {repo} selfDid={`did:key:${config.publicKey}`}>
+
<Layout>
  <svelte:fragment slot="breadcrumbs">
    <Link route={{ resource: "home" }}>
      <NodeId
@@ -47,7 +50,22 @@
    Issues
  </svelte:fragment>

+
  <svelte:fragment slot="header-center">
+
    <CopyableId id={repo.rid} />
+
  </svelte:fragment>
+

  <svelte:fragment slot="sidebar">
+
    <Border
+
      hoverable={false}
+
      variant="ghost"
+
      styleWidth="100%"
+
      styleHeight="32px">
+
      <RepoHeader
+
        {repo}
+
        selfDid={`did:key:${config.publicKey}`}
+
        emphasizedTitle={false} />
+
    </Border>
+

    <div class="global-flex txt-small" style:margin="0.5rem 0">
      <Link
        variant={status === "all" ? "active" : "tab"}
@@ -98,7 +116,7 @@

  <div class="list">
    {#each issues as issue}
-
      <IssueTeaser {issue} />
+
      <IssueTeaser {issue} rid={repo.rid} />
    {/each}

    {#if issues.length === 0}
modified src/views/repo/Layout.svelte
@@ -1,15 +1,7 @@
<script lang="ts">
-
  import type { RepoInfo } from "@bindings/RepoInfo";
-

-
  import Border from "@app/components/Border.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
  import Header from "@app/components/Header.svelte";
  import Icon from "@app/components/Icon.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
-
  import RepoHeader from "@app/components/RepoHeader.svelte";
-

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

  let hidden = false;
</script>
@@ -56,9 +48,11 @@
          <Icon name="sidebar" />
        </NakedButton>
      </svelte:fragment>
+

      <svelte:fragment slot="center">
-
        <CopyableId id={repo.rid} />
+
        <slot name="header-center" />
      </svelte:fragment>
+

      <svelte:fragment slot="breadcrumbs">
        <slot name="breadcrumbs" />
      </svelte:fragment>
@@ -66,14 +60,6 @@
  </div>

  <div class="sidebar" class:hidden>
-
    <Border
-
      hoverable={false}
-
      variant="ghost"
-
      styleWidth="100%"
-
      styleHeight="32px">
-
      <RepoHeader {repo} {selfDid} emphasizedTitle={false} />
-
    </Border>
-

    <slot name="sidebar" />
  </div>

modified src/views/repo/Patches.svelte
@@ -6,10 +6,13 @@

  import Layout from "./Layout.svelte";

+
  import Border from "@app/components/Border.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
+
  import RepoHeader from "@app/components/RepoHeader.svelte";

  export let repo: RepoInfo;
  export let patches: Patch[];
@@ -28,7 +31,7 @@
  }
</style>

-
<Layout {repo} selfDid={`did:key:${config.publicKey}`}>
+
<Layout>
  <svelte:fragment slot="breadcrumbs">
    <Link route={{ resource: "home" }}>
      <NodeId
@@ -46,8 +49,22 @@
    <Icon name="chevron-right" />
    Patches
  </svelte:fragment>
+
  <svelte:fragment slot="header-center">
+
    <CopyableId id={repo.rid} />
+
  </svelte:fragment>

  <svelte:fragment slot="sidebar">
+
    <Border
+
      hoverable={false}
+
      variant="ghost"
+
      styleWidth="100%"
+
      styleHeight="32px">
+
      <RepoHeader
+
        {repo}
+
        selfDid={`did:key:${config.publicKey}`}
+
        emphasizedTitle={false} />
+
    </Border>
+

    <div class="global-flex txt-small" style:margin="0.5rem 0">
      <Link
        variant="tab"
modified src/views/repo/router.ts
@@ -8,6 +8,22 @@ import { unreachable } from "@app/lib/utils";

export type IssueStatus = "all" | Issue["state"]["status"];

+
export interface RepoIssueRoute {
+
  resource: "repo.issue";
+
  rid: string;
+
  issue: string;
+
}
+

+
export interface LoadedRepoIssueRoute {
+
  resource: "repo.issue";
+
  params: {
+
    repo: RepoInfo;
+
    config: Config;
+
    issue: Issue;
+
    issues: Issue[];
+
  };
+
}
+

export interface RepoIssuesRoute {
  resource: "repo.issues";
  rid: string;
@@ -42,8 +58,11 @@ export interface LoadedRepoPatchesRoute {
  };
}

-
export type RepoRoute = RepoIssuesRoute | RepoPatchesRoute;
-
export type LoadedRepoRoute = LoadedRepoIssuesRoute | LoadedRepoPatchesRoute;
+
export type RepoRoute = RepoIssueRoute | RepoIssuesRoute | RepoPatchesRoute;
+
export type LoadedRepoRoute =
+
  | LoadedRepoIssueRoute
+
  | LoadedRepoIssuesRoute
+
  | LoadedRepoPatchesRoute;

export async function loadPatches(
  route: RepoPatchesRoute,
@@ -81,10 +100,35 @@ export async function loadIssues(
  };
}

+
export async function loadIssue(
+
  route: RepoIssueRoute,
+
): Promise<LoadedRepoIssueRoute> {
+
  const repo: RepoInfo = await invoke("repo_by_id", {
+
    rid: route.rid,
+
  });
+
  const config: Config = await invoke("config");
+
  const issues: Issue[] = await invoke("list_issues", {
+
    rid: route.rid,
+
    status: "all",
+
  });
+
  const issue: Issue = await invoke("issues_by_id", {
+
    rid: route.rid,
+
    id: route.issue,
+
  });
+

+
  return {
+
    resource: "repo.issue",
+
    params: { repo, config, issue, issues },
+
  };
+
}
+

export function repoRouteToPath(route: RepoRoute): string {
  const pathSegments = ["/repos", route.rid];

-
  if (route.resource === "repo.issues") {
+
  if (route.resource === "repo.issue") {
+
    const url = [...pathSegments, "issues", route.issue].join("/");
+
    return url;
+
  } else if (route.resource === "repo.issues") {
    let url = [...pathSegments, "issues"].join("/");
    const searchParams = new URLSearchParams();
    if (route.status) {
@@ -114,11 +158,20 @@ export function repoUrlToRoute(

  if (rid) {
    if (resource === "issues") {
-
      const status = searchParams.get("status");
-
      if (status === "open" || status === "closed") {
-
        return { resource: "repo.issues", rid, status };
+
      const id = segments.shift();
+
      if (id) {
+
        return {
+
          resource: "repo.issue",
+
          rid,
+
          issue: id,
+
        };
      } else {
-
        return { resource: "repo.issues", rid, status: "all" };
+
        const status = searchParams.get("status");
+
        if (status === "open" || status === "closed") {
+
          return { resource: "repo.issues", rid, status };
+
        } else {
+
          return { resource: "repo.issues", rid, status: "all" };
+
        }
      }
    } else if (resource === "patches") {
      const status = searchParams.get("status");