Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add revision commit list
Merged rudolfs opened 1 year ago
13 files changed +503 -182 f34522b6 8bfb0eb4
modified .github/workflows/check-unit-test.yml
@@ -8,7 +8,7 @@ jobs:
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
-
          node-version: "20.9.0"
+
          node-version: "22.11.0"
      - name: Checkout
        uses: actions/checkout@v4
      - run: npm ci
modified .github/workflows/check.yml
@@ -9,7 +9,7 @@ jobs:
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
-
          node-version: "20.9.0"
+
          node-version: "22.11.0"
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run check-js
modified crates/radicle-tauri/src/commands/repo.rs
@@ -42,9 +42,8 @@ pub async fn diff_stats(
pub async fn list_commits(
    ctx: tauri::State<'_, AppState>,
    rid: RepoId,
-
    parent: Option<String>,
-
    skip: Option<usize>,
-
    take: Option<usize>,
-
) -> Result<types::cobs::PaginatedQuery<Vec<types::repo::Commit>>, Error> {
-
    ctx.list_commits(rid, parent, skip, take)
+
    base: String,
+
    head: String,
+
) -> Result<Vec<types::repo::Commit>, Error> {
+
    ctx.list_commits(rid, base, head)
}
modified crates/radicle-types/src/traits/repo.rs
@@ -210,158 +210,26 @@ pub trait Repo: Profile {
    fn list_commits(
        &self,
        rid: identity::RepoId,
-
        parent: Option<String>,
-
        skip: Option<usize>,
-
        take: Option<usize>,
-
    ) -> Result<cobs::PaginatedQuery<Vec<repo::Commit>>, Error> {
+
        base: String,
+
        head: String,
+
    ) -> Result<Vec<repo::Commit>, Error> {
        let profile = self.profile();
-
        let cursor = skip.unwrap_or(0);
-
        let take = take.unwrap_or(20);
        let repo = profile.storage.repository(rid)?;

-
        let sha = match parent {
-
            Some(commit) => commit,
-
            None => repo.head()?.1.to_string(),
-
        };
-

        let repo = surf::Repository::open(repo.path())?;
-
        let history = repo.history(&sha)?;
-

-
        let mut commits = history
+
        let history = repo.history(&head)?;
+

+
        let commits = history
+
            .take_while(|c| {
+
                if let Ok(c) = c {
+
                    c.id.to_string() != base
+
                } else {
+
                    false
+
                }
+
            })
            .filter_map(|c| c.map(Into::into).ok())
-
            .skip(cursor)
-
            .take(take + 1); // Take one extra item to check if there's more.
-

-
        let paginated_commits: Vec<_> = commits.by_ref().take(take).collect();
-
        let more = commits.next().is_some();
-

-
        Ok::<_, Error>(cobs::PaginatedQuery {
-
            cursor,
-
            more,
-
            content: paginated_commits.to_vec(),
-
        })
-
    }
-
}
-

-
#[cfg(test)]
-
#[allow(clippy::unwrap_used)]
-
mod test {
-
    use std::str::FromStr;
-
    use std::vec;
-

-
    use radicle::crypto::test::signer::MockSigner;
-
    use radicle::{git, test};
-
    use radicle_surf::Author;
-

-
    use crate::cobs;
-
    use crate::repo;
-
    use crate::traits::repo::Repo;
-
    use crate::AppState;
-

-
    #[test]
-
    fn list_commits_pagination() {
-
        let signer = MockSigner::from_seed([0xff; 32]);
-
        let tempdir = tempfile::tempdir().unwrap();
-
        let profile = crate::test::profile(tempdir.path(), [0xff; 32]);
-
        let (rid, _, _, _) =
-
            test::fixtures::project(tempdir.path().join("original"), &profile.storage, &signer)
-
                .unwrap();
-
        let state = AppState { profile };
-
        let commits = Repo::list_commits(&state, rid, None, None, Some(1)).unwrap();
-

-
        assert_eq!(
-
            commits,
-
            cobs::PaginatedQuery {
-
                cursor: 0,
-
                more: true,
-
                content: vec![repo::Commit {
-
                    id: git::Oid::from_str("f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354").unwrap(),
-
                    author: Author {
-
                        name: "anonymous".to_string(),
-
                        email: "anonymous@radicle.xyz".to_string(),
-
                        time: radicle::git::raw::Time::new(1514817556, 0).into(),
-
                    },
-
                    committer: Author {
-
                        name: "anonymous".to_string(),
-
                        email: "anonymous@radicle.xyz".to_string(),
-
                        time: radicle::git::raw::Time::new(1514817556, 0).into(),
-
                    },
-
                    message: "Second commit".to_string(),
-
                    summary: "Second commit".to_string(),
-
                    parents: vec![
-
                        git::Oid::from_str("08c788dd1be6315de09e3fe09b5b1b7a2b8711d9").unwrap()
-
                    ],
-
                }],
-
            }
-
        );
-

-
        let commits = Repo::list_commits(&state, rid, None, Some(1), None).unwrap();
-

-
        assert_eq!(
-
            commits,
-
            cobs::PaginatedQuery {
-
                cursor: 1,
-
                more: false,
-
                content: vec![repo::Commit {
-
                    id: git::Oid::from_str("08c788dd1be6315de09e3fe09b5b1b7a2b8711d9").unwrap(),
-
                    author: Author {
-
                        name: "anonymous".to_string(),
-
                        email: "anonymous@radicle.xyz".to_string(),
-
                        time: radicle::git::raw::Time::new(1514817556, 0).into(),
-
                    },
-
                    committer: Author {
-
                        name: "anonymous".to_string(),
-
                        email: "anonymous@radicle.xyz".to_string(),
-
                        time: radicle::git::raw::Time::new(1514817556, 0).into(),
-
                    },
-
                    message: "Initial commit".to_string(),
-
                    summary: "Initial commit".to_string(),
-
                    parents: vec![],
-
                }],
-
            }
-
        );
-
    }
+
            .collect();

-
    #[test]
-
    fn list_commits_with_head() {
-
        let signer = MockSigner::from_seed([0xff; 32]);
-
        let tempdir = tempfile::tempdir().unwrap();
-
        let profile = crate::test::profile(tempdir.path(), [0xff; 32]);
-
        let (rid, _, _, _) =
-
            test::fixtures::project(tempdir.path().join("original"), &profile.storage, &signer)
-
                .unwrap();
-
        let state = AppState { profile };
-
        let commits = Repo::list_commits(
-
            &state,
-
            rid,
-
            Some("08c788dd1be6315de09e3fe09b5b1b7a2b8711d9".to_string()),
-
            None,
-
            None,
-
        )
-
        .unwrap();
-

-
        assert_eq!(
-
            commits,
-
            cobs::PaginatedQuery {
-
                cursor: 0,
-
                more: false,
-
                content: vec![repo::Commit {
-
                    id: git::Oid::from_str("08c788dd1be6315de09e3fe09b5b1b7a2b8711d9").unwrap(),
-
                    author: Author {
-
                        name: "anonymous".to_string(),
-
                        email: "anonymous@radicle.xyz".to_string(),
-
                        time: radicle::git::raw::Time::new(1514817556, 0).into(),
-
                    },
-
                    committer: Author {
-
                        name: "anonymous".to_string(),
-
                        email: "anonymous@radicle.xyz".to_string(),
-
                        time: radicle::git::raw::Time::new(1514817556, 0).into(),
-
                    },
-
                    message: "Initial commit".to_string(),
-
                    summary: "Initial commit".to_string(),
-
                    parents: vec![],
-
                }],
-
            }
-
        );
+
        Ok(commits)
    }
}
modified package-lock.json
@@ -25,6 +25,7 @@
        "@tauri-apps/cli": "^2.1.0",
        "@tsconfig/svelte": "^5.0.4",
        "@types/lodash": "^4.17.13",
+
        "@types/md5": "^2.3.5",
        "@types/node": "^22.10.2",
        "@types/wait-on": "^5.3.4",
        "@wooorm/starry-night": "^3.5.0",
@@ -46,6 +47,7 @@
        "marked-footnote": "^1.2.4",
        "marked-katex-extension": "^5.1.3",
        "marked-linkify-it": "^3.1.12",
+
        "md5": "^2.3.0",
        "prettier": "^3.4.2",
        "prettier-plugin-svelte": "^3.3.2",
        "svelte": "^5.14.0",
@@ -1373,6 +1375,13 @@
      "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==",
      "dev": true
    },
