Add commit history list and single commit views

Co-authored-by: Julien Donck <donckjulien@gmail.com>
This commit is contained in:
Rūdolfs Ošiņš
2026-04-27 18:00:14 +02:00
co-authored by Julien Donck
parent bb0ae84156
commit 65c453e37d
14 changed files with 1003 additions and 5 deletions
+40
View File
@@ -89,6 +89,46 @@ pub async fn list_commits(
ctx.list_commits(rid, base, head)
}
#[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>,
+4
View File
@@ -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,
+109
View File
@@ -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());
+78
View File
@@ -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))
@@ -261,6 +265,80 @@ async fn diff_handler(
Ok::<_, Error>(Json(info))
}
#[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 {
+6
View File
@@ -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"}
+5 -3
View File
@@ -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} />
+4
View File
@@ -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;
+42 -1
View File
@@ -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);
@@ -44,6 +45,25 @@
seededNotReplicated = initialSeededNotReplicated;
});
$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" ||
@@ -394,6 +422,19 @@
</a>
{#if activeRid() === repo.rid}
{@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)}
+33
View File
@@ -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?: {
+2
View File
@@ -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" ||
+8
View File
@@ -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") {
+235
View File
@@ -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>
+345
View File
@@ -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>
+92 -1
View File
@@ -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) {