Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add repo commit history views
Merged julien opened 17 days ago
21 files changed +1114 -86 dc74329d 65c453e3
modified crates/radicle-tauri/src/commands/repo.rs
@@ -80,6 +80,15 @@ pub async fn diff_stats(
}

#[tauri::command]
+
pub async fn commit_diff_stats(
+
    ctx: tauri::State<'_, AppState>,
+
    rid: RepoId,
+
    sha: git::Oid,
+
) -> Result<types::diff::Stats, Error> {
+
    ctx.commit_diff_stats(rid, sha)
+
}
+

+
#[tauri::command]
pub async fn list_commits(
    ctx: tauri::State<'_, AppState>,
    rid: RepoId,
@@ -90,6 +99,41 @@ 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,
+
    skip: Option<usize>,
+
    take: Option<usize>,
+
) -> Result<types::cobs::PaginatedQuery<Vec<types::repo::Commit>>, Error> {
+
    ctx.list_repo_commits(rid, skip, take)
+
}
+

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

+
#[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,8 @@ pub fn run() {
            cob::save_embed_by_path,
            cob::save_embed_to_disk,
            diff::get_diff,
+
            repo::commit_diff_stats,
+
            repo::get_commit_diff,
            inbox::clear_notifications,
            inbox::notification_count,
            inbox::list_notifications,
@@ -57,9 +59,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
@@ -248,6 +248,27 @@ pub trait Repo: Profile {
        Ok::<_, Error>(diff::Stats::new(stats))
    }

+
    fn commit_diff_stats(
+
        &self,
+
        rid: identity::RepoId,
+
        sha: git::Oid,
+
    ) -> Result<diff::Stats, Error> {
+
        let profile = self.profile();
+
        let repo = profile.storage.repository(rid)?.backend;
+
        let head = repo.find_commit(sha.into())?;
+
        let left = head
+
            .parents()
+
            .next()
+
            .map(|parent| parent.tree())
+
            .transpose()?;
+
        let right = head.tree()?;
+

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

+
        Ok::<_, Error>(diff::Stats::new(diff.stats()))
+
    }
