mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
Add commit history list and single commit views
Co-authored-by: Julien Donck <donckjulien@gmail.com>
This commit is contained in:
co-authored by
Julien Donck
parent
bb0ae84156
commit
65c453e37d
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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,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") {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user