mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): browse immutable repository tags (#2231)
This commit is contained in:
@@ -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,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user