diff --git a/crates/radicle-tauri/src/commands/repo.rs b/crates/radicle-tauri/src/commands/repo.rs index 55b1a62d2..b607517bb 100644 --- a/crates/radicle-tauri/src/commands/repo.rs +++ b/crates/radicle-tauri/src/commands/repo.rs @@ -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, + highlight: Option, +) -> Result { + ctx.get_commit_diff(rid, sha, unified, highlight) +} + +#[tauri::command] +pub async fn list_repo_commits( + ctx: tauri::State<'_, AppState>, + rid: RepoId, + head: Option, + skip: Option, + take: Option, +) -> Result>, Error> { + ctx.list_repo_commits(rid, head, skip, take) +} + +#[tauri::command] +pub fn repo_commit_count( + ctx: tauri::State, + rid: RepoId, + head: git::Oid, +) -> Result { + ctx.repo_commit_count(rid, head) +} + +#[tauri::command] +pub fn repo_commit( + ctx: tauri::State, + rid: RepoId, + sha: git::Oid, +) -> Result { + ctx.repo_commit(rid, sha) +} + #[tauri::command] pub(crate) async fn create_repo( ctx: tauri::State<'_, AppState>, diff --git a/crates/radicle-tauri/src/lib.rs b/crates/radicle-tauri/src/lib.rs index 73bb81434..9edd15a9c 100644 --- a/crates/radicle-tauri/src/lib.rs +++ b/crates/radicle-tauri/src/lib.rs @@ -49,6 +49,7 @@ pub fn run() { cob::save_embed_by_path, cob::save_embed_to_disk, diff::get_diff, + repo::get_commit_diff, inbox::clear_notifications, inbox::notification_count, inbox::list_notifications, @@ -57,9 +58,12 @@ pub fn run() { repo::create_repo, repo::diff_stats, repo::list_commits, + repo::list_repo_commits, repo::list_repos, repo::list_repos_summary, repo::repo_by_id, + repo::repo_commit_count, + repo::repo_commit, repo::repo_count, repo::repo_readme, repo::repo_tree, diff --git a/crates/radicle-types/src/traits/repo.rs b/crates/radicle-types/src/traits/repo.rs index 7c1085fbd..0494c2ff4 100644 --- a/crates/radicle-types/src/traits/repo.rs +++ b/crates/radicle-types/src/traits/repo.rs @@ -335,6 +335,44 @@ pub trait Repo: Profile { Ok::<_, Error>(diff.into()) } + fn get_commit_diff( + &self, + rid: identity::RepoId, + sha: git::Oid, + unified: Option, + highlight: Option, + ) -> Result { + 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, + skip: Option, + take: Option, + ) -> Result>, 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 = + commits.filter_map(|c| c.map(Into::into).ok()).collect(); + + Ok(crate::cobs::PaginatedQuery { + cursor: 0, + more: false, + content, + }) + } + Some(take) => { + let content: Vec = 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 { + 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 { + 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()); diff --git a/crates/test-http-api/src/api.rs b/crates/test-http-api/src/api.rs index eab2252e9..28fbcdc35 100644 --- a/crates/test-http-api/src/api.rs +++ b/crates/test-http-api/src/api.rs @@ -83,6 +83,10 @@ pub fn router(ctx: Context) -> Router { .route("/repo_tree", post(tree_handler)) .route("/repo_blob", post(blob_handler)) .route("/get_diff", post(diff_handler)) + .route("/get_commit_diff", post(commit_diff_handler)) + .route("/list_repo_commits", post(list_repo_commits_handler)) + .route("/repo_commit_count", post(repo_commit_count_handler)) + .route("/repo_commit", post(repo_commit_handler)) .route("/list_issues", post(issues_handler)) .route("/create_issue", post(create_issue_handler)) .route("/create_issue_comment", post(create_issue_comment_handler)) @@ -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, + pub highlight: Option, +} + +async fn commit_diff_handler( + State(ctx): State, + Json(CommitDiffBody { + rid, + sha, + unified, + highlight, + }): Json, +) -> 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, + pub skip: Option, + pub take: Option, +} + +async fn list_repo_commits_handler( + State(ctx): State, + Json(ListRepoCommitsBody { + rid, + head, + skip, + take, + }): Json, +) -> 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, + Json(RepoCommitCountBody { rid, head }): Json, +) -> 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, + Json(RepoCommitBody { rid, sha }): Json, +) -> impl IntoResponse { + let commit = ctx.repo_commit(rid, sha)?; + + Ok::<_, Error>(Json(commit)) +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct IssuesBody { diff --git a/src/App.svelte b/src/App.svelte index 622eed53e..efd69b559 100644 --- a/src/App.svelte +++ b/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 @@ {:else if $activeRouteStore.resource === "repo.home"} + {:else if $activeRouteStore.resource === "repo.commits"} + + {:else if $activeRouteStore.resource === "repo.commit"} + {:else if $activeRouteStore.resource === "repo.issue"} {:else if $activeRouteStore.resource === "repo.issues"} diff --git a/src/components/CobCommitTeaser.svelte b/src/components/CobCommitTeaser.svelte index 0c76e684e..e25bb3bce 100644 --- a/src/components/CobCommitTeaser.svelte +++ b/src/components/CobCommitTeaser.svelte @@ -15,15 +15,17 @@ commit: Commit; disabled: boolean; hoverable?: boolean; - onclick: () => void; + onclick?: () => void; + flush?: boolean; } const { children, commit, disabled, + flush = false, hoverable = false, - onclick, + onclick = undefined, }: Props = $props(); let commitMessageVisible = $state(false); @@ -81,7 +83,7 @@
-
+
diff --git a/src/components/FileBlock.svelte b/src/components/FileBlock.svelte index 72f91e076..d152599ce 100644 --- a/src/components/FileBlock.svelte +++ b/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; diff --git a/src/components/SidebarRepoList.svelte b/src/components/SidebarRepoList.svelte index 60c316a64..b7198bb60 100644 --- a/src/components/SidebarRepoList.svelte +++ b/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(initialRepos); let seededNotReplicated = $state(initialSeededNotReplicated); + let activeCommitCount = $state(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 @@ {#if activeRid() === repo.rid} {@const activeProject = activeRepo?.payloads["xyz.radicle.project"]} + + + Commits + {#if activeCommitCount !== undefined} + {activeCommitCount} + {/if} + { + 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 { + 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?: { diff --git a/src/lib/router.ts b/src/lib/router.ts index 59e0cf4dc..c2d2a5309 100644 --- a/src/lib/router.ts +++ b/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" || diff --git a/src/lib/router/definitions.ts b/src/lib/router/definitions.ts index fe22b8caf..9f9ba74db 100644 --- a/src/lib/router/definitions.ts +++ b/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") { diff --git a/src/views/repo/RepoCommit.svelte b/src/views/repo/RepoCommit.svelte new file mode 100644 index 000000000..c7a459436 --- /dev/null +++ b/src/views/repo/RepoCommit.svelte @@ -0,0 +1,235 @@ + + + + + +
+ + + + +
+
+
+
+
{commit.summary}
+
+ + + {commit.author.name} + + committed + + + {formatTimestamp(commit.committer.time * 1000)} + +
+
+ + {commit.parents.length === 1 ? "parent" : "parents"} + + {#if commit.parents.length === 0} + Initial commit + {:else} + {#each commit.parents as parent} + + {/each} + {/if} +
+
{commit.message
+                  .replace(commit.summary, "")
+                  .trim()}
+
+
+
+ {diff.stats.filesChanged} + {pluralize("file", diff.stats.filesChanged)} changed +
+ +
+
+
+ + +
+
+
+
diff --git a/src/views/repo/RepoCommits.svelte b/src/views/repo/RepoCommits.svelte new file mode 100644 index 000000000..755cc67dd --- /dev/null +++ b/src/views/repo/RepoCommits.svelte @@ -0,0 +1,345 @@ + + + + + +
+ + + Commits + + + {project.data.defaultBranch} + canonical + + {#if commitCount !== undefined} + {commitCount} + {/if} + +
+ 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} /> +
+
+
+ {#each groupedCommits as group (group.key)} +
+
{group.label}
+
+ {#each group.commits as commit (commit.id)} +
{ + 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, + }); + } + }}> + +
+ {/each} +
+
+ {/each} + + {#if filteredCommits.length === 0} +
+
+ {#if items.length > 0} + No matching commits + {:else} + No commits + {/if} +
+
+ {/if} + + +
+
+
diff --git a/src/views/repo/router.ts b/src/views/repo/router.ts index 78b878867..21eb1e93a 100644 --- a/src/views/repo/router.ts +++ b/src/views/repo/router.ts @@ -8,13 +8,15 @@ import type { Review } from "@bindings/cob/patch/Review"; import type { Revision } from "@bindings/cob/patch/Revision"; import type { Thread } from "@bindings/cob/thread/Thread"; import type { Config } from "@bindings/config/Config"; +import type { Diff } from "@bindings/diff/Diff"; +import type { Commit } from "@bindings/repo/Commit"; import type { Readme } from "@bindings/repo/Readme"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; import type { Tree } from "@bindings/source/Tree"; import type { DraftReview } from "@app/lib/draftReviewStorage"; import { draftReviewStorage } from "@app/lib/draftReviewStorage"; -import { invoke } from "@app/lib/invoke"; +import { cachedGetCommitDiff, invoke } from "@app/lib/invoke"; import type { SidebarData } from "@app/lib/router/definitions"; import { loadSidebarData } from "@app/lib/router/definitions"; import { didFromPublicKey, unreachable } from "@app/lib/utils"; @@ -22,6 +24,7 @@ import { didFromPublicKey, unreachable } from "@app/lib/utils"; export type IssueStatus = "all" | Issue["state"]["status"]; export const DEFAULT_TAKE = 20; +export const COMMITS_PAGE_SIZE = 300; export interface RepoHomeRoute { resource: "repo.home"; @@ -29,6 +32,17 @@ export interface RepoHomeRoute { rid: string; } +export interface RepoCommitsRoute { + resource: "repo.commits"; + rid: string; +} + +export interface RepoCommitRoute { + resource: "repo.commit"; + rid: string; + commit: string; +} + export interface RepoIssueRoute { resource: "repo.issue"; rid: string; @@ -47,6 +61,25 @@ export interface LoadedRepoHomeRoute { }; } +export interface LoadedRepoCommitsRoute { + resource: "repo.commits"; + params: { + repo: RepoInfo; + commits: PaginatedQuery; + 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 { + const [sidebarData, repo, commits] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke>("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 { + const [sidebarData, repo, commit, diff] = await Promise.all([ + loadSidebarData(), + invoke("repo_by_id", { + rid: route.rid, + }), + invoke("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 { @@ -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) {