Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add commit history list and single commit views
Rūdolfs Ošiņš committed 2 days ago
commit 65c453e37df766fec579c9ab8d1f29c5d61b6aaa
parent bb0ae84
14 files changed +1003 -5
modified crates/radicle-tauri/src/commands/repo.rs
@@ -90,6 +90,46 @@ pub async fn list_commits(
}

#[tauri::command]
+
pub async fn get_commit_diff(
+
    ctx: tauri::State<'_, AppState>,
+
    rid: RepoId,
+
    sha: git::Oid,
+
    unified: Option<u32>,
+
    highlight: Option<bool>,
+
) -> Result<types::diff::Diff, Error> {
+
    ctx.get_commit_diff(rid, sha, unified, highlight)
+
}
+

+
#[tauri::command]
+
pub async fn list_repo_commits(
+
    ctx: tauri::State<'_, AppState>,
+
    rid: RepoId,
+
    head: Option<git::Oid>,
+
    skip: Option<usize>,
+
    take: Option<usize>,
+
) -> Result<types::cobs::PaginatedQuery<Vec<types::repo::Commit>>, Error> {
+
    ctx.list_repo_commits(rid, head, skip, take)
+
}
+

+
#[tauri::command]
+
pub fn repo_commit_count(
+
    ctx: tauri::State<AppState>,
+
    rid: RepoId,
+
    head: git::Oid,
+
) -> Result<usize, Error> {
+
    ctx.repo_commit_count(rid, head)
+
}
+

+
#[tauri::command]
+
pub fn repo_commit(
+
    ctx: tauri::State<AppState>,
+
    rid: RepoId,
+
    sha: git::Oid,
+
) -> Result<types::repo::Commit, Error> {
+
    ctx.repo_commit(rid, sha)
+
}
+

