diff --git a/src/git.rs b/src/git.rs index f130807..ecfdbb5 100644 --- a/src/git.rs +++ b/src/git.rs @@ -162,6 +162,30 @@ pub fn list_branches(cwd: impl AsRef) -> Result> { Ok(names.into_iter().collect()) } +/// Local branches ordered by most recent commit, newest first. Backs the +/// "recent branches" shortcuts shown when a branch diff is empty. +pub fn recent_branches(cwd: impl AsRef, limit: usize) -> Result> { + let repo = discover(cwd)?; + let mut dated = Vec::new(); + for entry in repo.branches(Some(BranchType::Local))? { + let (branch, _) = entry?; + let Some(name) = branch.name()?.filter(|name| !name.is_empty()) else { + continue; + }; + let Ok(commit) = branch.get().peel_to_commit() else { + continue; + }; + dated.push((commit.time().seconds(), name.to_string())); + } + // Newest first; ties fall back to name so the order is stable. + dated.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + Ok(dated + .into_iter() + .take(limit) + .map(|(_, name)| name) + .collect()) +} + pub fn remote_url(cwd: impl AsRef, remote: &str) -> Result { let repo = discover(cwd)?; Ok(repo @@ -546,6 +570,34 @@ mod tests { ); } + #[test] + fn recent_branches_orders_by_commit_date_and_limits() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + git(root, &["init", "-b", "main"]); + git(root, &["config", "user.email", "diffs@example.com"]); + git(root, &["config", "user.name", "Diffs Test"]); + fs::write(root.join("a.txt"), "a\n").unwrap(); + git(root, &["add", "."]); + git(root, &["commit", "-m", "initial"]); + // `old` shares main's tip; `newer` gets a strictly later commit. + git(root, &["branch", "old"]); + git(root, &["checkout", "-q", "-b", "newer"]); + fs::write(root.join("b.txt"), "b\n").unwrap(); + git(root, &["add", "."]); + let status = Command::new("git") + .args(["commit", "-m", "later"]) + .env("GIT_AUTHOR_DATE", "2030-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2030-01-01T00:00:00Z") + .current_dir(root) + .status() + .expect("run git commit"); + assert!(status.success()); + + assert_eq!(recent_branches(root, 10).unwrap(), ["newer", "main", "old"]); + assert_eq!(recent_branches(root, 1).unwrap(), ["newer"]); + } + #[test] fn read_rev_file_reads_committed_content_not_worktree() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/server.rs b/src/server.rs index 9c149d9..a7dc5b9 100644 --- a/src/server.rs +++ b/src/server.rs @@ -91,6 +91,8 @@ struct RepoContextResponse { #[serde(rename_all = "camelCase")] struct BranchesResponse { branches: Vec, + /// Local branches by most recent commit, newest first. + recent: Vec, } #[derive(Debug, Deserialize)] @@ -320,11 +322,24 @@ async fn handle_branch_diff( } } -async fn handle_branches(State(state): State) -> Response { - match git::list_branches(&state.cwd) { - Ok(branches) => (StatusCode::OK, Json(BranchesResponse { branches })).into_response(), - Err(err) => error(StatusCode::BAD_GATEWAY, err), - } +#[derive(Debug, Deserialize)] +struct BranchesQuery { + /// How many recently committed local branches to include (default 5). + recent: Option, +} + +async fn handle_branches( + State(state): State, + Query(query): Query, +) -> Response { + let branches = match git::list_branches(&state.cwd) { + Ok(branches) => branches, + Err(err) => return error(StatusCode::BAD_GATEWAY, err), + }; + // Ordering is a convenience; never fail the whole list over it. + let limit = query.recent.unwrap_or(5).min(50); + let recent = git::recent_branches(&state.cwd, limit).unwrap_or_default(); + (StatusCode::OK, Json(BranchesResponse { branches, recent })).into_response() } async fn handle_repo_context(State(state): State) -> impl IntoResponse { diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx index e697268..3eb390d 100644 --- a/web/src/components/DiffView.tsx +++ b/web/src/components/DiffView.tsx @@ -61,6 +61,7 @@ import { DiffAnnotation } from "./diff-view/DiffAnnotation"; import { DiffStatusScreen } from "./diff-view/DiffStatusScreen"; import { DiffToolbar, type ToolbarLink } from "./diff-view/DiffToolbar"; import { FileActionsMenu } from "./diff-view/FileActionsMenu"; +import { RecentBranches } from "./diff-view/RecentBranches"; import { ShortcutsDialog } from "./diff-view/ShortcutsDialog"; import { SidebarTree } from "./diff-view/SidebarTree"; import type { @@ -1401,14 +1402,17 @@ export function DiffView({ source = "pr" }: { source?: "pr" | "local" | "branch" } if (files.length === 0) { + // Branch view keeps it to one line and lists other branches to try. const [emptyTitle, emptyMessage] = isBranch - ? ["No commits ahead", `No commits ahead of ${baseRef || "base"}.`] + ? [`No commits ahead of ${baseRef || "base"}.`, undefined] : isLocal ? ["No file changes yet", "The latest diffs are no longer available."] : ["No files changed", "This pull request doesn't change any files."]; return ( } title={emptyTitle} description={emptyMessage}> - {branchDiffPath ? ( + {isBranch && baseRef ? ( + + ) : branchDiffPath ? ( View branch diff diff --git a/web/src/components/diff-view/DiffStatusScreen.tsx b/web/src/components/diff-view/DiffStatusScreen.tsx index eb4dc34..95070c0 100644 --- a/web/src/components/diff-view/DiffStatusScreen.tsx +++ b/web/src/components/diff-view/DiffStatusScreen.tsx @@ -16,7 +16,7 @@ export function DiffStatusScreen({ }: { icon: ReactNode; title: string; - description: string; + description?: string; children?: ReactNode; }) { return ( @@ -25,7 +25,7 @@ export function DiffStatusScreen({ {icon} {title} - {description} + {description != null && {description}} {children != null && {children}} diff --git a/web/src/components/diff-view/DiffToolbar.tsx b/web/src/components/diff-view/DiffToolbar.tsx index 9f7d825..3d617ef 100644 --- a/web/src/components/diff-view/DiffToolbar.tsx +++ b/web/src/components/diff-view/DiffToolbar.tsx @@ -32,7 +32,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { apiFetch } from "@/lib/api"; import type { AppConfig, DiffSettingsProps, PullRequestInfo } from "./types"; -import { displayLocalPath, headerIconButtonClass } from "./helpers"; +import { branchDiffHref, displayLocalPath, headerIconButtonClass } from "./helpers"; const DiffSettingsPopover = lazy(() => import("./DiffSettingsPopover").then((m) => ({ default: m.DiffSettingsPopover })), @@ -73,15 +73,6 @@ function BranchChip({ label, title }: { label: string; title: string }) { ); } -// An empty head means the checked-out branch; dirty changes only apply there. -function branchDiffHref(base: string, head: string, includeDirty: boolean) { - const params = new URLSearchParams(); - params.set("base", base); - if (head) params.set("head", head); - else if (includeDirty) params.set("dirty", "1"); - return `/branch?${params.toString()}`; -} - function BranchSwitcher({ role, current, diff --git a/web/src/components/diff-view/RecentBranches.tsx b/web/src/components/diff-view/RecentBranches.tsx new file mode 100644 index 0000000..61ab8f1 --- /dev/null +++ b/web/src/components/diff-view/RecentBranches.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router"; +import { IconGitBranch } from "@tabler/icons-react"; +import { apiFetch } from "@/lib/api"; +import { branchDiffHref } from "./helpers"; + +const SHOW_LIMIT = 5; + +// Shortcuts to compare other recently committed branches against `base`. +// Branches already on screen (the base and the current head) are skipped. +export function RecentBranches({ base, exclude }: { base: string; exclude: string[] }) { + const [branches, setBranches] = useState(null); + // Stable dependency; callers pass a fresh array each render. + const excludeKey = exclude.filter(Boolean).join("\0"); + + useEffect(() => { + let cancelled = false; + const skip = new Set(excludeKey.split("\0")); + // Ask for a few extra so the list stays full after exclusions. + apiFetch<{ recent?: string[] }>(`/api/branches?recent=${SHOW_LIMIT + skip.size}`) + .then((data) => { + if (cancelled) return; + const names = (data.recent ?? []).filter((name) => !skip.has(name)); + setBranches(names.slice(0, SHOW_LIMIT)); + }) + .catch(() => { + if (!cancelled) setBranches([]); + }); + return () => { + cancelled = true; + }; + }, [base, excludeKey]); + + if (!branches?.length) return null; + + return ( +
+ Recent branches +
    + {branches.map((name) => ( +
  • + + + {name} + +
  • + ))} +
+
+ ); +} diff --git a/web/src/components/diff-view/helpers.ts b/web/src/components/diff-view/helpers.ts index 22a8079..0e8c4d2 100644 --- a/web/src/components/diff-view/helpers.ts +++ b/web/src/components/diff-view/helpers.ts @@ -61,6 +61,15 @@ function fileExtension(name: string): string { return dot > 0 ? base.slice(dot + 1).toLowerCase() : ""; } +// An empty head means the checked-out branch; dirty changes only apply there. +export function branchDiffHref(base: string, head: string, includeDirty: boolean) { + const params = new URLSearchParams(); + params.set("base", base); + if (head) params.set("head", head); + else if (includeDirty) params.set("dirty", "1"); + return `/branch?${params.toString()}`; +} + export function sortFiles( files: readonly FileDiffMetadata[], orderBy: DiffOrderBy,