Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,30 @@ pub fn list_branches(cwd: impl AsRef<Path>) -> Result<Vec<String>> {
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<Path>, limit: usize) -> Result<Vec<String>> {
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<Path>, remote: &str) -> Result<String> {
let repo = discover(cwd)?;
Ok(repo
Expand Down Expand Up @@ -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();
Expand Down
25 changes: 20 additions & 5 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ struct RepoContextResponse {
#[serde(rename_all = "camelCase")]
struct BranchesResponse {
branches: Vec<String>,
/// Local branches by most recent commit, newest first.
recent: Vec<String>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -320,11 +322,24 @@ async fn handle_branch_diff(
}
}

async fn handle_branches(State(state): State<AppState>) -> 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<usize>,
}

async fn handle_branches(
State(state): State<AppState>,
Query(query): Query<BranchesQuery>,
) -> 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<AppState>) -> impl IntoResponse {
Expand Down
8 changes: 6 additions & 2 deletions web/src/components/DiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<DiffStatusScreen icon={<IconFileX />} title={emptyTitle} description={emptyMessage}>
{branchDiffPath ? (
{isBranch && baseRef ? (
<RecentBranches base={baseRef} exclude={[baseRef, headLabel]} />
) : branchDiffPath ? (
<Link to={branchDiffPath} className={buttonVariants({ size: "sm" })}>
View branch diff
</Link>
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/diff-view/DiffStatusScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function DiffStatusScreen({
}: {
icon: ReactNode;
title: string;
description: string;
description?: string;
children?: ReactNode;
}) {
return (
Expand All @@ -25,7 +25,7 @@ export function DiffStatusScreen({
<EmptyHeader>
<EmptyMedia variant="icon">{icon}</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
{description != null && <EmptyDescription>{description}</EmptyDescription>}
</EmptyHeader>
{children != null && <EmptyContent>{children}</EmptyContent>}
</Empty>
Expand Down
11 changes: 1 addition & 10 deletions web/src/components/diff-view/DiffToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions web/src/components/diff-view/RecentBranches.tsx
Original file line number Diff line number Diff line change
@@ -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<string[] | null>(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 (
<div className="flex flex-col items-center gap-1.5 text-[12px]">
<span className="text-muted-foreground">Recent branches</span>
<ul className="flex flex-col items-center gap-1">
{branches.map((name) => (
<li key={name}>
<Link
to={branchDiffHref(base, name, false)}
className="inline-flex items-center gap-1 text-neutral-700 underline-offset-2 hover:underline dark:text-neutral-300"
>
<IconGitBranch size={12} className="shrink-0 opacity-70" />
<span className="truncate">{name}</span>
</Link>
</li>
))}
</ul>
</div>
);
}
9 changes: 9 additions & 0 deletions web/src/components/diff-view/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading