Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Add support for submodules
Merged did:key:z6MkkfM3...sVz5 opened 2 years ago
22 files changed +157 -71 953643c0 5b30269c
modified httpd-client/lib/project.ts
@@ -96,7 +96,8 @@ export type Blob = z.infer<typeof blobSchema>;
const treeEntrySchema = object({
  path: string(),
  name: string(),
-
  kind: union([literal("blob"), literal("tree")]),
+
  oid: string(),
+
  kind: union([literal("blob"), literal("tree"), literal("submodule")]),
});

export type TreeEntry = z.infer<typeof treeEntrySchema>;
modified httpd-client/lib/project/comment.ts
@@ -1,24 +1,25 @@
import type { z } from "zod";
import { array, boolean, number, object, string } from "zod";
-
import { codeLocationSchema } from "../shared";
+
import { authorSchema, codeLocationSchema } from "../shared";

export type Comment = z.infer<typeof commentSchema>;
-
export type Embed = z.infer<typeof commentSchema>["embeds"][0];
+
export type Embed = Comment["embeds"][0];
+
export type Reaction = Comment["reactions"][0];

export const commentSchema = object({
  id: string(),
-
  author: object({ id: string(), alias: string().optional() }),
+
  author: authorSchema,
  body: string(),
  edits: array(
    object({
-
      author: object({ id: string(), alias: string().optional() }),
+
      author: authorSchema,
      body: string(),
      embeds: array(object({ name: string(), content: string() })),
      timestamp: number(),
    }),
  ),
  embeds: array(object({ name: string(), content: string() })),
-
  reactions: array(object({ emoji: string(), authors: array(string()) })),
+
  reactions: array(object({ emoji: string(), authors: array(authorSchema) })),
  timestamp: number(),
  location: codeLocationSchema.nullable().optional(),
  resolved: boolean(),
modified httpd-client/lib/project/issue.ts
@@ -3,6 +3,7 @@ import type { ZodSchema, z } from "zod";
import { array, boolean, literal, object, string, union } from "zod";

import { commentSchema } from "./comment.js";
+
import { authorSchema } from "../shared.js";

export type IssueState =
  | { status: "open" }
@@ -18,12 +19,12 @@ const issueStateSchema = union([

export const issueSchema = object({
  id: string(),
-
  author: object({ id: string(), alias: string().optional() }),
+
  author: authorSchema,
  title: string(),
  state: issueStateSchema,
  discussion: array(commentSchema),
  labels: array(string()),
-
  assignees: array(string()),
+
  assignees: array(authorSchema),
});

export type Issue = z.infer<typeof issueSchema>;
modified httpd-client/lib/project/patch.ts
@@ -15,13 +15,9 @@ import {
  tuple,
  union,
} from "zod";
-
import { codeLocationSchema } from "../shared.js";
+
import { authorSchema, codeLocationSchema } from "../shared.js";

-
export type PatchState =
-
  | { status: "draft" }
-
  | { status: "open"; conflicts?: [string, string][] }
-
  | { status: "archived" }
-
  | { status: "merged"; revision: string; commit: string };
+
export type PatchState = z.infer<typeof patchStateSchema>;

const patchStateSchema = union([
  object({
@@ -39,26 +35,21 @@ const patchStateSchema = union([
    revision: string(),
    commit: string(),
  }),
-
]) satisfies ZodSchema<PatchState>;
+
]);

-
export interface Merge {
-
  author: { id: string; alias?: string };
-
  revision: string;
-
  commit: string;
-
  timestamp: number;
-
}
+
export type Merge = z.infer<typeof mergeSchema>;

const mergeSchema = object({
-
  author: object({ id: string(), alias: string().optional() }),
+
  author: authorSchema,
  revision: string(),
  commit: string(),
  timestamp: number(),
-
}) satisfies ZodSchema<Merge>;
+
});

export type Verdict = "accept" | "reject";

const reviewSchema = object({
-
  author: object({ id: string(), alias: string().optional() }),
+
  author: authorSchema,
  verdict: optional(union([literal("accept"), literal("reject")]).nullable()),
  comments: array(commentSchema),
  summary: string().nullable(),
@@ -69,11 +60,11 @@ export type Review = z.infer<typeof reviewSchema>;

const revisionSchema = object({
  id: string(),
-
  author: object({ id: string(), alias: string().optional() }),
+
  author: authorSchema,
  description: string(),
  edits: array(
    object({
-
      author: object({ id: string(), alias: string().optional() }),
+
      author: authorSchema,
      body: string(),
      embeds: array(object({ name: string(), content: string() })),
      timestamp: number(),
@@ -83,7 +74,7 @@ const revisionSchema = object({
    object({
      emoji: string(),
      location: codeLocationSchema.nullable(),
-
      authors: array(string()),
+
      authors: array(authorSchema),
    }),
  ),
  base: string(),
@@ -98,7 +89,7 @@ export type Revision = z.infer<typeof revisionSchema>;

export const patchSchema = object({
  id: string(),
-
  author: object({ id: string(), alias: string().optional() }),
+
  author: authorSchema,
  title: string(),
  state: patchStateSchema,
  target: string(),
modified httpd-client/lib/shared.ts
@@ -70,3 +70,8 @@ export const codeLocationSchema = object({
});

export type CodeLocation = z.infer<typeof codeLocationSchema>;
+

+
export const authorSchema = object({
+
  id: string(),
+
  alias: string().optional(),
+
});
modified src/components/Comment.svelte
@@ -34,7 +34,10 @@
    | ((body: string, embeds: Embed[]) => Promise<void>)
    | undefined = undefined;
  export let reactOnComment:
-
    | ((nids: string[], reaction: string) => Promise<void>)
+
    | ((
+
        authors: Comment["reactions"][0]["authors"],
+
        reaction: string,
+
      ) => Promise<void>)
    | undefined = undefined;
</script>

modified src/components/ReactionSelector.svelte
@@ -12,7 +12,7 @@
  export let reactions: Comment["reactions"] | undefined = undefined;

  const dispatch = createEventDispatcher<{
-
    select: { emoji: string; authors: string[] };
+
    select: Comment["reactions"][0];
  }>();
</script>

modified src/components/Reactions.svelte
@@ -5,7 +5,10 @@

  export let reactions: Comment["reactions"];
  export let handleReaction:
-
    | ((authors: string[], reaction: string) => Promise<void>)
+
    | ((
+
        authors: Comment["reactions"][0]["authors"],
+
        reaction: string,
+
      ) => Promise<void>)
    | undefined;
</script>

modified src/components/Thread.svelte
@@ -25,7 +25,11 @@
    | ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
    | undefined;
  export let reactOnComment:
-
    | ((commentId: string, nids: string[], reaction: string) => Promise<void>)
+
    | ((
+
        commentId: string,
+
        authors: Comment["reactions"][0]["authors"],
+
        reaction: string,
+
      ) => Promise<void>)
    | undefined;

  async function toggleReply() {
modified src/views/projects/Cob/AssigneeInput.svelte
@@ -1,4 +1,6 @@
<script lang="ts" strictEvents>
+
  import type { Reaction } from "@httpd-client/lib/project/comment";
+

  import { createEventDispatcher } from "svelte";

  import { formatNodeId, parseNodeId } from "@app/lib/utils";
@@ -9,14 +11,16 @@
  import IconSmall from "@app/components/IconSmall.svelte";
  import TextInput from "@app/components/TextInput.svelte";

-
  const dispatch = createEventDispatcher<{ save: string[] }>();
+
  const dispatch = createEventDispatcher<{
+
    save: Reaction["authors"];
+
  }>();

  export let locallyAuthenticated: boolean = false;
-
  export let assignees: string[] = [];
+
  export let assignees: Reaction["authors"] = [];
  export let submitInProgress: boolean = false;

  let showInput: boolean = false;
-
  let updatedAssignees: string[] = assignees;
+
  let updatedAssignees: Reaction["authors"] = assignees;
  let inputValue = "";
  let validationMessage: string | undefined = undefined;
  let valid: boolean = false;
@@ -29,7 +33,7 @@
      const parsedNodeId = parseNodeId(inputValue);
      if (parsedNodeId) {
        assignee = `${parsedNodeId.prefix}${parsedNodeId.pubkey}`;
-
        if (updatedAssignees.includes(assignee)) {
+
        if (Boolean(updatedAssignees.find(({ id }) => id === assignee))) {
          valid = false;
          validationMessage = "This assignee is already added";
        } else {
@@ -48,7 +52,7 @@

  function addAssignee() {
    if (valid && assignee) {
-
      updatedAssignees = [...updatedAssignees, assignee];
+
      updatedAssignees = [...updatedAssignees, { id: assignee }];
      inputValue = "";
      dispatch("save", updatedAssignees);
      showInput = false;
@@ -56,7 +60,7 @@
  }

  function removeAssignee(assignee: string) {
-
    updatedAssignees = updatedAssignees.filter(x => x !== assignee);
+
    updatedAssignees = updatedAssignees.filter(({ id }) => id !== assignee);
    dispatch("save", updatedAssignees);
    showInput = false;
  }
@@ -98,15 +102,16 @@
          variant="neutral"
          size="small"
          style="cursor: pointer;"
-
          on:click={() => (removeToggles[assignee] = !removeToggles[assignee])}>
+
          on:click={() =>
+
            (removeToggles[assignee.id] = !removeToggles[assignee.id])}>
          <div class="assignee">
-
            <Avatar inline nodeId={assignee} />
-
            <span>{formatNodeId(assignee)}</span>
-
            {#if removeToggles[assignee]}
+
            <Avatar inline nodeId={assignee.id} />
+
            <span>{formatNodeId(assignee.id)}</span>
+
            {#if removeToggles[assignee.id]}
              <IconButton title="remove assignee">
                <IconSmall
                  name="cross"
-
                  on:click={() => removeAssignee(assignee)} />
+
                  on:click={() => removeAssignee(assignee.id)} />
              </IconButton>
            {/if}
          </div>
@@ -153,8 +158,8 @@
      {#each updatedAssignees as assignee}
        <Badge variant="neutral" size="small">
          <div class="assignee">
-
            <Avatar inline nodeId={assignee} />
-
            <span>{formatNodeId(assignee)}</span>
+
            <Avatar inline nodeId={assignee.id} />
+
            <span>{formatNodeId(assignee.id)}</span>
          </div>
        </Badge>
      {:else}
modified src/views/projects/Cob/Revision.svelte
@@ -63,12 +63,15 @@
    | ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
    | undefined;
  export let reactOnRevision:
-
    | ((authors: string[], reaction: string) => Promise<void>)
+
    | ((
+
        authors: Comment["reactions"][0]["authors"],
+
        reaction: string,
+
      ) => Promise<void>)
    | undefined;
  export let reactOnComment:
    | ((
        commentId: string,
-
        authors: string[],
+
        authors: Comment["reactions"][0]["authors"],
        reaction: string,
      ) => Promise<void>)
    | undefined;
modified src/views/projects/Issue.svelte
@@ -1,9 +1,11 @@
<script lang="ts">
+
  import type { Reaction } from "@httpd-client/lib/project/comment";
  import type {
    BaseUrl,
+
    Comment,
+
    Embed,
    Issue,
    IssueState,
-
    Embed,
    Project,
  } from "@httpd-client";
  import type { Session } from "@app/lib/httpd";
@@ -165,9 +167,10 @@
  async function reactOnComment(
    session: Session,
    commentId: string,
-
    nids: string[],
+
    authors: Comment["reactions"][0]["authors"],
    reaction: string,
  ) {
+
    console.log(session.publicKey, authors);
    try {
      await api.project.updateIssue(
        project.id,
@@ -176,7 +179,11 @@
          type: "comment.react",
          id: commentId,
          reaction,
-
          active: nids.includes(session.publicKey) ? false : true,
+
          active: !Boolean(
+
            authors.find(
+
              ({ id }) => utils.parseNodeId(id)?.pubkey === session.publicKey,
+
            ),
+
          ),
        },
        session.id,
      );
@@ -274,12 +281,15 @@
    }
  }

-
  async function saveAssignees(sessionId: string, assignees: string[]) {
+
  async function saveAssignees(
+
    sessionId: string,
+
    assignees: Reaction["authors"],
+
  ) {
    try {
      await api.project.updateIssue(
        project.id,
        issue.id,
-
        { type: "assign", assignees },
+
        { type: "assign", assignees: assignees.map(({ id }) => id) },
        sessionId,
      );
    } catch (error) {
modified src/views/projects/Patch.svelte
@@ -207,7 +207,7 @@
    session: Session,
    revisionId: string,
    commentId: string,
-
    authors: string[],
+
    authors: Comment["reactions"][0]["authors"],
    reaction: string,
  ) {
    try {
@@ -219,7 +219,7 @@
          revision: revisionId,
          comment: commentId,
          reaction,
-
          active: authors.includes(session.publicKey) ? false : true,
+
          active: Boolean(authors.find(({ id }) => id === session.publicKey)),
        },
        session.id,
      );
@@ -366,7 +366,7 @@
  async function reactOnRevision(
    session: Session,
    revisionId: string,
-
    authors: string[],
+
    authors: Revision["reactions"][0]["authors"],
    reaction: string,
  ) {
    try {
@@ -377,7 +377,7 @@
          type: "revision.react",
          revision: revisionId,
          reaction,
-
          active: authors.includes(session.publicKey) ? false : true,
+
          active: Boolean(authors.find(({ id }) => id === session.publicKey)),
        },
        session.id,
      );
modified src/views/projects/Source/Tree.svelte
@@ -6,6 +6,7 @@
  import File from "./Tree/File.svelte";
  import Folder from "./Tree/Folder.svelte";
  import Link from "@app/components/Link.svelte";
+
  import Submodule from "./Tree/Submodule.svelte";

  export let baseUrl: BaseUrl;
  export let fetchTree: (path: string) => Promise<Tree | undefined>;
@@ -33,6 +34,8 @@
      {peer}
      {projectId}
      {revision} />
+
  {:else if entry.kind === "submodule"}
+
    <Submodule name={entry.name} oid={entry.oid} />
  {:else}
    <Link
      route={{
added src/views/projects/Source/Tree/Submodule.svelte
@@ -0,0 +1,43 @@
+
<script lang="ts">
+
  import IconSmall from "@app/components/IconSmall.svelte";
+
  import { formatCommit } from "@app/lib/utils";
+

+
  export let name: string;
+
  export let oid: string;
+
</script>
+

+
<style>
+
  .file {
+
    border-radius: var(--border-radius-tiny);
+
    display: flex;
+
    line-height: 1.5em;
+
    margin: 0.25rem 0;
+
    padding: 0.25rem 0.875rem;
+
    width: 100%;
+
  }
+

+
  .name {
+
    margin-left: 0.25rem;
+
    user-select: none;
+
    white-space: nowrap;
+
    text-overflow: ellipsis !important;
+
    color: var(--color-foreground-dim);
+
    overflow: hidden;
+
    font-size: var(--font-size-small);
+
    font-weight: var(--font-weight-medium);
+
  }
+
  .icon-container {
+
    color: var(--color-fill-secondary);
+
    display: flex;
+
    justify-content: center;
+
    align-items: center;
+
    margin-right: 0.125rem;
+
  }
+
</style>
+

+
<div class="file">
+
  <div class="icon-container">
+
    <IconSmall name="link" />
+
  </div>
+
  <span class="name">{name} @ {formatCommit(oid)}</span>
+
</div>
modified tests/e2e/project.spec.ts
@@ -151,6 +151,11 @@ test("navigate deep file hierarchies", async ({ page }) => {
  }
});

+
test("submodules", async ({ page }) => {
+
  await page.goto(sourceBrowsingUrl);
+
  await expect(page.getByText("rips @ 329dee9")).toBeVisible();
+
});
+

test("files with special characters in the filename", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);

modified tests/e2e/project/commit.spec.ts
@@ -13,7 +13,7 @@ test("navigation from commit list", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
  await page.getByTitle("Change peer").click();
  await page.getByRole("link", { name: "bob" }).click();
-
  await page.getByRole("link", { name: "Commits 7" }).click();
+
  await page.getByRole("link", { name: "Commits 8" }).click();

  await page.getByText("Update readme").click();
  await expect(page).toHaveURL(commitUrl);
@@ -30,7 +30,9 @@ test("relative timestamps", async ({ page }) => {
    };
  });
  await page.goto(commitUrl);
-
  await expect(page.getByText("Bob Belcher committed 28f3710")).toBeVisible();
+
  await expect(
+
    page.getByText(`Bob Belcher committed ${shortBobHead}`),
+
  ).toBeVisible();
});

test("modified file", async ({ page }) => {
modified tests/e2e/project/commits.spec.ts
@@ -2,6 +2,7 @@ import {
  aliceMainHead,
  expect,
  gitOptions,
+
  shortBobHead,
  sourceBrowsingUrl,
  test,
} from "@tests/support/fixtures.js";
@@ -9,7 +10,7 @@ import { createProject } from "@tests/support/project";

test("peer and branch switching", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
-
  await page.getByRole("link", { name: "Commits 6" }).click();
+
  await page.getByRole("link", { name: "Commits 7" }).click();

  // Alice's peer.
  {
@@ -23,10 +24,10 @@ test("peer and branch switching", async ({ page }) => {
    await expect(page.getByTitle("Change peer")).toHaveText("alice Delegate");

    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
-
    await expect(page.locator(".list .teaser")).toHaveCount(6);
+
    await expect(page.locator(".list .teaser")).toHaveCount(7);

    const latestCommit = page.locator(".teaser").first();
-
    await expect(latestCommit).toContainText("Add README.md");
+
    await expect(latestCommit).toContainText("Add submodule");
    await expect(latestCommit).toContainText(aliceMainHead.substring(0, 7));

    const earliestCommit = page.locator(".teaser").last();
@@ -68,12 +69,12 @@ test("peer and branch switching", async ({ page }) => {

    await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
    await expect(page.locator(".list").last().locator(".teaser")).toHaveCount(
-
      6,
+
      7,
    );

    const latestCommit = page.locator(".teaser").first();
    await expect(latestCommit).toContainText("Update readme");
-
    await expect(latestCommit).toContainText("28f3710");
+
    await expect(latestCommit).toContainText(shortBobHead);

    const earliestCommit = page.locator(".teaser").last();
    await expect(earliestCommit).toContainText(
@@ -85,7 +86,7 @@ test("peer and branch switching", async ({ page }) => {

test("expand commit message", async ({ page }) => {
  await page.goto(sourceBrowsingUrl);
-
  await page.getByRole("link", { name: "Commits 6" }).click();
+
  await page.getByRole("link", { name: "Commits 7" }).click();
  const commitToggle = page.getByRole("button", { name: "expand" }).first();

  await commitToggle.click();
@@ -111,14 +112,16 @@ test("relative timestamps", async ({ page }) => {
  });

  await page.goto(sourceBrowsingUrl);
-
  await page.getByRole("link", { name: "Commits 6" }).click();
+
  await page.getByRole("link", { name: "Commits 7" }).click();

  await page.getByTitle("Change peer").click();
  await page.getByRole("link", { name: "bob" }).click();
  await expect(page.getByTitle("Change peer")).toHaveText("bob");
  const latestCommit = page.locator(".teaser").first();
-
  await expect(latestCommit).toContainText("Bob Belcher committed 28f3710 now");
-
  await expect(latestCommit).toContainText("28f3710");
+
  await expect(latestCommit).toContainText(
+
    `Bob Belcher committed ${shortBobHead} now`,
+
  );
+
  await expect(latestCommit).toContainText(shortBobHead);
  const earliestCommit = page.locator(".teaser").last();
  await expect(earliestCommit).toContainText(
    "Alice Liddell committed 36d5bbe last month",
modified tests/fixtures/repos/source-browsing.tar.bz2
modified tests/support/fixtures.ts
@@ -13,6 +13,7 @@ import { createOptions, supportDir, tmpDir } from "@tests/support/support.js";
import { createPeerManager } from "@tests/support/peerManager.js";
import { createProject } from "@tests/support/project.js";
import type { PeerManager, RadiclePeer } from "./peerManager.js";
+
import { formatCommit } from "@app/lib/utils.js";

export { expect };

@@ -161,6 +162,8 @@ export const test = base.extend<{
    const { stdout } = await peer.spawn("rad-web", [
      "http://localhost:3001",
      "--no-open",
+
      "--path",
+
      "/",
      "--connect",
      `${peer.httpdBaseUrl.hostname}:${peer.httpdBaseUrl.port}`,
    ]);
@@ -647,13 +650,13 @@ export async function createMarkdownFixture(peer: RadiclePeer) {
  );
}

-
export const aliceMainHead = "dd068e9aff9a569e597f6abaf84f120dd0cbbd70";
+
export const aliceMainHead = "4a9f278344b795afaef44c0d9effb35c4d794fba";
export const aliceRemote =
  "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK";
export const bobRemote =
  "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5";
-
export const bobHead = "28f37105bb78db48111e36281291ff253dd050e8";
-
export const shortBobHead = "28f3710";
+
export const bobHead = "ff32f18dc09d82bff329ffcd96fd5eba2d7f826c";
+
export const shortBobHead = formatCommit(bobHead);
export const sourceBrowsingRid = "rad:z4BwwjPCFNVP27FwVbDFgwVwkjcir";
export const cobRid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe";
export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg";
modified tests/support/heartwood-version
@@ -1 +1 @@
-
a48081f2717f069d456ec09f31d9e639b232dbed
+
570a7eb141b6ba001713c46345d79b6fead1ca15
deleted tests/tmp/.gitkeep