+
    "node_modules/@types/md5": {
+
      "version": "2.3.5",
+
      "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.5.tgz",
+
      "integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==",
+
      "dev": true,
+
      "license": "MIT"
+
    },
    "node_modules/@types/node": {
      "version": "22.10.2",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
@@ -1972,6 +1981,16 @@
        "url": "https://github.com/chalk/chalk?sponsor=1"
      }
    },
+
    "node_modules/charenc": {
+
      "version": "0.0.2",
+
      "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+
      "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
+
      "dev": true,
+
      "license": "BSD-3-Clause",
+
      "engines": {
+
        "node": "*"
+
      }
+
    },
    "node_modules/check-error": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -2056,6 +2075,16 @@
        "node": ">= 8"
      }
    },
+
    "node_modules/crypt": {
+
      "version": "0.0.2",
+
      "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+
      "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
+
      "dev": true,
+
      "license": "BSD-3-Clause",
+
      "engines": {
+
        "node": "*"
+
      }
+
    },
    "node_modules/cssesc": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2867,6 +2896,13 @@
        "node": ">=0.8.19"
      }
    },
+
    "node_modules/is-buffer": {
+
      "version": "1.1.6",
+
      "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+
      "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+
      "dev": true,
+
      "license": "MIT"
+
    },
    "node_modules/is-extendable": {
      "version": "0.1.1",
      "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
@@ -3197,6 +3233,18 @@
        "marked": ">=4 <16"
      }
    },
