feat(desktop): browse immutable repository tags (#2231)

This commit is contained in:
thomaspblock
2026-07-21 18:11:28 +02:00
committed by GitHub
parent 396ce6473e
commit c11b7b611e
14 changed files with 381 additions and 106 deletions

View File

@@ -1,5 +1,6 @@
use super::project_git_exec::{
build_git_auth_config, clean_branch, run_git, validate_workspace_clone_url, GitAuthConfig,
build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url,
GitAuthConfig,
};
use super::project_git_push::push_project_local_repository_blocking;
use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir};
@@ -717,8 +718,9 @@ pub async fn get_project_repo_snapshot(
let auth = build_git_auth_config(&state)?;
let branch = clean_branch(default_branch);
let base_branch = clean_branch(base_branch);
let target_ref = target_ref.filter(|value| value.starts_with("refs/") && !value.contains(".."));
let target_ref = clean_target_ref(target_ref);
let target_commit = target_commit
.map(|value| value.to_ascii_lowercase())
.filter(|value| matches!(value.len(), 40 | 64))
.filter(|value| value.chars().all(|c| c.is_ascii_hexdigit()));
@@ -729,38 +731,53 @@ pub async fn get_project_repo_snapshot(
.to_str()
.ok_or_else(|| "temporary repository path is not UTF-8".to_string())?;
let mut clone_args = vec!["clone", "--filter=blob:none"];
if let Some(ref branch) = branch {
clone_args.push("--branch");
clone_args.push(branch.as_str());
}
clone_args.push(clone_url.as_str());
clone_args.push(repo_path);
if run_git(&clone_args, None, &auth).is_err() && branch.is_some() {
let has_pr_target = target_ref.is_some() || target_commit.is_some();
let fallback_args = if has_pr_target {
vec![
let explicit_target = target_ref.as_deref().or(target_commit.as_deref());
if let Some(fetch_ref) = explicit_target {
run_git(
&[
"clone",
"--filter=blob:none",
"--no-checkout",
clone_url.as_str(),
repo_path,
]
} else {
vec!["clone", "--filter=blob:none", clone_url.as_str(), repo_path]
};
run_git(&fallback_args, None, &auth)?;
if has_pr_target {
let fetch_ref = target_ref.as_deref().or(target_commit.as_deref()).unwrap();
],
None,
&auth,
)?;
run_git(
&["fetch", "--depth=100", "origin", fetch_ref],
Some(&repo_dir),
&auth,
)?;
if let Some(expected_commit) = target_commit.as_deref() {
let fetched_commit = run_git(&["rev-parse", "FETCH_HEAD"], Some(&repo_dir), &auth)
.ok()
.and_then(|output| first_output_line(&output))
.map(|commit| commit.to_ascii_lowercase())
.ok_or_else(|| "Could not resolve the requested repository ref.".to_string())?;
if fetched_commit != expected_commit {
return Err(
"The requested repository ref changed. Refresh and try again.".to_string(),
);
}
}
run_git(
&["checkout", "--detach", "FETCH_HEAD"],
Some(&repo_dir),
&auth,
)?;
} else {
let mut clone_args = vec!["clone", "--filter=blob:none"];
if let Some(ref branch) = branch {
clone_args.push("--branch");
clone_args.push(branch.as_str());
}
clone_args.push(clone_url.as_str());
clone_args.push(repo_path);
if run_git(&clone_args, None, &auth).is_err() && branch.is_some() {
run_git(
&["fetch", "--depth=100", "origin", fetch_ref],
Some(&repo_dir),
&auth,
)?;
run_git(
&["checkout", "--detach", "FETCH_HEAD"],
Some(&repo_dir),
&["clone", "--filter=blob:none", clone_url.as_str(), repo_path],
None,
&auth,
)?;
}

View File

@@ -237,6 +237,17 @@ pub(crate) fn clean_branch(value: Option<String>) -> Option<String> {
.map(ToString::to_string)
}
pub(crate) fn clean_target_ref(value: Option<String>) -> Option<String> {
let value = value?.trim().to_string();
for prefix in ["refs/tags/", "refs/nostr/"] {
if let Some(name) = value.strip_prefix(prefix) {
let clean_name = clean_branch(Some(name.to_string()))?;
return (clean_name == name).then_some(format!("{prefix}{clean_name}"));
}
}
None
}
pub(crate) fn validate_clone_url(clone_url: &str) -> Result<(), String> {
let parsed = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?;
if !matches!(parsed.scheme(), "http" | "https") {
@@ -304,7 +315,7 @@ fn validate_clone_url_against_relay(clone_url: &str, relay_base: &str) -> Result
#[cfg(test)]
mod tests {
use super::{
clean_branch, git_needs_credentials, git_subcommand, validate_clone_url,
clean_branch, clean_target_ref, git_needs_credentials, git_subcommand, validate_clone_url,
validate_clone_url_against_relay,
};
@@ -362,6 +373,20 @@ mod tests {
assert_eq!(clean_branch(None), None);
}
#[test]
fn clean_target_ref_accepts_only_tags_and_pull_request_refs() {
assert_eq!(
clean_target_ref(Some("refs/tags/v1.0.0".into())),
Some("refs/tags/v1.0.0".to_string())
);
assert_eq!(
clean_target_ref(Some("refs/nostr/abc123".into())),
Some("refs/nostr/abc123".to_string())
);
assert_eq!(clean_target_ref(Some("refs/heads/main".into())), None);
assert_eq!(clean_target_ref(Some("refs/tags/../main".into())), None);
}
#[test]
fn validate_clone_url_requires_buzz_repo_shape() {
let owner = "a".repeat(64);

View File

@@ -548,6 +548,7 @@ async function fetchProjectRepoSnapshot(
project: Project,
branchName?: string | null,
pullRequest?: ProjectPullRequest | null,
tag?: { name: string; commit: string } | null,
): Promise<ProjectRepoSnapshot | null> {
const cloneUrl = pullRequest?.cloneUrls[0] ?? project.cloneUrls[0];
if (!cloneUrl) return null;
@@ -556,8 +557,12 @@ async function fetchProjectRepoSnapshot(
cloneUrl,
defaultBranch: branchName ?? project.defaultBranch,
baseBranch: project.defaultBranch,
targetCommit: pullRequest?.commit ?? null,
targetRef: pullRequest ? `refs/nostr/${pullRequest.id}` : null,
targetCommit: tag?.commit ?? pullRequest?.commit ?? null,
targetRef: tag
? `refs/tags/${tag.name}`
: pullRequest
? `refs/nostr/${pullRequest.id}`
: null,
});
}
@@ -692,6 +697,7 @@ export function useProjectRepoSnapshotQuery(
project: Project | null | undefined,
branchName?: string | null,
pullRequest?: ProjectPullRequest | null,
tag?: { name: string; commit: string } | null,
) {
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
@@ -704,10 +710,17 @@ export function useProjectRepoSnapshotQuery(
selectedBranch ?? "default",
pullRequest?.id ?? "none",
pullRequest?.commit ?? "none",
tag?.name ?? "no-tag",
tag?.commit ?? "no-tag-commit",
],
queryFn: () => {
if (!project) throw new Error("No project selected.");
return fetchProjectRepoSnapshot(project, selectedBranch, pullRequest);
return fetchProjectRepoSnapshot(
project,
selectedBranch,
pullRequest,
tag,
);
},
staleTime: 30_000,
retry: 1,

View File

@@ -3,6 +3,7 @@ import { test } from "node:test";
import {
normalizeProjectBranchName,
projectBranchCreationReason,
projectBranchManagementState,
projectBranchNameError,
projectBranchOptions,
@@ -47,6 +48,24 @@ test("combines remote and local branch options without duplicates", () => {
assert.deepEqual(projectBranchOptions(["main"], ["main"]), ["main"]);
});
test("explains why a branch cannot be created", () => {
assert.equal(
projectBranchCreationReason({
activeBranch: "main",
activeBranchCommit: null,
localHead: "a".repeat(40),
}),
"Push the first local commit to main before creating another branch.",
);
assert.equal(
projectBranchCreationReason({
activeBranch: "main",
activeBranchCommit: "a".repeat(40),
}),
null,
);
});
test("ignores a dangling HEAD and selects a published branch", () => {
assert.equal(
resolveProjectDefaultBranch("master", {

View File

@@ -59,6 +59,18 @@ export function projectBranchOptionsFromSync(
return projectBranchOptions(remoteBranches, localBranches);
}
export function projectBranchCreationReason(input: {
activeBranch: string | null;
activeBranchCommit: string | null;
localHead?: string | null;
}): string | null {
if (!input.activeBranch) return "Choose a branch first.";
if (input.activeBranchCommit) return null;
return input.localHead
? `Push the first local commit to ${input.activeBranch} before creating another branch.`
: "Create the repository's first commit before creating another branch.";
}
/** Resolve a usable default branch when a repository advertises a stale HEAD. */
export function resolveProjectDefaultBranch(
announcedBranch: string,

View File

@@ -0,0 +1,38 @@
import type { useProjectBranchActions } from "@/features/projects/branchMutations";
import {
CreateProjectBranchDialog,
DeleteProjectBranchDialog,
} from "./ProjectBranchDialogs";
export function ProjectBranchActionDialogs({
actions,
activeBranch,
activeBranchCommit,
existingBranches,
}: {
actions: ReturnType<typeof useProjectBranchActions>;
activeBranch: string | null;
activeBranchCommit: string | null;
existingBranches: string[];
}) {
return (
<>
<CreateProjectBranchDialog
existingBranches={existingBranches}
onCreate={actions.handleCreate}
onOpenChange={actions.setCreateOpen}
open={actions.createOpen}
pending={actions.createPending}
sourceBranch={activeBranch ?? ""}
sourceCommit={activeBranchCommit}
/>
<DeleteProjectBranchDialog
branch={activeBranch ?? ""}
onDelete={actions.handleDelete}
onOpenChange={actions.setDeleteOpen}
open={actions.deleteOpen}
pending={actions.deletePending}
/>
</>
);
}

View File

@@ -30,6 +30,7 @@ import {
} from "@/features/projects/repoSyncHooks";
import { useProjectBranchActions } from "@/features/projects/branchMutations";
import { useOptimisticProjectBranches } from "@/features/projects/useOptimisticProjectBranches";
import { useProjectRepositoryRefSelection } from "@/features/projects/useProjectRepositoryRefSelection";
import { useUpdateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations";
import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
@@ -63,6 +64,7 @@ import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitD
import { useGitIdentityQuery } from "@/features/projects/useGitIdentity";
import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching";
import {
projectBranchCreationReason,
projectBranchManagementState,
projectBranchOptionsFromSync,
resolveProjectDefaultBranch,
@@ -75,11 +77,9 @@ import {
useOpenProjectTerminal,
} from "./useOpenProjectTerminal";
import type { CreateIssueDialogInput } from "./CreateIssueDialog";
import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs";
import {
CreateProjectBranchDialog,
DeleteProjectBranchDialog,
} from "./ProjectBranchDialogs";
import {
PROJECT_TAB_CRUMB_LABELS,
projectPeople,
pushPullTitle,
snapshotHasContent,
@@ -126,11 +126,16 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
(pullRequest) => pullRequest.branchName ?? null,
) ?? [],
});
const [selectedBranch, setSelectedBranch] = React.useState<string | null>(
null,
);
const activeBranch =
selectedBranch ?? defaultBranch ?? branchOptions[0] ?? null;
const { activeBranch, selectBranch, selectedTag, selectTag } =
useProjectRepositoryRefSelection({
branchOptions,
defaultBranch,
projectAvailable: Boolean(project),
projectPending: projectQuery.isPending,
tags: repoStateQuery.data?.tags ?? [],
});
const activeTag =
repoStateQuery.data?.tags.find((tag) => tag.name === selectedTag) ?? null;
const [selectedPullRequestId, setSelectedPullRequestId] = React.useState<
string | null
>(pullRequestId ?? null);
@@ -207,7 +212,8 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const repoSnapshotQuery = useProjectRepoSnapshotQuery(
project,
activeBranch,
selectedBranchPullRequest,
selectedTag ? null : selectedBranchPullRequest,
activeTag,
);
const repoDiffQuery = useProjectRepoDiffQuery(
project,
@@ -290,7 +296,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
});
const handleBranchChange = React.useCallback(
(branch: string | null) => {
setSelectedBranch(branch);
selectBranch(branch);
if (
branch &&
repoSource === "local" &&
@@ -299,7 +305,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
setRepoSource("remote");
}
},
[repoSource, repoSyncStatusQuery.data?.localBranch],
[repoSource, repoSyncStatusQuery.data?.localBranch, selectBranch],
);
const handleTagChange = React.useCallback(
(tag: string) => {
selectTag(tag);
setRepoSource("remote");
},
[selectTag],
);
const branchActions = useProjectBranchActions({
activeBranch,
@@ -313,13 +326,11 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
rememberBranch,
selectBranch: handleBranchChange,
});
const createBranchReason = !activeBranch
? "Choose a branch first."
: !activeBranchCommit
? repoSyncStatusQuery.data?.localHead
? `Push the first local commit to ${activeBranch} before creating another branch.`
: "Create the repository's first commit before creating another branch."
: null;
const createBranchReason = projectBranchCreationReason({
activeBranch,
activeBranchCommit,
localHead: repoSyncStatusQuery.data?.localHead,
});
const handleFetchRepo = React.useCallback(async () => {
const results = await Promise.all([
repoSnapshotQuery.refetch(),
@@ -341,7 +352,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const filesSourceControls: RepoSourceHeaderControls = {
branch: activeBranch ?? "",
branchOptions: branchOptionsWithLocal,
selectedTag,
tagOptions: repoStateQuery.data?.tags ?? [],
onBranchChange: handleBranchChange,
onTagChange: handleTagChange,
onCreateBranch: () => branchActions.setCreateOpen(true),
createBranchDisabled: branchActions.createPending || !activeBranchCommit,
createBranchTitle: createBranchReason ?? "Create a remote branch",
@@ -349,38 +363,44 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
deleteBranchDisabled:
branchActions.deletePending || Boolean(deleteBranchReason),
deleteBranchTitle: deleteBranchReason ?? "Delete this remote branch",
source: repoSource,
source: selectedTag ? "remote" : repoSource,
onSourceChange: setRepoSource,
localDisabled:
!repoSyncStatusQuery.data?.localPath &&
!localRepoSnapshotQuery.data &&
!localRepoSnapshotQuery.isLoading,
Boolean(selectedTag) ||
(!repoSyncStatusQuery.data?.localPath &&
!localRepoSnapshotQuery.data &&
!localRepoSnapshotQuery.isLoading),
localLabel: localRepoSnapshotQuery.isLoading
? "Local checking"
: repoSyncStatusQuery.data?.localPath || localRepoSnapshotQuery.data
? "Local"
: "Local missing",
remoteLabel: repoSnapshotQuery.isLoading ? "Remote checking" : "Remote",
onCloneLocal: project?.cloneUrls[0]
? () => {
void handleCloneRepo();
}
: undefined,
onCloneLocal:
!selectedTag && project?.cloneUrls[0]
? () => {
void handleCloneRepo();
}
: undefined,
clonePending: cloneRepoMutation.isPending,
canPush: repoSyncStatusQuery.data?.canPush ?? false,
onPush: () => {
void handlePushLocalRepo();
},
canPush: !selectedTag && (repoSyncStatusQuery.data?.canPush ?? false),
onPush: selectedTag
? undefined
: () => {
void handlePushLocalRepo();
},
pushDisabled:
pushLocalRepoMutation.isPending || !repoSyncStatusQuery.data?.canPush,
pushPending: pushLocalRepoMutation.isPending,
pushTitle:
repoSyncStatusQuery.data?.pushBlockReason ??
pushPullTitle("Push", repoSyncStatusQuery.data?.aheadCount, "local"),
canPull: repoSyncStatusQuery.data?.canPull ?? false,
onPull: () => {
void handlePullLocalRepo();
},
canPull: !selectedTag && (repoSyncStatusQuery.data?.canPull ?? false),
onPull: selectedTag
? undefined
: () => {
void handlePullLocalRepo();
},
pullDisabled:
pullLocalRepoMutation.isPending || !repoSyncStatusQuery.data?.canPull,
pullPending: pullLocalRepoMutation.isPending,
@@ -406,21 +426,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
// pullRequestId/issueId selections — clearing here would discard them
// before the detail view ever gets a chance to open.
if (projectPending) return;
setSelectedBranch(null);
setSelectedPullRequestId(null);
setSelectedIssueId(null);
setSelectedCommitHash(null);
return;
}
setSelectedBranch((currentBranch) => {
if (currentBranch && branchOptions.includes(currentBranch)) {
return currentBranch;
}
return defaultBranch ?? branchOptions[0] ?? null;
});
}, [project, branchOptions, defaultBranch, projectPending]);
}, [project, projectPending]);
React.useEffect(() => {
setRepoSource((currentSource) => {
if (selectedTag) return "remote";
if (currentSource === "local" && !hasLocalCheckout) return "remote";
if (
currentSource === "remote" &&
@@ -431,7 +444,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
}
return currentSource;
});
}, [hasLocalCheckout, hasRemoteSnapshot]);
}, [hasLocalCheckout, hasRemoteSnapshot, selectedTag]);
const peoplePubkeys = React.useMemo(() => {
if (!project) return [];
// Include PR authors/updaters so commit rows can resolve avatars for
@@ -746,16 +759,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
}
: null;
// Sub-tab crumb when no work item is open. Overview (readme) is home.
const TAB_CRUMB_LABELS: Record<string, string> = {
files: "Files",
activity: "Commits",
issues: "Issues",
prs: "Pull Request",
contributors: "Contributors",
};
const activeTabCrumb = activeWorkItemCrumb
? null
: (TAB_CRUMB_LABELS[activeTab] ?? null);
: (PROJECT_TAB_CRUMB_LABELS[activeTab] ?? null);
const handleGoToProjectHome = () => {
setSelectedPullRequestId(null);
setSelectedIssueId(null);
@@ -767,21 +773,11 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
return (
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<CreateProjectBranchDialog
<ProjectBranchActionDialogs
actions={branchActions}
activeBranch={activeBranch}
activeBranchCommit={activeBranchCommit}
existingBranches={branchOptionsWithLocal}
onCreate={branchActions.handleCreate}
onOpenChange={branchActions.setCreateOpen}
open={branchActions.createOpen}
pending={branchActions.createPending}
sourceBranch={activeBranch ?? ""}
sourceCommit={activeBranchCommit}
/>
<DeleteProjectBranchDialog
branch={activeBranch ?? ""}
onDelete={branchActions.handleDelete}
onOpenChange={branchActions.setDeleteOpen}
open={branchActions.deleteOpen}
pending={branchActions.deletePending}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
@@ -942,7 +938,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
localSnapshot={localRepoSnapshotQuery.data}
localSnapshotError={localRepoSnapshotQuery.error}
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
onBranchChange={setSelectedBranch}
onBranchChange={handleBranchChange}
onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
onOpenTerminal={() => {
void handleOpenTerminal();

View File

@@ -102,6 +102,9 @@ export function ReadmePanel({
onBranchChange={sourceControls.onBranchChange}
onCreateBranch={sourceControls.onCreateBranch}
onDeleteBranch={sourceControls.onDeleteBranch}
onTagChange={sourceControls.onTagChange}
selectedTag={sourceControls.selectedTag}
tagOptions={sourceControls.tagOptions}
/>
<div className="ml-auto flex shrink-0 items-center">
<RepoSyncActionButton controls={sourceControls} />

View File

@@ -731,6 +731,9 @@ export function RepositoryFilesPanel({
onBranchChange={sourceControls.onBranchChange}
onCreateBranch={sourceControls.onCreateBranch}
onDeleteBranch={sourceControls.onDeleteBranch}
onTagChange={sourceControls.onTagChange}
selectedTag={sourceControls.selectedTag}
tagOptions={sourceControls.tagOptions}
/>
<div className="ml-auto flex shrink-0 items-center">
<RepoSyncActionButton controls={sourceControls} />
@@ -770,6 +773,9 @@ export function RepositoryFilesPanel({
onBranchChange={sourceControls.onBranchChange}
onCreateBranch={sourceControls.onCreateBranch}
onDeleteBranch={sourceControls.onDeleteBranch}
onTagChange={sourceControls.onTagChange}
selectedTag={sourceControls.selectedTag}
tagOptions={sourceControls.tagOptions}
/>
</>
) : (

View File

@@ -7,6 +7,7 @@ import {
Loader2,
Plus,
RefreshCw,
Tag,
Trash2,
UploadCloud,
} from "lucide-react";
@@ -16,6 +17,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
@@ -27,17 +29,22 @@ import { PROJECT_PANEL_ACTION_BUTTON_CLASS } from "./projectPanelStyles";
export function RepositoryBranchDropdown({
branch,
branchOptions,
selectedTag,
tagOptions = [],
compact,
createBranchDisabled,
createBranchTitle,
deleteBranchDisabled,
deleteBranchTitle,
onBranchChange,
onTagChange,
onCreateBranch,
onDeleteBranch,
}: {
branch: string;
branchOptions: string[];
selectedTag?: string | null;
tagOptions?: Array<{ name: string; commit: string }>;
/** Smaller trigger for inline headers. */
compact?: boolean;
createBranchDisabled?: boolean;
@@ -45,11 +52,14 @@ export function RepositoryBranchDropdown({
deleteBranchDisabled?: boolean;
deleteBranchTitle?: string;
onBranchChange: (branch: string) => void;
onTagChange?: (tag: string) => void;
onCreateBranch?: () => void;
onDeleteBranch?: () => void;
}) {
const selectableBranches =
branchOptions.length > 0 ? branchOptions : [branch];
const selectedValue = selectedTag ? `tag:${selectedTag}` : `branch:${branch}`;
const RefIcon = selectedTag ? Tag : GitBranch;
if (!branch) {
return (
<span className="truncate font-mono text-sm font-semibold text-foreground">
@@ -70,20 +80,49 @@ export function RepositoryBranchDropdown({
type="button"
variant="outline"
>
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{branch}</span>
<RefIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{selectedTag ?? branch}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuRadioGroup onValueChange={onBranchChange} value={branch}>
<DropdownMenuRadioGroup
onValueChange={(value) => {
if (value.startsWith("tag:")) {
onTagChange?.(value.slice("tag:".length));
} else {
onBranchChange(value.slice("branch:".length));
}
}}
value={selectedValue}
>
<DropdownMenuLabel>Branches</DropdownMenuLabel>
{selectableBranches.map((option) => (
<DropdownMenuRadioItem key={option} value={option}>
<DropdownMenuRadioItem key={option} value={`branch:${option}`}>
<GitBranch className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-mono">{option}</span>
</DropdownMenuRadioItem>
))}
{tagOptions.length > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Tags</DropdownMenuLabel>
{tagOptions.map((option) => (
<DropdownMenuRadioItem
key={option.name}
value={`tag:${option.name}`}
>
<Tag className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-mono">{option.name}</span>
<span className="ml-auto font-mono text-xs text-muted-foreground">
{option.commit.slice(0, 7)}
</span>
</DropdownMenuRadioItem>
))}
</>
) : null}
</DropdownMenuRadioGroup>
{onCreateBranch || onDeleteBranch ? (
{!selectedTag && (onCreateBranch || onDeleteBranch) ? (
<>
<DropdownMenuSeparator />
{onCreateBranch ? (
@@ -127,7 +166,10 @@ export function RepositoryBranchDropdown({
export type RepoSourceHeaderControls = {
branch: string;
branchOptions: string[];
selectedTag?: string | null;
tagOptions?: Array<{ name: string; commit: string }>;
onBranchChange: (branch: string) => void;
onTagChange?: (tag: string) => void;
onCreateBranch?: () => void;
createBranchDisabled?: boolean;
createBranchTitle?: string;

View File

@@ -1,6 +1,14 @@
import type { Project, ProjectRepoSnapshot } from "@/features/projects/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
export const PROJECT_TAB_CRUMB_LABELS: Record<string, string> = {
files: "Files",
activity: "Commits",
issues: "Issues",
prs: "Pull Request",
contributors: "Contributors",
};
/** Tooltip for the push/pull sync buttons, e.g. "Pull 2 remote commits". */
export function pushPullTitle(
verb: "Push" | "Pull",

View File

@@ -0,0 +1,58 @@
import * as React from "react";
export function useProjectRepositoryRefSelection(input: {
branchOptions: string[];
defaultBranch: string | null;
projectAvailable: boolean;
projectPending: boolean;
tags: Array<{ name: string }>;
}) {
const [selectedBranch, setSelectedBranch] = React.useState<string | null>(
null,
);
const [selectedTag, setSelectedTag] = React.useState<string | null>(null);
const activeBranch =
selectedBranch ?? input.defaultBranch ?? input.branchOptions[0] ?? null;
React.useEffect(() => {
if (!input.projectAvailable) {
if (input.projectPending) return;
setSelectedBranch(null);
setSelectedTag(null);
return;
}
setSelectedBranch((currentBranch) => {
if (currentBranch && input.branchOptions.includes(currentBranch)) {
return currentBranch;
}
return input.defaultBranch ?? input.branchOptions[0] ?? null;
});
setSelectedTag((currentTag) => {
if (currentTag && input.tags.some((tag) => tag.name === currentTag)) {
return currentTag;
}
return null;
});
}, [
input.branchOptions,
input.defaultBranch,
input.projectAvailable,
input.projectPending,
input.tags,
]);
const selectBranch = React.useCallback((branch: string | null) => {
setSelectedBranch(branch);
setSelectedTag(null);
}, []);
const selectTag = React.useCallback((tag: string) => {
setSelectedTag(tag);
}, []);
return {
activeBranch,
selectBranch,
selectedTag,
selectTag,
};
}

View File

@@ -4825,6 +4825,7 @@ function buildMockProjectEvents(): RelayEvent[] {
}`,
],
["refs/heads/main", "0123456789abcdef0123456789abcdef01234567"],
["refs/tags/v1.0.0", "0123456789abcdef0123456789abcdef01234567"],
...Object.entries(readMockProjectBranches()[seed.dtag] ?? {}).map(
([branch, commit]) => [`refs/heads/${branch}`, commit],
),

View File

@@ -756,6 +756,43 @@ test("project branches can be created from the selected remote branch", async ({
).toBeVisible();
});
test("repository tags can be browsed as immutable remote snapshots", async ({
page,
}) => {
await enableProjectsFeature(page);
await installMockBridge(page);
await openBuzzProject(page);
await page.getByRole("button", { name: /main/ }).click();
await expect(page.getByText("Tags", { exact: true })).toBeVisible();
await expect(
page.getByRole("menuitemradio", { name: /v1\.0\.0.*0123456/ }),
).toBeVisible();
await page.getByRole("menuitemradio", { name: /v1\.0\.0.*0123456/ }).click();
await expect(page.getByRole("button", { name: /v1\.0\.0/ })).toBeVisible();
await expect(
page.getByRole("button", { name: "Remote", exact: true }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((entry) => entry.command === "get_project_repo_snapshot");
return (call?.payload as { targetRef?: string } | undefined)?.targetRef;
}),
)
.toBe("refs/tags/v1.0.0");
await page.getByRole("button", { name: /v1\.0\.0/ }).click();
await expect(page.getByTestId("project-create-branch")).toHaveCount(0);
await expect(page.getByTestId("project-delete-branch")).toHaveCount(0);
await page.getByRole("menuitemradio", { name: "main" }).click();
await page.getByRole("button", { name: /main/ }).click();
await expect(page.getByTestId("project-create-branch")).toBeVisible();
});
test("project branches can be deleted but the default branch cannot", async ({
page,
}) => {