Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Refactor issue thread compute to the backend
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago
15 files changed +216 -45 93e2770d 376e475a
modified crates/radicle-tauri/src/commands/cob/issue.rs
@@ -47,3 +47,13 @@ pub(crate) fn issue_by_id(
) -> Result<Option<types::cobs::issue::Issue>, Error> {
    ctx.issue_by_id(rid, id).map_err(Error::from)
}
+

+
#[tauri::command]
+
pub(crate) fn comment_threads_by_issue_id(
+
    ctx: tauri::State<AppState>,
+
    rid: identity::RepoId,
+
    id: git::Oid,
+
) -> Result<Option<Vec<types::cobs::thread::Thread>>, Error> {
+
    ctx.comment_threads_by_issue_id(rid, id)
+
        .map_err(Error::from)
+
}
modified crates/radicle-tauri/src/lib.rs
@@ -102,6 +102,7 @@ pub fn run() {
            cob::save_embed,
            cob::issue::list_issues,
            cob::issue::issue_by_id,
+
            cob::issue::comment_threads_by_issue_id,
            cob::issue::create_issue,
            cob::issue::edit_issue,
            cob::patch::list_patches,
modified crates/radicle-types/bindings/cob/issue/Issue.ts
@@ -10,7 +10,8 @@ export type Issue = {
  title: string;
  state: State;
  assignees: Array<Author>;
-
  discussion: Array<Comment<Never>>;
+
  body: Comment<Never>;
+
  commentCount: number;
  labels: Array<string>;
  timestamp: number;
};
added crates/radicle-types/bindings/cob/thread/Thread.ts
@@ -0,0 +1,8 @@
+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
import type { Comment } from "./Comment";
+
import type { Never } from "../Never";
+

+
export type Thread<T = Never> = {
+
  root: Comment<T>;
+
  replies: Array<Comment<T>>;
+
};
modified crates/radicle-types/src/cobs/issue.rs
@@ -21,7 +21,9 @@ pub struct Issue {
    title: String,
    state: cobs::issue::State,
    assignees: Vec<cobs::Author>,
-
    discussion: Vec<cobs::thread::Comment<cobs::Never>>,
+
    body: cobs::thread::Comment,
+
    #[ts(type = "number")]
+
    comment_count: usize,
    #[ts(as = "Vec<String>")]
    labels: Vec<cob::Label>,
    #[ts(type = "number")]
@@ -30,6 +32,8 @@ pub struct Issue {

impl Issue {
    pub fn new(id: &issue::IssueId, issue: &issue::Issue, aliases: &impl AliasStore) -> Self {
+
        let (root_oid, root_comment) = issue.root();
+

        Self {
            id: id.to_string(),
            author: cobs::Author::new(*issue.author().id(), aliases),
@@ -39,10 +43,12 @@ impl Issue {
                .assignees()
                .map(|did| cobs::Author::new(*did, aliases))
                .collect::<Vec<_>>(),
-
            discussion: issue
-
                .comments()
-
                .map(|(id, c)| cobs::thread::Comment::<cobs::Never>::new(*id, c.clone(), aliases))
-
                .collect::<Vec<_>>(),
+
            body: cobs::thread::Comment::<cobs::Never>::new(
+
                *root_oid,
+
                root_comment.clone(),
+
                aliases,
+
            ),
+
            comment_count: issue.replies().count(),
            labels: issue.labels().cloned().collect::<Vec<_>>(),
            timestamp: issue.timestamp(),
        }
modified crates/radicle-types/src/cobs/thread.rs
@@ -26,6 +26,15 @@ pub struct CreateReviewComment {
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[ts(export_to = "cob/thread/")]
+
pub struct Thread<T = cobs::Never> {
+
    pub root: Comment<T>,
+
    pub replies: Vec<Comment<T>>,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/thread/")]
pub struct Comment<T = cobs::Never> {
    #[ts(as = "String")]
    id: cob::thread::CommentId,
modified crates/radicle-types/src/traits/issue.rs
@@ -50,6 +50,45 @@ pub trait Issues: Profile {

        Ok::<_, Error>(issue)
    }
+

+
    fn comment_threads_by_issue_id(
+
        &self,
+
        rid: identity::RepoId,
+
        id: git::Oid,
+
    ) -> Result<Option<Vec<cobs::thread::Thread>>, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+
        let issues = profile.issues(&repo)?;
+
        let issue = issues.get(&id.into())?;
+

+
        let aliases = &profile.aliases();
+
        let comments = issue.map(|issue| {
+
            issue
+
                .replies()
+
                // Filter out replies that aren't top level replies
+
                .filter(|c| {
+
                    let Some(oid) = c.1.reply_to() else {
+
                        return false;
+
                    };
+

+
                    oid == id
+
                })
+
                .map(|(oid, c)| {
+
                    let root = cobs::thread::Comment::<cobs::Never>::new(*oid, c.clone(), aliases);
+
                    let replies = issue
+
                        .replies_to(oid)
+
                        .map(|(oid, c)| {
+
                            cobs::thread::Comment::<cobs::Never>::new(*oid, c.clone(), aliases)
+
                        })
+
                        .collect::<Vec<_>>();
+

+
                    cobs::thread::Thread { root, replies }
+
                })
+
                .collect::<Vec<_>>()
+
        });
+

+
        Ok::<_, Error>(comments)
+
    }
}

pub trait IssuesMut: Profile {
modified crates/test-http-api/src/api.rs
@@ -64,6 +64,7 @@ pub fn router(ctx: Context) -> Router {
        .route("/create_issue_comment", post(create_issue_comment_handler))
        .route("/edit_issue", post(edit_issue_handler))
        .route("/issue_by_id", post(issue_handler))
+
        .route("/comment_threads_by_issue_id", post(issue_threads_handler))
        .route("/list_patches", post(patches_handler))
        .route("/patch_by_id", post(patch_handler))
        .route("/revisions_by_patch", post(revision_handler))
@@ -275,6 +276,15 @@ async fn issue_handler(
    Ok::<_, Error>(Json(issue))
}

+
async fn issue_threads_handler(
+
    State(ctx): State<Context>,
+
    Json(IssueBody { rid, id }): Json<IssueBody>,
+
) -> impl IntoResponse {
+
    let issue_threads = ctx.comment_threads_by_issue_id(rid, id)?;
+

+
    Ok::<_, Error>(Json(issue_threads))
+
}
+

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PatchesBody {
modified src/components/Icon.svelte
@@ -66,6 +66,7 @@
  style:cursor={styleCursor}
  role="img"
  {onclick}
+
  aria-label={`icon-${name}`}
  width={size}
  height={size}
  fill="currentColor"
modified src/components/IssueTeaser.svelte
@@ -85,10 +85,10 @@
      <div class="global-counter txt-small">{label}</div>
    {/each}

-
    {#if issue.discussion.length > 1}
+
    {#if issue.commentCount > 0}
      <div class="txt-small global-flex" style:gap="0.25rem">
        <Icon name="comment" />
-
        {issue.discussion.length - 1}
+
        {issue.commentCount}
      </div>
    {/if}
  </div>
modified src/views/repo/Issue.svelte
@@ -5,6 +5,7 @@
  import type { Issue } from "@bindings/cob/issue/Issue";
  import type { Operation } from "@bindings/cob/issue/Operation";
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+
  import type { Thread } from "@bindings/cob/thread/Thread";

  import partial from "lodash/partial";
  import { tick } from "svelte";
@@ -31,7 +32,7 @@
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import TextInput from "@app/components/TextInput.svelte";
-
  import Thread from "@app/components/Thread.svelte";
+
  import ThreadComponent from "@app/components/Thread.svelte";

  import Layout from "./Layout.svelte";

@@ -41,6 +42,7 @@
    issues: Issue[];
    activity: Operation[];
    config: Config;
+
    threads: Thread[];
  }

  /* eslint-disable prefer-const */
@@ -50,6 +52,7 @@
    issues: initialIssues,
    activity,
    config,
+
    threads,
  }: Props = $props();
  /* eslint-enable prefer-const */

@@ -73,23 +76,6 @@
  });

  const project = $derived(repo.payloads["xyz.radicle.project"]!);
-
  const issueBody = $derived(issue.discussion[0]);
-
  const threads = $derived(
-
    issue.discussion
-
      .filter(
-
        comment =>
-
          (comment.id !== issueBody.id && !comment.replyTo) ||
-
          comment.replyTo === issueBody.id,
-
      )
-
      .map(thread => {
-
        return {
-
          root: thread,
-
          replies: issue.discussion
-
            .filter(comment => comment.replyTo === thread.id)
-
            .sort((a, b) => a.edits[0].timestamp - b.edits[0].timestamp),
-
        };
-
      }, []),
-
  );

  async function toggleReply() {
    topLevelReplyOpen = !topLevelReplyOpen;
@@ -105,7 +91,7 @@
  }

  async function reload() {
-
    [issue, activity] = await Promise.all([
+
    [issue, activity, threads] = await Promise.all([
      invoke<Issue>("issue_by_id", {
        rid: repo.rid,
        id: issue.id,
@@ -115,7 +101,14 @@
        typeName: "xyz.radicle.issue",
        id: issue.id,
      }),
+
      invoke<Thread[]>("comment_threads_by_issue_id", {
+
        rid: repo.rid,
+
        id: issue.id,
+
      }),
    ]);
+

+
    topLevelReplyOpen = false;
+
    editingTitle = false;
  }

  async function createComment(body: string, embeds: Embed[]) {
@@ -410,7 +403,7 @@
      {:else}
        <div class="title">
          <InlineTitle content={issue.title} fontSize="medium" />
-
          {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), issueBody.author.did, )}
+
          {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), issue.body.author.did, )}
            <div class="title-icons">
              <Icon
                styleCursor="pointer"
@@ -429,22 +422,22 @@
      <CommentComponent
        rid={repo.rid}
        id={issue.id}
-
        lastEdit={issueBody.edits.length > 1
-
          ? issueBody.edits.at(-1)
+
        lastEdit={issue.body.edits.length > 1
+
          ? issue.body.edits.at(-1)
          : undefined}
-
        author={issueBody.author}
-
        reactions={issueBody.reactions}
-
        timestamp={issueBody.edits.slice(-1)[0].timestamp}
-
        body={issueBody.edits.slice(-1)[0].body}
+
        author={issue.body.author}
+
        reactions={issue.body.reactions}
+
        timestamp={issue.body.edits.slice(-1)[0].timestamp}
+
        body={issue.body.edits.slice(-1)[0].body}
        editComment={roles.isDelegateOrAuthor(
          config.publicKey,
          repo.delegates.map(delegate => delegate.did),
-
          issueBody.author.did,
-
        ) && partial(editComment, issueBody.id)}
+
          issue.body.author.did,
+
        ) && partial(editComment, issue.body.id)}
        reactOnComment={partial(
          reactOnComment,
          config.publicKey,
-
          issueBody.id,
+
          issue.body.id,
        )}>
        {#snippet actions()}
          <Icon styleCursor="pointer" name="reply" onclick={toggleReply} />
@@ -461,7 +454,7 @@
        {:else if op.type === "comment"}
          {@const thread = threads.find(t => t.root.id === op.entryId)}
          {#if thread}
-
            <Thread
+
            <ThreadComponent
              {thread}
              rid={repo.rid}
              canEditComment={partial(
modified src/views/repo/router.ts
@@ -1,4 +1,5 @@
import type { Config } from "@bindings/config/Config";
+
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Operation } from "@bindings/cob/issue/Operation";
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
@@ -30,6 +31,7 @@ export interface LoadedRepoIssueRoute {
    issue: Issue;
    issues: Issue[];
    activity: Operation[];
+
    threads: Thread[];
  };
}

@@ -176,7 +178,7 @@ export async function loadCreateIssue(
export async function loadIssue(
  route: RepoIssueRoute,
): Promise<LoadedRepoIssueRoute> {
-
  const [config, repo, issue, activity, issues] = await Promise.all([
+
  const [config, repo, issue, activity, issues, threads] = await Promise.all([
    invoke<Config>("config"),
    invoke<RepoInfo>("repo_by_id", {
      rid: route.rid,
@@ -194,11 +196,15 @@ export async function loadIssue(
      rid: route.rid,
      status: "all",
    }),
+
    invoke<Thread[]>("comment_threads_by_issue_id", {
+
      rid: route.rid,
+
      id: route.issue,
+
    }),
  ]);

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

modified tests/e2e/repo/issue.spec.ts
@@ -6,3 +6,86 @@ test("navigate single issue", async ({ page }) => {

  await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/);
});
+

+
test("correct order of threads", async ({ page }) => {
+
  await page.goto("/repos");
+
  await page.getByRole("button", { name: "cobs" }).click();
+
  await page
+
    .getByRole("button", { name: "This title has **markdown**" })
+
    .click();
+
  const body = page.locator(".issue-body");
+
  await expect(body.getByText("This is a description")).toBeVisible();
+

+
  const topLevelComments = await page.locator(".comments").all();
+
  expect(topLevelComments).toHaveLength(2);
+

+
  const [first, second] = topLevelComments;
+
  await expect(first.getByText("This is a multiline comment")).toBeVisible();
+
  await expect(
+
    first.getByText("This is a reply, to a first comment"),
+
  ).toBeVisible();
+
  await expect(
+
    second.getByText("A root level comment after a reply, for margins sake."),
+
  ).toBeVisible();
+
});
+

+
test("creation of top level comments", async ({ page }) => {
+
  await page.goto("/repos");
+
  await page.getByRole("button", { name: "cobs" }).click();
+
  await page.getByRole("button", { name: "New" }).click();
+
  await page
+
    .getByPlaceholder("Title")
+
    .fill("Make sure that comment creation is working");
+
  await page
+
    .getByPlaceholder("Description")
+
    .fill(
+
      "It's important for us that the comment creation flow works as expected.",
+
    );
+
  await page.getByRole("button", { name: "Save" }).click();
+
  await expect(
+
    page.getByText("Make sure that comment creation is working").last(),
+
  ).toBeVisible();
+
  await expect(
+
    page.getByRole("link", { name: "icon-issue Make sure that" }),
+
  ).toBeVisible();
+
  await expect(
+
    page
+
      .getByText(
+
        "It's important for us that the comment creation flow works as expected.",
+
      )
+
      .last(),
+
  ).toBeVisible();
+

+
  await page.getByRole("button", { name: "Leave a comment" }).click();
+
  await page
+
    .getByPlaceholder("Leave a comment")
+
    .fill("A top level comment by playwright");
+
  await page.getByRole("button", { name: "icon-checkmark Comment" }).click();
+
  await expect(
+
    page.getByText("A top level comment by playwright"),
+
  ).toBeVisible();
+

+
  await page.getByLabel("icon-reply").first().click();
+
  await page
+
    .getByPlaceholder("Leave a comment")
+
    .fill(
+
      "A top level comment by playwright created by replying to the issue body",
+
    );
+
  await page.getByRole("button", { name: "icon-checkmark Comment" }).click();
+
  await expect(
+
    page.getByText(
+
      "A top level comment by playwright created by replying to the issue body",
+
    ),
+
  ).toBeVisible();
+

+
  await page.getByLabel("icon-reply").nth(1).click();
+
  await page
+
    .getByPlaceholder("Reply to comment")
+
    .fill("A reply comment by playwright replying to the first comment");
+
  await page.getByRole("button", { name: "icon-checkmark Reply" }).click();
+
  await expect(
+
    page.getByText(
+
      "A reply comment by playwright replying to the first comment",
+
    ),
+
  ).toBeVisible();
+
});
modified tests/e2e/repo/issues.spec.ts
@@ -2,8 +2,6 @@ import { test, cobRid, expect } from "@tests/support/fixtures.js";

test("navigate issues listing", async ({ page }) => {
  await page.goto(`/repos/${cobRid}/issues?show=all`);
-
  await expect(page.locator(".issue-teaser")).toHaveCount(3);
-

  await page.getByRole("link", { name: "Closed" }).click();
  await expect(page.locator(".issue-teaser")).toHaveCount(2);
  await expect(page).toHaveURL(`/repos/${cobRid}/issues?status=closed`);
modified tests/e2e/theme.spec.ts
@@ -11,7 +11,9 @@ test("theme persistence", async ({ page }) => {
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
  await page.getByRole("button", { name: "Settings" }).click();

-
  await page.getByRole("button", { name: "Light", exact: true }).click();
+
  await page
+
    .getByRole("button", { name: "icon-sun Light", exact: true })
+
    .click();
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");

  await page.reload();
@@ -24,9 +26,13 @@ test("change theme", async ({ page }) => {
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
  await page.getByRole("button", { name: "Settings" }).click();

-
  await page.getByRole("button", { name: "Light", exact: true }).click();
+
  await page
+
    .getByRole("button", { name: "icon-sun Light", exact: true })
+
    .click();
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");

-
  await page.getByRole("button", { name: "Dark", exact: true }).click();
+
  await page
+
    .getByRole("button", { name: "icon-moon Dark", exact: true })
+
    .click();
  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});