+

    fn repo_info(
        &self,
        repo: &storage::git::Repository,
@@ -337,6 +358,46 @@ 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 {
+
            let mut hi = Highlighter::new();
+

+
            return Ok::<_, Error>(diff.pretty(&mut hi, &(), &repo));
+
        }
+

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

    fn list_commits(
        &self,
        rid: identity::RepoId,
@@ -363,6 +424,73 @@ pub trait Repo: Profile {
        Ok(commits)
    }

+
    fn list_repo_commits(
+
        &self,
+
        rid: identity::RepoId,
+
        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 = repo.head()?;
+
        let cursor = skip.unwrap_or(0);
+

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

+
                Ok(crate::cobs::PaginatedQuery {
+
                    cursor: 0,
+
                    more: false,
+
                    content,
+
                })
+
            }
+
            Some(take) => {
+
                let mut content: Vec<repo::Commit> = repo
+
                    .history(head.to_string())?
+
                    .filter_map(|c| c.map(Into::into).ok())
+
                    .skip(cursor)
+
                    .take(take + 1)
+
                    .collect();
+

+
                let more = content.len() > take;
+
                content.truncate(take);
+

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

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

+
        let repo = surf::Repository::open(repo.path())?;
+
        let head = repo.head()?;
+
        let count = repo.history(head.to_string())?.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
@@ -97,6 +97,12 @@ pub fn router(ctx: Context) -> Router {
        .route("/save_embed_by_clipboard", post(save_embed_handler))
        .route("/save_embed_by_bytes", post(save_embed_handler))
        .route("/save_embed_to_disk", post(save_embed_handler))
+
        .route("/list_commits", post(list_commits_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("/commit_diff_stats", post(commit_diff_stats_handler))
+
        .route("/get_commit_diff", post(get_commit_diff_handler))
        .route("/list_jobs", post(jobs_handler))
        .route("/list_notifications", post(list_notifications_handler))
        .route("/notification_count", post(notification_count_handler))
@@ -520,3 +526,90 @@ async fn jobs_handler(

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

+
#[derive(Serialize, Deserialize)]
+
struct ListCommitsBody {
+
    pub rid: identity::RepoId,
+
    pub base: String,
+
    pub head: String,
+
}
+

+
async fn list_commits_handler(
+
    State(ctx): State<Context>,
+
    Json(ListCommitsBody { rid, base, head }): Json<ListCommitsBody>,
+
) -> impl IntoResponse {
+
    let commits = ctx.list_commits(rid, base, head)?;
+

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

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

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

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

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

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

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

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

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

+
async fn commit_diff_stats_handler(
+
    State(ctx): State<Context>,
+
    Json(CommitBody { rid, sha }): Json<CommitBody>,
+
) -> impl IntoResponse {
+
    let stats = ctx.commit_diff_stats(rid, sha)?;
+

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

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

+
async fn get_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))
+
}
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
@@ -16,14 +16,18 @@
    disabled: boolean;
    hoverable?: boolean;
    onclick: () => void;
+
    flush?: boolean;
+
    timeOnly?: boolean;
  }

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

  let commitMessageVisible = $state(false);
@@ -81,7 +85,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} />
@@ -108,7 +112,7 @@
  </div>
  <div class="right">
    {@render children?.()}
-
    <CompactCommitAuthorship {commit}>
+
    <CompactCommitAuthorship {commit} {timeOnly}>
      <Id id={commit.id} clipboard={commit.id} />
    </CompactCommitAuthorship>
  </div>
modified src/components/CompactCommitAuthorship.svelte
@@ -9,9 +9,17 @@
  interface Props {
    children: Snippet;
    commit: Commit;
+
    timeOnly?: boolean;
  }

-
  const { children, commit }: Props = $props();
+
  const { children, commit, timeOnly = false }: Props = $props();
+

+
  function formatTime(timestamp: number): string {
+
    return new Date(timestamp).toLocaleTimeString("en-GB", {
+
      hour: "2-digit",
+
      minute: "2-digit",
+
    });
+
  }
</script>

<style>
@@ -96,6 +104,8 @@
  </HoverPopover>
  {@render children()}
  <div title={utils.absoluteTimestamp(commit.committer.time * 1000)}>
-
    {utils.formatTimestamp(commit.committer.time * 1000)}
+
    {timeOnly
+
      ? formatTime(commit.committer.time * 1000)
+
      : utils.formatTimestamp(commit.committer.time * 1000)}
  </div>
</div>
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/ScrollArea.svelte
@@ -14,6 +14,15 @@
    style = "height: 100%;",
    onScrollHalf = undefined,
  }: Props = $props();
+

+
  function shouldLoadMore(instance: {
+
    elements(): { target: HTMLElement };
+
  }): boolean {
+
    const el = instance.elements().target;
+
    const threshold = 200;
+

+
    return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
+
  }
</script>

<OverlayScrollbarsComponent
@@ -24,9 +33,18 @@
  }}
  events={onScrollHalf
    ? {
+
        initialized: instance => {
+
          if (shouldLoadMore(instance)) {
+
            onScrollHalf();
+
          }
+
        },
        scroll: instance => {
-
          const el = instance.elements().target;
-
          if (el.scrollTop + el.clientHeight >= el.scrollHeight / 2) {
+
          if (shouldLoadMore(instance)) {
+
            onScrollHalf();
+
          }
+
        },
+
        updated: instance => {
+
          if (shouldLoadMore(instance)) {
            onScrollHalf();
          }
        },
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",
added src/components/Topbar.svelte
@@ -0,0 +1,27 @@
+
<script lang="ts">
+
  import type { Snippet } from "svelte";
+

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

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

+
<style>
+
  .topbar {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.75rem;
+
    padding: 0 1rem;
+
    height: 2.75rem;
+
    flex-shrink: 0;
+
    border-bottom: 1px solid var(--color-border-subtle);
+
    font: var(--txt-body-m-regular);
+
    color: var(--color-text-secondary);
+
  }
+
</style>
+

+
<div class="topbar">
+
  {@render children()}
+
</div>
modified src/lib/invoke.ts
@@ -1,4 +1,5 @@
import type { DiffOptions } from "@bindings/cob/DiffOptions";
+
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
import type { Diff } from "@bindings/diff/Diff";
import type { Stats } from "@bindings/diff/Stats";
import type { Commit } from "@bindings/repo/Commit";
@@ -103,6 +104,44 @@ export const cachedListCommits = cached(
  { max: 5_000 },
);

+
async function listRepoCommits(
+
  rid: string,
+
  skip?: number,
+
  take?: number,
+
): Promise<PaginatedQuery<Commit[]>> {
+
  return withTestBackend(tauri.invoke, "list_repo_commits", {
+
    rid,
+
    skip,
+
    take,
+
  });
+
}
+

+
export const cachedListRepoCommits = cached(
+
  listRepoCommits,
+
  (...[rid, skip, take]) => `list_repo_commits:${rid}:${skip}:${take}`,
+
  { max: 5_000 },
+
);
+

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

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

+
async function repoCommit(rid: string, sha: string): Promise<Commit> {
+
  return withTestBackend(tauri.invoke, "repo_commit", { rid, sha });
+
}
+

+
export const cachedRepoCommit = cached(
+
  repoCommit,
+
  (...[rid, sha]) => `repo_commit:${rid}:${sha}`,
+
  { max: 5_000 },
+
);
+

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

+
async function commitDiffStats(rid: string, sha: string): Promise<Stats> {
+
  return withTestBackend(tauri.invoke, "commit_diff_stats", { rid, sha });
+
}
+

+
export const cachedCommitDiffStats = cached(
+
  commitDiffStats,
+
  (...[rid, sha]) => `commit_diff_stats:${rid}:${sha}`,
+
  { 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,
+
  });
+
}
+

+
export const cachedGetCommitDiff = cached(
+
  getCommitDiff,
+
  (...[rid, sha, unified, highlight]) =>
+
    `get_commit_diff:${rid}:${sha}:${unified}:${highlight}`,
+
  { max: 10_000 },
+
);
+

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") {
modified src/views/repo/Issue.svelte
@@ -36,6 +36,7 @@
  import IssueTimeline from "@app/components/IssueTimeline.svelte";
  import LabelInput from "@app/components/LabelInput.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
+
  import Topbar from "@app/components/Topbar.svelte";
  import CreateIssueModal from "@app/modals/CreateIssue.svelte";

  import Layout from "./Layout.svelte";
@@ -265,18 +266,12 @@
    flex-direction: column;
    height: 100%;
  }
-
  .topbar {
+
  .breadcrumb {
    display: flex;
    align-items: center;
-
    padding: 0 1rem;
-
    height: 2.5rem;
-
    flex-shrink: 0;
    gap: 0.375rem;
-
    border-bottom: 1px solid var(--color-border-subtle);
-
    font: var(--txt-body-m-regular);
-
    color: var(--color-text-secondary);
  }
-
  .topbar-link {
+
  .breadcrumb-link {
    cursor: pointer;
    background: none;
    border: none;
@@ -284,7 +279,7 @@
    font: var(--txt-body-m-regular);
    color: var(--color-text-secondary);
  }
-
  .topbar-link:hover {
+
  .breadcrumb-link:hover {
    color: var(--color-text-primary);
  }
  .content {
@@ -345,23 +340,25 @@

<Layout>
  <div class="page">
-
    <div class="topbar">
-
      <Icon name={issue.state.status === "open" ? "issue" : "issue-closed"} />
-
      <button
-
        class="topbar-link"
-
        onclick={() =>
-
          router.push({
-
            resource: "repo.issues",
-
            rid: repo.rid,
-
            status: "all",
-
          })}>
-
        All Issues
-
      </button>
-
      <Icon name="chevron-right" />
-
      <Id id={issue.id} clipboard={issue.id} placement="bottom-start" />
-
      <ExternalLink
-
        href={explorerUrl(`${repo.rid}/issues/${issue.id}`)}
-
        title="Open in app.radicle.xyz" />
+
    <Topbar>
+
      <div class="breadcrumb">
+
        <Icon name={issue.state.status === "open" ? "issue" : "issue-closed"} />
+
        <button
+
          class="breadcrumb-link"
+
          onclick={() =>
+
            router.push({
+
              resource: "repo.issues",
+
              rid: repo.rid,
+
              status: "all",
+
            })}>
+
          All Issues
+
        </button>
+
        <Icon name="chevron-right" />
+
        <Id id={issue.id} clipboard={issue.id} placement="bottom-start" />
+
        <ExternalLink
+
          href={explorerUrl(`${repo.rid}/issues/${issue.id}`)}
+
          title="Open in app.radicle.xyz" />
+
      </div>
      <div style:margin-left="auto">
        <Button
          styleHeight="2rem"
@@ -374,7 +371,7 @@
          <Icon name="plus" />New issue
        </Button>
      </div>
-
    </div>
+
    </Topbar>

    <ScrollArea style="flex: 1; min-height: 0;">
      <div class="content">
modified src/views/repo/Issues.svelte
@@ -23,6 +23,7 @@
  import Icon from "@app/components/Icon.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
+
  import Topbar from "@app/components/Topbar.svelte";
  import CreateIssueModal from "@app/modals/CreateIssue.svelte";

  import Layout from "./Layout.svelte";
@@ -105,15 +106,6 @@
    flex-direction: column;
    height: 100%;
  }
-
  .topbar {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.75rem;
-
    padding: 0 1rem;
-
    height: 2.75rem;
-
    flex-shrink: 0;
-
    border-bottom: 1px solid var(--color-border-subtle);
-
  }
  .topbar-title {
    font: var(--txt-body-m-semibold);
    color: var(--color-text-secondary);
@@ -156,7 +148,7 @@

<Layout selfScroll>
  <div class="page">
-
    <div class="topbar">
+
    <Topbar>
      <span class="topbar-title">Issues</span>
      <div class="filters">
        <a
@@ -222,7 +214,7 @@
          <Icon name="plus" />New issue
        </Button>
      </div>
-
    </div>
+
    </Topbar>

    <ScrollArea style="height: 100%; min-width: 0;">
      {#if issueCountMismatch(status)}
modified src/views/repo/Patch.svelte
@@ -34,6 +34,7 @@
  import RevisionComponent from "@app/components/Revision.svelte";
  import Revisions from "@app/components/Revisions.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
+
  import Topbar from "@app/components/Topbar.svelte";

  import Layout from "./Layout.svelte";

@@ -182,18 +183,12 @@
    flex-direction: column;
    height: 100%;
  }
-
  .topbar {
+
  .breadcrumb {
    display: flex;
    align-items: center;
-
    padding: 0 1rem;
-
    height: 2.5rem;
-
    flex-shrink: 0;
    gap: 0.375rem;
-
    border-bottom: 1px solid var(--color-border-subtle);
-
    font: var(--txt-body-m-regular);
-
    color: var(--color-text-secondary);
  }
-
  .topbar-link {
+
  .breadcrumb-link {
    cursor: pointer;
    background: none;
    border: none;
@@ -201,7 +196,7 @@
    font: var(--txt-body-m-regular);
    color: var(--color-text-secondary);
  }
-
  .topbar-link:hover {
+
  .breadcrumb-link:hover {
    color: var(--color-text-primary);
  }
  .content {
@@ -260,30 +255,32 @@
      }} />
  {:else}
    <div class="page">
-
      <div class="topbar">
-
        <Icon
-
          name={patch.state.status === "open"
-
            ? "patch"
-
            : `patch-${patch.state.status}`} />
-
        <button
-
          class="topbar-link"
-
          onclick={() =>
-
            router.push({
-
              resource: "repo.patches",
-
              rid: repo.rid,
-
              status: undefined,
-
            })}>
-
          All Patches
-
        </button>
-
        <Icon name="chevron-right" />
-
        <Id id={patch.id} clipboard={patch.id} placement="bottom-start" />
-
        <ExternalLink
-
          href={explorerUrl(`${repo.rid}/patches/${patch.id}`)}
-
          title="Open in app.radicle.xyz" />
+
      <Topbar>
+
        <div class="breadcrumb">
+
          <Icon
+
            name={patch.state.status === "open"
+
              ? "patch"
+
              : `patch-${patch.state.status}`} />
+
          <button
+
            class="breadcrumb-link"
+
            onclick={() =>
+
              router.push({
+
                resource: "repo.patches",
+
                rid: repo.rid,
+
                status: undefined,
+
              })}>
+
            All Patches
+
          </button>
+
          <Icon name="chevron-right" />
+
          <Id id={patch.id} clipboard={patch.id} placement="bottom-start" />
+
          <ExternalLink
+
            href={explorerUrl(`${repo.rid}/patches/${patch.id}`)}
+
            title="Open in app.radicle.xyz" />
+
        </div>
        <div style:margin-left="auto">
          <NewPatchButton rid={repo.rid} ghost />
        </div>
-
      </div>
+
      </Topbar>

      <ScrollArea style="flex: 1; min-height: 0;">
        <div class="content">
modified src/views/repo/Patches.svelte
@@ -25,6 +25,7 @@
  import NewPatchButton from "@app/components/NewPatchButton.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
  import ScrollArea from "@app/components/ScrollArea.svelte";
+
  import Topbar from "@app/components/Topbar.svelte";

  import Layout from "./Layout.svelte";

@@ -163,15 +164,6 @@
    flex-direction: column;
    height: 100%;
  }
-
  .topbar {
-
    display: flex;
-
    align-items: center;
-
    gap: 0.75rem;
-
    padding: 0 1rem;
-
    height: 2.75rem;
-
    flex-shrink: 0;
-
    border-bottom: 1px solid var(--color-border-subtle);
-
  }
  .topbar-title {
    font: var(--txt-body-m-semibold);
    color: var(--color-text-secondary);
@@ -225,7 +217,7 @@

<Layout selfScroll>
  <div class="page">
-
    <div class="topbar">
+
    <Topbar>
      <span class="topbar-title">Patches</span>
      <div class="filters">
        <a
@@ -328,7 +320,7 @@
          bind:value={searchInput} />
        <NewPatchButton rid={repo.rid} />
      </div>
-
    </div>
+
    </Topbar>

    <ScrollArea
      style="height: 100%; min-width: 0;"
added src/views/repo/RepoCommit.svelte
@@ -0,0 +1,271 @@
+
<script lang="ts">
+
  import type { Diff } from "@bindings/diff/Diff";
+
  import type { Stats } from "@bindings/diff/Stats";
+
  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;
+
    stats: Stats;
+
    diff: Diff;
+
  }
+

+
  const { repo, commit, stats, 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>
+
              {#if commit.author.email === commit.committer.email}
+
                <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>
+
              {:else}
+
                <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>
+
                  authored
+
                  <span
+
                    class="summary-timestamp"
+
                    title={absoluteTimestamp(commit.author.time * 1000)}>
+
                    {formatTimestamp(commit.author.time * 1000)}
+
                  </span>
+
                </div>
+
                <div class="summary-meta">
+
                  <span class="summary-author">
+
                    <img
+
                      class="summary-avatar"
+
                      alt=""
+
                      src={gravatarURL(commit.committer.email)} />
+
                    <span class="txt-selectable">{commit.committer.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>
+
              {/if}
+
              <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">
+
                {stats.filesChanged}
+
                {pluralize("file", stats.filesChanged)} changed
+
              </div>
+
              <DiffStatBadge {stats} />
+
            </div>
+
          </div>
+
        </section>
+

+
        <Changeset {diff} head={commit.id} />
+
      </div>
+
    </ScrollArea>
+
  </div>
+
</Layout>
added src/views/repo/RepoCommits.svelte
@@ -0,0 +1,218 @@
+
<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 { DEFAULT_TAKE } from "@app/views/repo/router";
+

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

+
  import CobCommitTeaser from "@app/components/CobCommitTeaser.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(() => {
+
    commitCount = undefined;
+
    void cachedRepoCommitCount(repo.rid, project.meta.head)
+
      .then(count => {
+
        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);
+

+
  $effect(() => {
+
    items = commits.content;
+
    more = commits.more;
+
  });
+

+
  async function loadMoreContent() {
+
    if (!more) return;
+

+
    loadingMore = true;
+
    try {
+
      const page = await invoke<PaginatedQuery<Commit[]>>("list_repo_commits", {
+
        rid: repo.rid,
+
        skip: items.length,
+
        take: DEFAULT_TAKE,
+
      });
+

+
      more = page.more;
+
      items = [...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 groupedCommits = $derived.by<CommitGroup[]>(() => {
+
    const groups = new Map<string, CommitGroup>();
+

+
    for (const commit of items) {
+
      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;
+
  }
+
  .content {
+
    padding: 1rem;
+
  }
+
  .group + .group {
+
    margin-top: 1.5rem;
+
  }
+
  .group-title {
+
    color: var(--color-text-tertiary);
+
    font: var(--txt-body-m-medium);
+
    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;
+
  }
+
  .commit-item + .commit-item {
+
    border-top: 1px solid var(--color-border-subtle);
+
  }
+
  .commit-item:hover {
+
    background: var(--color-surface-subtle);
+
  }
+
  .loading {
+
    padding: 1rem 0;
+
    color: var(--color-text-secondary);
+
    font: var(--txt-body-m-regular);
+
    text-align: center;
+
  }
+
</style>
+

+
<Layout loadMoreContent={more ? loadMoreContent : undefined}>
+
  <div class="page">
+
    <Topbar>
+
      <span class="topbar-title">
+
        Commits
+
        {#if commitCount !== undefined}
+
          <span class="global-counter-badge">{commitCount}</span>
+
        {/if}
+
      </span>
+
    </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">
+
                <CobCommitTeaser
+
                  {commit}
+
                  disabled={false}
+
                  flush
+
                  hoverable
+
                  timeOnly
+
                  onclick={() => {
+
                    void router.push({
+
                      resource: "repo.commit",
+
                      rid: repo.rid,
+
                      commit: commit.id,
+
                    });
+
                  }} />
+
              </div>
+
            {/each}
+
          </div>
+
        </section>
+
      {/each}
+

+
      {#if loadingMore}
+
        <div class="loading">Loading more commits…</div>
+
      {/if}
+
    </div>
+
  </div>
+
</Layout>
modified src/views/repo/router.ts
@@ -8,6 +8,9 @@ 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 { Stats } from "@bindings/diff/Stats";
+
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";
@@ -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,26 @@ 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;
+
    stats: Stats;
+
    diff: Diff;
+
    sidebarData: SidebarData;
+
  };
+
}
+

export interface LoadedRepoIssueRoute {
  resource: "repo.issue";
  params: {
@@ -120,12 +154,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 +273,57 @@ 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: DEFAULT_TAKE,
+
    }),
+
  ]);
+

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

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

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

export async function loadIssue(
  route: RepoIssueRoute,
): Promise<LoadedRepoIssueRoute> {
@@ -304,6 +393,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 +441,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) {