Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add commit history list and single commit views
Rūdolfs Ošiņš committed 23 days ago
commit 65c453e37df766fec579c9ab8d1f29c5d61b6aaa
parent bb0ae8415652724af873e51bf8fd89e4258c557a
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) {