+
    "node_modules/md5": {
+
      "version": "2.3.0",
+
      "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
+
      "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
+
      "dev": true,
+
      "license": "BSD-3-Clause",
+
      "dependencies": {
+
        "charenc": "0.0.2",
+
        "crypt": "0.0.2",
+
        "is-buffer": "~1.1.6"
+
      }
+
    },
    "node_modules/merge2": {
      "version": "1.4.1",
      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
modified package.json
@@ -40,6 +40,7 @@
    "@tauri-apps/cli": "^2.1.0",
    "@tsconfig/svelte": "^5.0.4",
    "@types/lodash": "^4.17.13",
+
    "@types/md5": "^2.3.5",
    "@types/node": "^22.10.2",
    "@types/wait-on": "^5.3.4",
    "@wooorm/starry-night": "^3.5.0",
@@ -61,6 +62,7 @@
    "marked-footnote": "^1.2.4",
    "marked-katex-extension": "^5.1.3",
    "marked-linkify-it": "^3.1.12",
+
    "md5": "^2.3.0",
    "prettier": "^3.4.2",
    "prettier-plugin-svelte": "^3.3.2",
    "svelte": "^5.14.0",
added src/components/CobCommitTeaser.svelte
@@ -0,0 +1,91 @@
+
<script lang="ts">
+
  import type { Commit } from "@bindings/repo/Commit";
+

+
  import { twemoji } from "@app/lib/utils";
+

+
  import CompactCommitAuthorship from "@app/components/CompactCommitAuthorship.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import InlineTitle from "@app/components/InlineTitle.svelte";
+
  import NakedButton from "@app/components/NakedButton.svelte";
+

+
  interface Props {
+
    commit: Commit;
+
  }
+

+
  const { commit }: Props = $props();
+

+
  let commitMessageVisible = $state(false);
+
</script>
+

+
<style>
+
  .teaser {
+
    display: flex;
+
    font-size: var(--font-size-small);
+
    align-items: start;
+
    padding: 0.125rem 0;
+
  }
+
  .message {
+
    align-items: center;
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
  }
+
  .left {
+
    display: flex;
+
    gap: 0.5rem;
+
    padding: 0 0.5rem;
+
    flex-direction: column;
+
  }
+
  .right {
+
    display: flex;
+
    align-items: center;
+
    gap: 1rem;
+
    margin-left: auto;
+
    height: 21px;
+
  }
+
  .commit-message {
+
    font-size: var(--font-size-tiny);
+
  }
+
  .commit-expand-button {
+
    height: 21px;
+
    display: flex;
+
    align-items: center;
+
  }
+
  pre {
+
    white-space: pre-wrap;
+
    word-wrap: break-word;
+
  }
+
</style>
+

+
<div class="teaser" aria-label="commit-teaser">
+
  <div class="left">
+
    <div class="message">
+
      <div class="summary" use:twemoji>
+
        <InlineTitle fontSize="small" content={commit.summary} />
+
      </div>
+
      {#if commit.message.trim() !== commit.summary.trim()}
+
        <div class="commit-expand-button">
+
          <NakedButton
+
            stylePadding="0 4px"
+
            variant="ghost"
+
            onclick={() => {
+
              commitMessageVisible = !commitMessageVisible;
+
            }}>
+
            <Icon name="ellipsis" />
+
          </NakedButton>
+
        </div>
+
      {/if}
+
    </div>
+
    {#if commitMessageVisible}
+
      <div class="commit-message">
+
        <pre>{commit.message.replace(commit.summary, "").trim()}</pre>
+
      </div>
+
    {/if}
+
  </div>
+
  <div class="right">
+
    <CompactCommitAuthorship {commit}>
+
      <Id id={commit.id} variant="commit" />
+
    </CompactCommitAuthorship>
+
  </div>
+
</div>
added src/components/CommitsContainer.svelte
@@ -0,0 +1,69 @@
+
<script lang="ts">
+
  import type { Snippet } from "svelte";
+

+
  import Border from "./Border.svelte";
+
  import Icon from "./Icon.svelte";
+
  import NakedButton from "./NakedButton.svelte";
+

+
  interface Props {
+
    leftHeader: Snippet;
+
    children: Snippet;
+
    expanded: boolean;
+
  }
+

+
  /* eslint-disable prefer-const */
+
  let { leftHeader, children, expanded }: Props = $props();
+
  /* eslint-enable prefer-const */
+
</script>
+

+
<style>
+
  .header {
+
    display: flex;
+
    align-items: center;
+
    height: 2rem;
+
    padding-left: 0.25rem;
+
    font-size: var(--font-size-small);
+
    background-color: var(--color-background-default);
+
  }
+

+
  .left {
+
    display: flex;
+
    gap: 0.5rem;
+
    align-items: center;
+
  }
+
  .divider {
+
    width: calc(100% + 4px);
+
    position: relative;
+
    top: -6px;
+
    left: -2px;
+
    z-index: 1;
+
    height: 2px;
+
    background-color: var(--color-fill-ghost);
+
  }
+
</style>
+

+
<Border
+
  variant="ghost"
+
  styleFlexDirection="column"
+
  styleAlignItems="flex-start">
+
  <div class="header" class:collapsed={!expanded}>
+
    <div class="left">
+
      <NakedButton
+
        stylePadding="0 4px"
+
        variant="ghost"
+
        onclick={async () => {
+
          expanded = !expanded;
+
        }}>
+
        <Icon name={expanded ? "chevron-down" : "chevron-right"} />
+
      </NakedButton>
+
      {@render leftHeader()}
+
    </div>
+
  </div>
+

+
  {#if expanded}
+
    <div class="divider"></div>
+
    <div style:width="100%">
+
      {@render children()}
+
    </div>
+
  {/if}
+
</Border>
added src/components/CompactCommitAuthorship.svelte
@@ -0,0 +1,106 @@
+
<script lang="ts">
+
  import type { Commit } from "@bindings/repo/Commit";
+
  import type { Snippet } from "svelte";
+

+
  import * as utils from "@app/lib/utils";
+
  import HoverPopover from "./HoverPopover.svelte";
+

+
  interface Props {
+
    children: Snippet;
+
    commit: Commit;
+
  }
+

+
  const { children, commit }: Props = $props();
+
</script>
+

+
<style>
+
  .authorship {
+
    display: flex;
+
    font-size: var(--font-size-small);
+
    column-gap: 0.5rem;
+
    align-items: center;
+
    white-space: nowrap;
+
  }
+
  .person {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: nowrap;
+
    white-space: nowrap;
+
    gap: 0.5rem;
+
    font-family: var(--font-family-monospace);
+
    font-weight: var(--font-weight-semibold);
+
  }
+
  .label {
+
    font-family: var(--font-family-sans-serif);
+
    font-weight: var(--font-weight-regular);
+
    color: var(--color-foreground-dim);
+
  }
+
  .avatar {
+
    width: 1rem;
+
    height: 1rem;
+
    clip-path: var(--1px-corner-fill);
+
  }
+
</style>
+

+
<div class="authorship">
+
  <HoverPopover
+
    stylePopoverPositionLeft="-8rem"
+
    stylePopoverPositionBottom="1.5rem">
+
    {#snippet toggle()}
+
      <div style="display: flex;">
+
        {#if commit.author.email === commit.committer.email}
+
          <img
+
            class="avatar"
+
            alt="avatar"
+
            src={utils.gravatarURL(commit.committer.email)} />
+
        {:else}
+
          <img
+
            style:margin-right="0.25rem"
+
            class="avatar"
+
            alt="avatar"
+
            src={utils.gravatarURL(commit.author.email)} />
+
          <img
+
            class="avatar"
+
            alt="avatar"
+
            src={utils.gravatarURL(commit.committer.email)} />
+
        {/if}
+
      </div>
+
    {/snippet}
+

+
    {#snippet popover()}
+
      <div class="popover">
+
        {#if commit.author.email === commit.committer.email}
+
          <div class="person">
+
            <div class="label">Author</div>
+
            <img
+
              class="avatar"
+
              alt="avatar"
+
              src={utils.gravatarURL(commit.committer.email)} />
+
            {commit.author.name}
+
          </div>
+
        {:else}
+
          <div class="person">
+
            <div class="label">Author</div>
+
            <img
+
              class="avatar"
+
              alt="avatar"
+
              src={utils.gravatarURL(commit.author.email)} />
+
            {commit.author.name}
+
          </div>
+
          <div class="person">
+
            <div class="label">Committer</div>
+
            <img
+
              class="avatar"
+
              alt="avatar"
+
              src={utils.gravatarURL(commit.committer.email)} />
+
            {commit.committer.name}
+
          </div>
+
        {/if}
+
      </div>
+
    {/snippet}
+
  </HoverPopover>
+
  {@render children()}
+
  <div title={utils.absoluteTimestamp(commit.committer.time * 1000)}>
+
    {utils.formatTimestamp(commit.committer.time * 1000)}
+
  </div>
+
</div>
added src/components/HoverPopover.svelte
@@ -0,0 +1,62 @@
+
<script lang="ts">
+
  import type { Snippet } from "svelte";
+

+
  import debounce from "lodash/debounce";
+

+
  interface Props {
+
    popover: Snippet;
+
    stylePopoverPositionBottom: string | undefined;
+
    stylePopoverPositionLeft: string | undefined;
+
    toggle: Snippet;
+
  }
+

+
  const {
+
    popover,
+
    stylePopoverPositionBottom,
+
    stylePopoverPositionLeft,
+
    toggle,
+
  }: Props = $props();
+

+
  let visible: boolean = $state(false);
+

+
  const setVisible = debounce((value: boolean) => {
+
    visible = value;
+
  }, 50);
+
</script>
+

+
<style>
+
  .container {
+
    position: relative;
+
    display: inline-block;
+
  }
+
  .popover {
+
    background: var(--color-fill-ghost);
+
    border-radius: var(--border-radius-regular);
+
    padding: 0.5rem 1rem;
+
    box-shadow: var(--elevation-low);
+
    position: absolute;
+
    clip-path: var(--2px-corner-fill);
+
    z-index: 10;
+
  }
+
</style>
+

+
<div class="container">
+
  <div
+
    role="button"
+
    tabindex="0"
+
    onmouseenter={() => setVisible(true)}
+
    onmouseleave={() => setVisible(false)}>
+
    {@render toggle()}
+

+
    {#if visible}
+
      <div style:position="absolute">
+
        <div
+
          class="popover"
+
          style:left={stylePopoverPositionLeft}
+
          style:bottom={stylePopoverPositionBottom}>
+
          {@render popover()}
+
        </div>
+
      </div>
+
    {/if}
+
  </div>
+
</div>
modified src/components/Icon.svelte
@@ -29,6 +29,7 @@
      | "dashboard"
      | "delegate"
      | "diff"
+
      | "ellipsis"
      | "expand"
      | "expand-panel"
      | "eye"
@@ -432,6 +433,10 @@
    <path d="M7 4H8V9H7V4Z" />
    <path d="M5 6H10V7H5V6Z" />
    <path d="M5 10H10V11H5V10Z" />
+
  {:else if name === "ellipsis"}
+
    <path d="M2 7H4V9H2V7Z" />
+
    <path d="M7 7H9V9H7V7Z" />
+
    <path d="M12 7H14V9H12V7Z" />
  {:else if name === "expand"}
    <path d="M9 1.5H8V2.5H9V1.5Z" />
    <path d="M10 2.5H9V3.5H10V2.5Z" />
modified src/components/Revision.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
  import type { Author } from "@bindings/cob/Author";
+
  import type { Commit } from "@bindings/repo/Commit";
  import type { Config } from "@bindings/config/Config";
  import type { Diff } from "@bindings/diff/Diff";
  import type { Embed } from "@bindings/cob/thread/Embed";
@@ -16,9 +17,12 @@
  import { publicKeyFromDid, scrollIntoView } from "@app/lib/utils";

  import Changeset from "@app/components/Changeset.svelte";
+
  import CobCommitTeaser from "./CobCommitTeaser.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
+
  import CommitsContainer from "@app/components/CommitsContainer.svelte";
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "./Id.svelte";
  import NakedButton from "./NakedButton.svelte";
  import ReviewTeaser from "@app/components/ReviewTeaser.svelte";
  import ThreadComponent from "@app/components/Thread.svelte";
@@ -226,6 +230,14 @@
      },
    });
  }
+

+
  async function loadCommits(rid: string, base: string, head: string) {
+
    return invoke<Commit[]>("list_commits", {
+
      rid,
+
      base,
+
      head,
+
    });
+
  }
</script>

<style>
@@ -255,6 +267,32 @@
    margin-left: 1.25rem;
    background-color: var(--color-background-float);
  }
+
  .commits {
+
    position: relative;
+
    display: flex;
+
    flex-direction: column;
+
    font-size: 0.875rem;
+
    margin-left: 0.5rem;
+
    gap: 0.5rem;
+
    padding: 1rem 0.5rem 0.5rem 1rem;
+
    border-left: 1px solid var(--color-fill-separator);
+
  }
+
  .commit:last-of-type::after {
+
    content: "";
+
    position: absolute;
+
    left: -18.5px;
+
    top: 14px;
+
    bottom: -0.5rem;
+
    border-left: 4px solid var(--color-background-default);
+
  }
+
  .commit-dot {
+
    width: 4px;
+
    height: 4px;
+
    position: absolute;
+
    top: 0.625rem;
+
    left: -18.5px;
+
    background-color: var(--color-fill-separator);
+
  }
</style>

<div class="txt-small patch-body">
@@ -282,15 +320,14 @@
</div>

<div style:margin="1rem 0">
-
  <!-- svelte-ignore a11y_click_events_have_key_events -->
-
  <div
-
    role="button"
-
    tabindex="0"
-
    class="txt-semibold global-flex"
-
    style:margin-bottom="1rem"
-
    style:cursor="pointer"
-
    onclick={() => (hideDiscussion = !hideDiscussion)}>
-
    <Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />Discussion
+
  <div class="global-flex" style:margin-bottom="1rem">
+
    <NakedButton
+
      stylePadding="0 4px"
+
      variant="ghost"
+
      onclick={() => (hideDiscussion = !hideDiscussion)}>
+
      <Icon name={hideDiscussion ? "chevron-right" : "chevron-down"} />
+
      <div class="txt-semibold global-flex txt-regular">Discussion</div>
+
    </NakedButton>
  </div>
  <div class:hide={hideDiscussion}>
    {#each threads as thread}
@@ -325,17 +362,16 @@

{#if revision.reviews && revision.reviews.length}
  <div style:margin="1rem 0">
-
    <!-- svelte-ignore a11y_click_events_have_key_events -->
-
    <div
-
      role="button"
-
      tabindex="0"
-
      class="txt-semibold global-flex"
-
      style:margin-bottom="1rem"
-
      style:cursor="pointer"
-
      onclick={() => (hideReviews = !hideReviews)}>
-
      <Icon name={hideReviews ? "chevron-right" : "chevron-down"} />Reviews
+
    <div class="global-flex" style:margin-bottom="1rem">
+
      <NakedButton
+
        stylePadding="0 4px"
+
        variant="ghost"
+
        onclick={() => (hideReviews = !hideReviews)}>
+
        <Icon name={hideReviews ? "chevron-right" : "chevron-down"} />
+
        <div class="txt-semibold global-flex txt-regular">Reviews</div>
+
      </NakedButton>
    </div>
-
    <div class:hide={hideReviews}>
+
    <div class:hide={hideReviews} style:margin-top="1rem">
      {#each revision.reviews as review}
        <ReviewTeaser {rid} {review} />
      {/each}
@@ -343,19 +379,16 @@
  </div>
{/if}

-
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
  class="txt-semibold global-flex"
  style:margin-bottom={hideChanges ? undefined : "1rem"}>
-
  <div
-
    style:cursor="pointer"
-
    style:min-height="2rem"
-
    class="global-flex"
-
    role="button"
-
    tabindex="0"
+
  <NakedButton
+
    stylePadding="0 4px"
+
    variant="ghost"
    onclick={() => (hideChanges = !hideChanges)}>
-
    <Icon name={hideChanges ? "chevron-right" : "chevron-down"} />Changes
-
  </div>
+
    <Icon name={hideChanges ? "chevron-right" : "chevron-down"} />
+
    <div class="txt-semibold global-flex txt-regular">Changes</div>
+
  </NakedButton>
  {#if !hideChanges}
    <div style:margin-left="auto">
      <NakedButton
@@ -372,7 +405,36 @@
    </div>
  {/if}
</div>
+

<div class:hide={hideChanges}>
+
  {#await loadCommits(rid, revision.base, revision.head) then commits}
+
    <div style:margin-bottom="1rem">
+
      <CommitsContainer expanded={filesExpanded}>
+
        {#snippet leftHeader()}
+
          <div class="txt-semibold">Commits</div>
+
        {/snippet}
+
        {#snippet children()}
+
          <div style:padding="0 1rem">
+
            <div
+
              class="global-flex txt-small"
+
              style:color="var(--color-foreground-dim)">
+
              <Icon name="branch" /><Id id={revision.base} variant="commit" />
+
              <div class="global-counter">base</div>
+
            </div>
+
            <div class="commits">
+
              {#each commits.reverse() as commit}
+
                <div class="commit" style:position="relative">
+
                  <div class="commit-dot"></div>
+
                  <CobCommitTeaser {commit} />
+
                </div>
+
              {/each}
+
            </div>
+
          </div>
+
        {/snippet}
+
      </CommitsContainer>
+
    </div>
+
  {/await}
+

  {#await loadHighlightedDiff(rid, revision.base, revision.head)}
    <span class="txt-small">Loading…</span>
  {:then diff}
modified src/lib/utils.ts
@@ -6,6 +6,7 @@ import type { Patch } from "@bindings/cob/patch/Patch";

import bs58 from "bs58";
import twemojiModule from "twemoji";
+
import md5 from "md5";

import NodeId from "@app/components/NodeId.svelte";

@@ -211,3 +212,11 @@ export function parseNodeId(

  return undefined;
}
+

+
// Get the gravatar URL of an email.
+
export function gravatarURL(email: string): string {
+
  const address = email.trim().toLowerCase();
+
  const hash = md5(address);
+

+
  return `https://www.gravatar.com/avatar/${hash}`;
+
}