+
#[tauri::command]
pub(crate) async fn create_repo(
    ctx: tauri::State<'_, AppState>,
    name: String,
modified crates/radicle-tauri/src/lib.rs
@@ -49,6 +49,7 @@ pub fn run() {
            cob::save_embed_by_path,
            cob::save_embed_to_disk,
            diff::get_diff,
+
            repo::get_commit_diff,
            inbox::clear_notifications,
            inbox::notification_count,
            inbox::list_notifications,
@@ -57,9 +58,12 @@ pub fn run() {
            repo::create_repo,
            repo::diff_stats,
            repo::list_commits,
+
            repo::list_repo_commits,
            repo::list_repos,
            repo::list_repos_summary,
            repo::repo_by_id,
+
            repo::repo_commit_count,
+
            repo::repo_commit,
            repo::repo_count,
            repo::repo_readme,
            repo::repo_tree,
modified crates/radicle-types/src/traits/repo.rs
@@ -335,6 +335,44 @@ pub trait Repo: Profile {
        Ok::<_, Error>(diff.into())
    }

+
    fn get_commit_diff(
+
        &self,
+
        rid: identity::RepoId,
+
        sha: git::Oid,
+
        unified: Option<u32>,
+
        highlight: Option<bool>,
+
    ) -> Result<Diff, Error> {
+
        let unified = unified.unwrap_or(5);
+
        let highlight = highlight.unwrap_or(true);
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?.backend;
+
        let head = repo.find_commit(sha.into())?;
+

+
        let mut opts = git::raw::DiffOptions::new();
+
        opts.patience(true).minimal(true).context_lines(unified);
+

+
        let mut find_opts = git::raw::DiffFindOptions::new();
+
        find_opts.exact_match_only(true);
+
        find_opts.all(true);
+

+
        let left = head
+
            .parents()
+
            .next()
+
            .map(|parent| parent.tree())
+
            .transpose()?;
+
        let right = head.tree()?;
+

+
        let mut diff = repo.diff_tree_to_tree(left.as_ref(), Some(&right), Some(&mut opts))?;
+
        diff.find_similar(Some(&mut find_opts))?;
+
        let diff = surf::diff::Diff::try_from(diff)?;
+

+
        if highlight {
+
            return Ok::<_, Error>(diff.pretty(highlighter(), &(), &repo));
+
        }
+

+
        Ok::<_, Error>(diff.into())
+
    }
+

    fn list_commits(
        &self,
        rid: identity::RepoId,
@@ -361,6 +399,77 @@ pub trait Repo: Profile {
        Ok(commits)
    }

+
    fn list_repo_commits(
+
        &self,
+
        rid: identity::RepoId,
+
        head: Option<git::Oid>,
+
        skip: Option<usize>,
+
        take: Option<usize>,
+
    ) -> Result<crate::cobs::PaginatedQuery<Vec<repo::Commit>>, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+

+
        let repo = surf::Repository::open(repo.path())?;
+
        let head = match head {
+
            Some(head) => crate::oid::into_surf(head),
+
            None => repo.head()?,
+
        };
+
        let commits = repo.history(head)?;
+
        let cursor = skip.unwrap_or(0);
+

+
        match take {
+
            None => {
+
                let content: Vec<repo::Commit> =
+
                    commits.filter_map(|c| c.map(Into::into).ok()).collect();
+

+
                Ok(crate::cobs::PaginatedQuery {
+
                    cursor: 0,
+
                    more: false,
+
                    content,
+
                })
+
            }
+
            Some(take) => {
+
                let content: Vec<repo::Commit> = commits
+
                    .filter_map(|c| c.map(Into::into).ok())
+
                    .skip(cursor)
+
                    .take(take + 1)
+
                    .collect();
+
                let more = content.len() > take;
+
                let content = if more {
+
                    content[..take].to_vec()
+
                } else {
+
                    content
+
                };
+

+
                Ok(crate::cobs::PaginatedQuery {
+
                    cursor,
+
                    more,
+
                    content,
+
                })
+
            }
+
        }
+
    }
+

+
    fn repo_commit_count(&self, rid: identity::RepoId, head: git::Oid) -> Result<usize, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+

+
        let repo = surf::Repository::open(repo.path())?;
+
        let count = repo.history(crate::oid::into_surf(head))?.count();
+

+
        Ok(count)
+
    }
+

+
    fn repo_commit(&self, rid: identity::RepoId, sha: git::Oid) -> Result<repo::Commit, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?;
+

+
        let repo = surf::Repository::open(repo.path())?;
+
        let commit = repo.commit(crate::oid::into_surf(sha))?;
+

+
        Ok(commit.into())
+
    }
+

    fn unseed(&self, rid: identity::RepoId) -> Result<(), Error> {
        let profile = self.profile();
        let mut node = radicle::Node::new(profile.socket());
modified crates/test-http-api/src/api.rs
@@ -83,6 +83,10 @@ pub fn router(ctx: Context) -> Router {
        .route("/repo_tree", post(tree_handler))
        .route("/repo_blob", post(blob_handler))
        .route("/get_diff", post(diff_handler))
+
        .route("/get_commit_diff", post(commit_diff_handler))
+
        .route("/list_repo_commits", post(list_repo_commits_handler))
+
        .route("/repo_commit_count", post(repo_commit_count_handler))
+
        .route("/repo_commit", post(repo_commit_handler))
        .route("/list_issues", post(issues_handler))
        .route("/create_issue", post(create_issue_handler))
        .route("/create_issue_comment", post(create_issue_comment_handler))
@@ -262,6 +266,80 @@ async fn diff_handler(
}

#[derive(Serialize, Deserialize)]
+
struct CommitDiffBody {
+
    pub rid: identity::RepoId,
+
    pub sha: git::Oid,
+
    pub unified: Option<u32>,
+
    pub highlight: Option<bool>,
+
}
+

+
async fn commit_diff_handler(
+
    State(ctx): State<Context>,
+
    Json(CommitDiffBody {
+
        rid,
+
        sha,
+
        unified,
+
        highlight,
+
    }): Json<CommitDiffBody>,
+
) -> impl IntoResponse {
+
    let diff = ctx.get_commit_diff(rid, sha, unified, highlight)?;
+

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

+
#[derive(Serialize, Deserialize)]
+
struct ListRepoCommitsBody {
+
    pub rid: identity::RepoId,
+
    pub head: Option<git::Oid>,
+
    pub skip: Option<usize>,
+
    pub take: Option<usize>,
+
}
+

+
async fn list_repo_commits_handler(
+
    State(ctx): State<Context>,
+
    Json(ListRepoCommitsBody {
+
        rid,
+
        head,
+
        skip,
+
        take,
+
    }): Json<ListRepoCommitsBody>,
+
) -> impl IntoResponse {
+
    let commits = ctx.list_repo_commits(rid, head, skip, take)?;
+

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

+
#[derive(Serialize, Deserialize)]
+
struct RepoCommitCountBody {
+
    pub rid: identity::RepoId,
+
    pub head: git::Oid,
+
}
+

+
async fn repo_commit_count_handler(
+
    State(ctx): State<Context>,
+
    Json(RepoCommitCountBody { rid, head }): Json<RepoCommitCountBody>,
+
) -> impl IntoResponse {
+
    let count = ctx.repo_commit_count(rid, head)?;
+

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

+
#[derive(Serialize, Deserialize)]
+
struct RepoCommitBody {
+
    pub rid: identity::RepoId,
+
    pub sha: git::Oid,
+
}
+

+
async fn repo_commit_handler(
+
    State(ctx): State<Context>,
+
    Json(RepoCommitBody { rid, sha }): Json<RepoCommitBody>,
+
) -> impl IntoResponse {
+
    let commit = ctx.repo_commit(rid, sha)?;
+

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

+
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IssuesBody {
    pub rid: identity::RepoId,
modified src/App.svelte
@@ -42,6 +42,8 @@
  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 RepoCommit from "@app/views/repo/RepoCommit.svelte";
+
  import RepoCommits from "@app/views/repo/RepoCommits.svelte";
  import RepoHome from "@app/views/repo/RepoHome.svelte";

  import Command from "./components/Command.svelte";
@@ -213,6 +215,10 @@
      <GuideView {...$activeRouteStore.params} />
    {:else if $activeRouteStore.resource === "repo.home"}
      <RepoHome {...$activeRouteStore.params} />
+
    {:else if $activeRouteStore.resource === "repo.commits"}
+
      <RepoCommits {...$activeRouteStore.params} />
+
    {:else if $activeRouteStore.resource === "repo.commit"}
+
      <RepoCommit {...$activeRouteStore.params} />
    {:else if $activeRouteStore.resource === "repo.issue"}
      <Issue {...$activeRouteStore.params} />
    {:else if $activeRouteStore.resource === "repo.issues"}
modified src/components/CobCommitTeaser.svelte
@@ -15,15 +15,17 @@
    commit: Commit;
    disabled: boolean;
    hoverable?: boolean;
-
    onclick: () => void;
+
    onclick?: () => void;
+
    flush?: boolean;
  }

  const {
    children,
    commit,
    disabled,
+
    flush = false,
    hoverable = false,
-
    onclick,
+
    onclick = undefined,
  }: Props = $props();

  let commitMessageVisible = $state(false);
@@ -81,7 +83,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="teaser" class:disabled {onclick} aria-label="commit-teaser">
-
  <div class="left">
+
  <div class="left" style:padding={flush ? "0" : undefined}>
    <div class="message">
      <div style:cursor={hoverable ? "pointer" : "default"} use:twemoji>
        <InlineTitle fontSize="small" content={commit.summary} />
modified src/components/FileBlock.svelte
@@ -44,6 +44,10 @@
    border-top-left-radius: var(--border-radius-md);
    border-top-right-radius: var(--border-radius-md);
  }
+
  .header.collapsed {
+
    border-bottom-left-radius: var(--border-radius-md);
+
    border-bottom-right-radius: var(--border-radius-md);
+
  }

  .sticky {
    position: sticky;
modified src/components/SidebarRepoList.svelte
@@ -7,7 +7,7 @@

  import { nodeRunning } from "@app/lib/events";
  import { dynamicInterval, resetDynamicInterval } from "@app/lib/interval";
-
  import { invoke } from "@app/lib/invoke";
+
  import { cachedRepoCommitCount, invoke } from "@app/lib/invoke";
  import * as router from "@app/lib/router";
  import useLocalStorage from "@app/lib/useLocalStorage.svelte";
  import { formatRepositoryId } from "@app/lib/utils";
@@ -32,6 +32,7 @@

  let repos = $state<RepoSummary[]>(initialRepos);
  let seededNotReplicated = $state<string[]>(initialSeededNotReplicated);
+
  let activeCommitCount = $state<number | undefined>(undefined);
  let filterOpen = $state(false);
  let filterQuery = $state("");
  let filterInputElement: HTMLInputElement | undefined = $state(undefined);
@@ -45,6 +46,25 @@
  });

  $effect(() => {
+
    const rid = activeRepo?.rid;
+
    const head = activeRepo?.payloads["xyz.radicle.project"]?.meta.head;
+

+
    activeCommitCount = undefined;
+

+
    if (!rid || !head) return;
+

+
    void cachedRepoCommitCount(rid, head)
+
      .then(count => {
+
        if (activeRepo?.rid === rid) {
+
          activeCommitCount = count;
+
        }
+
      })
+
      .catch(error => {
+
        console.error("Failed to load commit count", error);
+
      });
+
  });
+

+
  $effect(() => {
    if (filterOpen && filterInputElement) {
      filterInputElement.focus({ preventScroll: true });
    }
@@ -118,6 +138,14 @@
    );
  }

+
  function isCommits(rid: string): boolean {
+
    return (
+
      ($activeRoute.resource === "repo.commits" ||
+
        $activeRoute.resource === "repo.commit") &&
+
      activeRid() === rid
+
    );
+
  }
+

  function isPatches(rid: string): boolean {
    return (
      ($activeRoute.resource === "repo.patches" ||
@@ -396,6 +424,19 @@
          {@const activeProject = activeRepo?.payloads["xyz.radicle.project"]}
          <a
            class="nav-item sub-item"
+
            class:active={isCommits(repo.rid)}
+
            href={router.routeToPath({
+
              resource: "repo.commits",
+
              rid: repo.rid,
+
            })}>
+
            <span class="icon"><Icon name="branch" /></span>
+
            Commits
+
            {#if activeCommitCount !== undefined}
+
              <span class="global-counter-badge">{activeCommitCount}</span>
+
            {/if}
+
          </a>
+
          <a
+
            class="nav-item sub-item"
            class:active={isIssues(repo.rid)}
            href={router.routeToPath({
              resource: "repo.issues",
modified src/lib/invoke.ts
@@ -103,6 +103,16 @@ export const cachedListCommits = cached(
  { max: 5_000 },
);

+
async function repoCommitCount(rid: string, head: string): Promise<number> {
+
  return withTestBackend(tauri.invoke, "repo_commit_count", { rid, head });
+
}
+

+
export const cachedRepoCommitCount = cached(
+
  repoCommitCount,
+
  (...[rid, head]) => `repo_commit_count:${rid}:${head}`,
+
  { max: 5_000 },
+
);
+

async function diffStats(
  rid: string,
  base: string,
@@ -121,6 +131,29 @@ export const cachedDiffStats = cached(
  { max: 10_000 },
);

+
async function getCommitDiff(
+
  rid: string,
+
  sha: string,
+
  unified = 3,
+
  highlight = true,
+
): Promise<Diff> {
+
  return withTestBackend(tauri.invoke, "get_commit_diff", {
+
    rid,
+
    sha,
+
    unified,
+
    highlight,
+
  });
+
}
+

+
// Commits are immutable, so SHA is a perfect cache key. Cap entries since
+
// each highlighted Diff can be sizeable.
+
export const cachedGetCommitDiff = cached(
+
  getCommitDiff,
+
  (...[rid, sha, unified, highlight]) =>
+
    `get_commit_diff:${rid}:${sha}:${unified}:${highlight}`,
+
  { max: 100 },
+
);
+

export async function writeToClipboard(
  text: string,
  opts?: {
modified src/lib/router.ts
@@ -164,6 +164,8 @@ export function routeToPath(route: Route): string {
    return "/guide";
  } else if (
    route.resource === "repo.home" ||
+
    route.resource === "repo.commits" ||
+
    route.resource === "repo.commit" ||
    route.resource === "repo.issue" ||
    route.resource === "repo.issues" ||
    route.resource === "repo.patch" ||
modified src/lib/router/definitions.ts
@@ -8,6 +8,8 @@ import {
  loadIssues,
  loadPatch,
  loadPatches,
+
  loadRepoCommit,
+
  loadRepoCommits,
  loadRepoHome,
} from "@app/views/repo/router";

@@ -57,6 +59,8 @@ export function isLoadedRepoRoute(
): route is LoadedRepoRoute {
  return (
    route.resource === "repo.home" ||
+
    route.resource === "repo.commits" ||
+
    route.resource === "repo.commit" ||
    route.resource === "repo.issue" ||
    route.resource === "repo.issues" ||
    route.resource === "repo.patch" ||
@@ -103,6 +107,10 @@ export async function loadRoute(
    return loadGuide();
  } else if (route.resource === "repo.home") {
    return loadRepoHome(route);
+
  } else if (route.resource === "repo.commits") {
+
    return loadRepoCommits(route);
+
  } else if (route.resource === "repo.commit") {
+
    return loadRepoCommit(route);
  } else if (route.resource === "repo.issue") {
    return loadIssue(route);
  } else if (route.resource === "repo.issues") {
added src/views/repo/RepoCommit.svelte
@@ -0,0 +1,235 @@
+
<script lang="ts">
+
  import type { Diff } from "@bindings/diff/Diff";
+
  import type { Commit } from "@bindings/repo/Commit";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+

+
  import * as router from "@app/lib/router";
+
  import {
+
    absoluteTimestamp,
+
    explorerUrl,
+
    formatOid,
+
    formatTimestamp,
+
    gravatarURL,
+
    pluralize,
+
  } from "@app/lib/utils";
+

+
  import Changeset from "@app/components/Changeset.svelte";
+
  import DiffStatBadge from "@app/components/DiffStatBadge.svelte";
+
  import ExternalLink from "@app/components/ExternalLink.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import Id from "@app/components/Id.svelte";
+
  import ScrollArea from "@app/components/ScrollArea.svelte";
+
  import Topbar from "@app/components/Topbar.svelte";
+

+
  import Layout from "./Layout.svelte";
+

+
  interface Props {
+
    repo: RepoInfo;
+
    commit: Commit;
+
    diff: Diff;
+
  }
+

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

+
<style>
+
  .page {
+
    display: flex;
+
    flex-direction: column;
+
    height: 100%;
+
    min-height: 0;
+
  }
+
  .breadcrumb {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.375rem;
+
  }
+
  .breadcrumb-link {
+
    cursor: pointer;
+
    background: none;
+
    border: none;
+
    padding: 0;
+
    font: var(--txt-body-m-regular);
+
    color: var(--color-text-secondary);
+
  }
+
  .breadcrumb-link:hover {
+
    color: var(--color-text-primary);
+
  }
+
  .content {
+
    padding: 1rem;
+
    display: flex;
+
    flex-direction: column;
+
    gap: 1rem;
+
  }
+
  .meta {
+
    padding: 0.25rem 0 0.5rem;
+
  }
+
  .meta-header {
+
    display: flex;
+
    gap: 0.75rem;
+
    align-items: flex-start;
+
    justify-content: space-between;
+
    padding: 0 0 1rem;
+
    flex-wrap: wrap;
+
  }
+
  .meta-title {
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
    min-width: 0;
+
  }
+
  .summary {
+
    font: var(--txt-body-l-semibold);
+
    overflow-wrap: anywhere;
+
  }
+
  .summary-meta {
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
    align-items: center;
+
    color: var(--color-text-secondary);
+
    font: var(--txt-body-m-regular);
+
  }
+
  .summary-author {
+
    display: inline-flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
  }
+
  .summary-avatar {
+
    width: 1rem;
+
    height: 1rem;
+
    border-radius: 999px;
+
    flex: none;
+
  }
+
  .summary-timestamp {
+
    color: var(--color-text-quaternary);
+
  }
+
  .summary-message {
+
    white-space: pre-wrap;
+
    margin: 0;
+
    font: var(--txt-body-m-regular);
+
    color: var(--color-text-secondary);
+
  }
+
  .summary-parents {
+
    display: flex;
+
    flex-wrap: wrap;
+
    gap: 0.5rem;
+
    align-items: center;
+
    font: var(--txt-body-m-regular);
+
    color: var(--color-text-secondary);
+
  }
+
  .summary-parents-label {
+
    font: inherit;
+
    color: inherit;
+
  }
+
  .parent-link {
+
    cursor: pointer;
+
    background: none;
+
    border: none;
+
    padding: 0;
+
  }
+
  .parent-link:hover {
+
    color: var(--color-text-primary);
+
  }
+
  .chips {
+
    display: flex;
+
    gap: 0.5rem;
+
    align-items: center;
+
    flex-wrap: wrap;
+
    padding-top: 0.125rem;
+
  }
+
  .files-chip {
+
    padding: 0 0.5rem;
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-sm);
+
    height: 1.5rem;
+
    display: flex;
+
    align-items: center;
+
    font: var(--txt-code-regular);
+
    color: var(--color-text-secondary);
+
  }
+
</style>
+

+
<Layout selfScroll>
+
  <div class="page">
+
    <Topbar>
+
      <div class="breadcrumb">
+
        <button
+
          class="breadcrumb-link"
+
          onclick={() =>
+
            router.push({
+
              resource: "repo.commits",
+
              rid: repo.rid,
+
            })}>
+
          All commits
+
        </button>
+
        <Icon name="chevron-right" />
+
        <Id id={commit.id} clipboard={commit.id} placement="bottom-start" />
+
        <ExternalLink
+
          href={explorerUrl(`${repo.rid}/commits/${commit.id}`)}
+
          title="Open in app.radicle.xyz" />
+
      </div>
+
    </Topbar>
+
    <ScrollArea style="height: 100%; min-width: 0;">
+
      <div class="content">
+
        <section class="meta">
+
          <div class="meta-header">
+
            <div class="meta-title">
+
              <div class="summary txt-selectable">{commit.summary}</div>
+
              <div class="summary-meta">
+
                <span class="summary-author">
+
                  <img
+
                    class="summary-avatar"
+
                    alt=""
+
                    src={gravatarURL(commit.author.email)} />
+
                  <span class="txt-selectable">{commit.author.name}</span>
+
                </span>
+
                committed
+
                <Id id={commit.id} clipboard={commit.id} />
+
                <span
+
                  class="summary-timestamp"
+
                  title={absoluteTimestamp(commit.committer.time * 1000)}>
+
                  {formatTimestamp(commit.committer.time * 1000)}
+
                </span>
+
              </div>
+
              <div class="summary-parents">
+
                <span class="summary-parents-label">
+
                  {commit.parents.length === 1 ? "parent" : "parents"}
+
                </span>
+
                {#if commit.parents.length === 0}
+
                  <span>Initial commit</span>
+
                {:else}
+
                  {#each commit.parents as parent}
+
                    <button
+
                      class="parent-link txt-id"
+
                      onclick={() => {
+
                        void router.push({
+
                          resource: "repo.commit",
+
                          rid: repo.rid,
+
                          commit: parent,
+
                        });
+
                      }}>
+
                      {formatOid(parent)}
+
                    </button>
+
                  {/each}
+
                {/if}
+
              </div>
+
              <pre class="summary-message txt-selectable">{commit.message
+
                  .replace(commit.summary, "")
+
                  .trim()}</pre>
+
            </div>
+
            <div class="chips">
+
              <div class="files-chip">
+
                {diff.stats.filesChanged}
+
                {pluralize("file", diff.stats.filesChanged)} changed
+
              </div>
+
              <DiffStatBadge stats={diff.stats} />
+
            </div>
+
          </div>
+
        </section>
+

+
        <Changeset {diff} head={commit.id} />
+
      </div>
+
    </ScrollArea>
+
  </div>
+
</Layout>
added src/views/repo/RepoCommits.svelte
@@ -0,0 +1,345 @@
+
<script lang="ts">
+
  import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
+
  import type { Commit } from "@bindings/repo/Commit";
+
  import type { RepoInfo } from "@bindings/repo/RepoInfo";
+

+
  import { COMMITS_PAGE_SIZE } from "@app/views/repo/router";
+
  import fuzzysort from "fuzzysort";
+

+
  import { cachedRepoCommitCount } from "@app/lib/invoke";
+
  import { invoke } from "@app/lib/invoke";
+
  import * as mutexExecutor from "@app/lib/mutexExecutor";
+
  import * as router from "@app/lib/router";
+
  import { modifierKey } from "@app/lib/utils";
+

+
  import CobCommitTeaser from "@app/components/CobCommitTeaser.svelte";
+
  import FuzzySearch from "@app/components/FuzzySearch.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import InfiniteScrollSentinel from "@app/components/InfiniteScrollSentinel.svelte";
+
  import Topbar from "@app/components/Topbar.svelte";
+

+
  import Layout from "./Layout.svelte";
+

+
  interface Props {
+
    repo: RepoInfo;
+
    commits: PaginatedQuery<Commit[]>;
+
  }
+

+
  const { repo, commits }: Props = $props();
+

+
  const project = $derived(repo.payloads["xyz.radicle.project"]!);
+
  let commitCount: number | undefined = $state();
+

+
  $effect(() => {
+
    const rid = repo.rid;
+
    commitCount = undefined;
+
    void cachedRepoCommitCount(rid, project.meta.head)
+
      .then(count => {
+
        if (repo.rid === rid) {
+
          commitCount = count;
+
        }
+
      })
+
      .catch(error => {
+
        console.error("Failed to load commit count", error);
+
      });
+
  });
+

+
  type CommitGroup = {
+
    key: string;
+
    label: string;
+
    commits: Commit[];
+
  };
+

+
  let items = $state(commits.content);
+
  let more = $state(commits.more);
+
  let loadingMore = $state(false);
+
  let loading = $state(false);
+
  let searchInput = $state("");
+
  let debouncedSearch = $state("");
+
  let showSearch = $state(false);
+

+
  const loader = mutexExecutor.create();
+
  const abort = async (): Promise<undefined> => undefined;
+

+
  $effect(() => {
+
    items = commits.content;
+
    more = commits.more;
+
    // Abort any in-flight loadMoreContent so it cannot append a page
+
    // from the previous navigation onto the just-reset items.
+
    void loader.run(abort);
+
  });
+

+
  $effect(() => {
+
    const value = searchInput;
+
    const timer = setTimeout(() => {
+
      debouncedSearch = value;
+
    }, 150);
+
    return () => clearTimeout(timer);
+
  });
+

+
  async function loadMoreContent(all: boolean = false): Promise<void> {
+
    if (!more) return;
+
    loadingMore = true;
+
    try {
+
      const page = await loader.run(async () => {
+
        return await invoke<PaginatedQuery<Commit[]>>("list_repo_commits", {
+
          rid: repo.rid,
+
          head: project.meta.head,
+
          skip: all ? 0 : items.length,
+
          take: all ? undefined : COMMITS_PAGE_SIZE,
+
        });
+
      });
+

+
      // Superseded by a newer load (e.g. fuzzy-focus triggered a load-all).
+
      // Leave items/more alone for the new call.
+
      if (page === undefined) return;
+

+
      more = page.more;
+
      items = all ? page.content : [...items, ...page.content];
+
      if (page.content.length === 0) more = false;
+
    } finally {
+
      loadingMore = false;
+
    }
+
  }
+

+
  function dayKey(timestamp: number) {
+
    const date = new Date(timestamp);
+
    const month = `${date.getMonth() + 1}`.padStart(2, "0");
+
    const day = `${date.getDate()}`.padStart(2, "0");
+

+
    return `${date.getFullYear()}-${month}-${day}`;
+
  }
+

+
  function dayLabel(timestamp: number) {
+
    const date = new Date(timestamp);
+
    const today = new Date();
+
    const yesterday = new Date();
+
    yesterday.setDate(today.getDate() - 1);
+

+
    if (dayKey(date.getTime()) === dayKey(today.getTime())) {
+
      return "Today";
+
    }
+
    if (dayKey(date.getTime()) === dayKey(yesterday.getTime())) {
+
      return "Yesterday";
+
    }
+

+
    return new Intl.DateTimeFormat("en", {
+
      weekday: "long",
+
      month: "long",
+
      day: "numeric",
+
      year: "numeric",
+
    }).format(date);
+
  }
+

+
  const searchableCommits = $derived(
+
    items.map(c => ({
+
      commit: c,
+
      id: c.id,
+
      summary: c.summary,
+
      author: c.author.name,
+
    })),
+
  );
+

+
  const searchResults = $derived(
+
    fuzzysort.go(debouncedSearch, searchableCommits, {
+
      keys: ["summary", "id", "author"],
+
      threshold: 0.5,
+
      all: true,
+
    }),
+
  );
+

+
  const filteredCommits = $derived(searchResults.map(r => r.obj.commit));
+

+
  const groupedCommits = $derived.by<CommitGroup[]>(() => {
+
    const groups = new Map<string, CommitGroup>();
+

+
    for (const commit of filteredCommits) {
+
      const timestamp = commit.committer.time * 1000;
+
      const key = dayKey(timestamp);
+
      const current = groups.get(key);
+

+
      if (current) {
+
        current.commits.push(commit);
+
      } else {
+
        groups.set(key, {
+
          key,
+
          label: dayLabel(timestamp),
+
          commits: [commit],
+
        });
+
      }
+
    }
+

+
    return [...groups.values()];
+
  });
+
</script>
+

+
<style>
+
  .page {
+
    display: flex;
+
    flex-direction: column;
+
    height: 100%;
+
    min-height: 0;
+
  }
+
  .topbar-title {
+
    font: var(--txt-body-m-semibold);
+
    color: var(--color-text-secondary);
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
  }
+
  .branch-group {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
    margin-left: 1.5rem;
+
  }
+
  .branch {
+
    color: var(--color-text-secondary);
+
    font: var(--txt-body-m-regular);
+
  }
+
  .canonical-badge {
+
    display: inline-flex;
+
    align-items: center;
+
    height: 1.25rem;
+
    padding: 0 0.375rem;
+
    border-radius: var(--border-radius-sm);
+
    background-color: var(--color-surface-strong);
+
    color: var(--color-text-secondary);
+
    font: var(--txt-body-s-regular);
+
    text-transform: lowercase;
+
    flex-shrink: 0;
+
  }
+
  .content {
+
    display: flex;
+
    flex-direction: column;
+
    flex: 1;
+
    padding: 1rem;
+
  }
+
  .group + .group {
+
    margin-top: 1.5rem;
+
  }
+
  .group-title {
+
    color: var(--color-text-secondary);
+
    font: var(--txt-body-m-regular);
+
    margin-bottom: 0.75rem;
+
  }
+
  .commit-list {
+
    display: flex;
+
    flex-direction: column;
+
    border: 1px solid var(--color-border-subtle);
+
    border-radius: var(--border-radius-md);
+
    overflow: hidden;
+
    background: var(--color-surface-canvas);
+
  }
+
  .commit-item {
+
    padding: 0.625rem 1rem;
+
    cursor: pointer;
+
  }
+
  .commit-item + .commit-item {
+
    border-top: 1px solid var(--color-border-subtle);
+
  }
+
  .commit-item:hover {
+
    background: var(--color-surface-subtle);
+
  }
+
</style>
+

+
<Layout>
+
  <div class="page">
+
    <Topbar>
+
      <span class="topbar-title">
+
        Commits
+
        <span class="branch-group">
+
          <Icon name="branch" />
+
          <span class="branch">{project.data.defaultBranch}</span>
+
          <span class="canonical-badge">canonical</span>
+
        </span>
+
        {#if commitCount !== undefined}
+
          <span class="global-counter-badge">{commitCount}</span>
+
        {/if}
+
      </span>
+
      <div class="global-flex" style:margin-left="auto" style:gap="0.5rem">
+
        <FuzzySearch
+
          hasItems={items.length > 0}
+
          placeholder={`Fuzzy filter commits ${modifierKey()} + f`}
+
          icon={loading ? "clock" : "filter"}
+
          onFocus={async () => {
+
            try {
+
              loading = true;
+
              await loadMoreContent(true);
+
            } catch (e) {
+
              console.error("Loading all commits failed: ", e);
+
            } finally {
+
              loading = false;
+
            }
+
          }}
+
          onSubmit={() => {
+
            if (filteredCommits.length === 1) {
+
              void router.push({
+
                resource: "repo.commit",
+
                rid: repo.rid,
+
                commit: filteredCommits[0].id,
+
              });
+
            }
+
          }}
+
          bind:show={showSearch}
+
          bind:value={searchInput} />
+
      </div>
+
    </Topbar>
+
    <div class="content">
+
      {#each groupedCommits as group (group.key)}
+
        <section class="group">
+
          <div class="group-title">{group.label}</div>
+
          <div class="commit-list">
+
            {#each group.commits as commit (commit.id)}
+
              <div
+
                class="commit-item"
+
                role="button"
+
                tabindex="0"
+
                onclick={() => {
+
                  void router.push({
+
                    resource: "repo.commit",
+
                    rid: repo.rid,
+
                    commit: commit.id,
+
                  });
+
                }}
+
                onkeydown={e => {
+
                  if (e.key === "Enter" || e.key === " ") {
+
                    e.preventDefault();
+
                    void router.push({
+
                      resource: "repo.commit",
+
                      rid: repo.rid,
+
                      commit: commit.id,
+
                    });
+
                  }
+
                }}>
+
                <CobCommitTeaser {commit} disabled={false} flush hoverable />
+
              </div>
+
            {/each}
+
          </div>
+
        </section>
+
      {/each}
+

+
      {#if filteredCommits.length === 0}
+
        <div
+
          class="global-flex"
+
          style:flex="1"
+
          style:justify-content="center"
+
          style:align-items="center">
+
          <div
+
            class="txt-missing txt-body-m-regular global-flex"
+
            style:gap="0.25rem">
+
            {#if items.length > 0}
+
              No matching commits
+
            {:else}
+
              No commits
+
            {/if}
+
          </div>
+
        </div>
+
      {/if}
+

+
      <InfiniteScrollSentinel
+
        onIntersect={loadMoreContent}
+
        disabled={!more || loadingMore} />
+
    </div>
+
  </div>
+
</Layout>
modified src/views/repo/router.ts
@@ -8,13 +8,15 @@ import type { Review } from "@bindings/cob/patch/Review";
import type { Revision } from "@bindings/cob/patch/Revision";
import type { Thread } from "@bindings/cob/thread/Thread";
import type { Config } from "@bindings/config/Config";
+
import type { Diff } from "@bindings/diff/Diff";
+
import type { Commit } from "@bindings/repo/Commit";
import type { Readme } from "@bindings/repo/Readme";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { Tree } from "@bindings/source/Tree";

import type { DraftReview } from "@app/lib/draftReviewStorage";
import { draftReviewStorage } from "@app/lib/draftReviewStorage";
-
import { invoke } from "@app/lib/invoke";
+
import { cachedGetCommitDiff, invoke } from "@app/lib/invoke";
import type { SidebarData } from "@app/lib/router/definitions";
import { loadSidebarData } from "@app/lib/router/definitions";
import { didFromPublicKey, unreachable } from "@app/lib/utils";
@@ -22,6 +24,7 @@ import { didFromPublicKey, unreachable } from "@app/lib/utils";
export type IssueStatus = "all" | Issue["state"]["status"];

export const DEFAULT_TAKE = 20;
+
export const COMMITS_PAGE_SIZE = 300;

export interface RepoHomeRoute {
  resource: "repo.home";
@@ -29,6 +32,17 @@ export interface RepoHomeRoute {
  rid: string;
}

+
export interface RepoCommitsRoute {
+
  resource: "repo.commits";
+
  rid: string;
+
}
+

+
export interface RepoCommitRoute {
+
  resource: "repo.commit";
+
  rid: string;
+
  commit: string;
+
}
+

export interface RepoIssueRoute {
  resource: "repo.issue";
  rid: string;
@@ -47,6 +61,25 @@ export interface LoadedRepoHomeRoute {
  };
}

+
export interface LoadedRepoCommitsRoute {
+
  resource: "repo.commits";
+
  params: {
+
    repo: RepoInfo;
+
    commits: PaginatedQuery<Commit[]>;
+
    sidebarData: SidebarData;
+
  };
+
}
+

+
export interface LoadedRepoCommitRoute {
+
  resource: "repo.commit";
+
  params: {
+
    repo: RepoInfo;
+
    commit: Commit;
+
    diff: Diff;
+
    sidebarData: SidebarData;
+
  };
+
}
+

export interface LoadedRepoIssueRoute {
  resource: "repo.issue";
  params: {
@@ -120,12 +153,16 @@ export interface LoadedRepoPatchesRoute {

export type RepoRoute =
  | RepoHomeRoute
+
  | RepoCommitsRoute
+
  | RepoCommitRoute
  | RepoIssueRoute
  | RepoIssuesRoute
  | RepoPatchRoute
  | RepoPatchesRoute;
export type LoadedRepoRoute =
  | LoadedRepoHomeRoute
+
  | LoadedRepoCommitsRoute
+
  | LoadedRepoCommitRoute
  | LoadedRepoIssueRoute
  | LoadedRepoIssuesRoute
  | LoadedRepoPatchRoute
@@ -235,6 +272,48 @@ export async function loadRepoHome(
  };
}

+
export async function loadRepoCommits(
+
  route: RepoCommitsRoute,
+
): Promise<LoadedRepoCommitsRoute> {
+
  const [sidebarData, repo, commits] = await Promise.all([
+
    loadSidebarData(),
+
    invoke<RepoInfo>("repo_by_id", {
+
      rid: route.rid,
+
    }),
+
    invoke<PaginatedQuery<Commit[]>>("list_repo_commits", {
+
      rid: route.rid,
+
      skip: 0,
+
      take: COMMITS_PAGE_SIZE,
+
    }),
+
  ]);
+

+
  return {
+
    resource: "repo.commits",
+
    params: { sidebarData, repo, commits },
+
  };
+
}
+

+
export async function loadRepoCommit(
+
  route: RepoCommitRoute,
+
): Promise<LoadedRepoCommitRoute> {
+
  const [sidebarData, repo, commit, diff] = await Promise.all([
+
    loadSidebarData(),
+
    invoke<RepoInfo>("repo_by_id", {
+
      rid: route.rid,
+
    }),
+
    invoke<Commit>("repo_commit", {
+
      rid: route.rid,
+
      sha: route.commit,
+
    }),
+
    cachedGetCommitDiff(route.rid, route.commit, 3, true),
+
  ]);
+

+
  return {
+
    resource: "repo.commit",
+
    params: { sidebarData, repo, commit, diff },
+
  };
+
}
+

export async function loadIssue(
  route: RepoIssueRoute,
): Promise<LoadedRepoIssueRoute> {
@@ -304,6 +383,10 @@ export function repoRouteToPath(route: RepoRoute): string {
  if (route.resource === "repo.home") {
    const url = [...pathSegments, "home"].join("/");
    return url;
+
  } else if (route.resource === "repo.commits") {
+
    return [...pathSegments, "commits"].join("/");
+
  } else if (route.resource === "repo.commit") {
+
    return [...pathSegments, "commits", route.commit].join("/");
  } else if (route.resource === "repo.issue") {
    let url = [...pathSegments, "issues", route.issue].join("/");
    searchParams.set("status", route.status);
@@ -348,6 +431,14 @@ export function repoUrlToRoute(
  if (rid) {
    if (resource === "home") {
      return { resource: "repo.home", rid, sha: segments.shift() };
+
    } else if (resource === "commits") {
+
      const sha = segments.shift();
+

+
      if (sha) {
+
        return { resource: "repo.commit", rid, commit: sha };
+
      }
+

+
      return { resource: "repo.commits", rid };
    } else if (resource === "issues") {
      const idOrAction = segments.shift();
      if (idOrAction) {