feat(desktop): clarify repository hosting and availability

Distinguish Buzz-hosted repositories from linked GitHub repositories, provide a safe local clone flow for public GitHub remotes, and replace misleading empty states with actionable availability feedback.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-07-29 18:33:07 +02:00
parent 485d03a358
commit 0d2bfcaa68
25 changed files with 1034 additions and 164 deletions
@@ -203,6 +203,22 @@ pub(crate) fn build_git_auth_config(state: &AppState) -> Result<GitAuthConfig, S
build_git_auth_config_for_keys(&keys)
}
pub(crate) fn build_git_clone_auth_config(
clone_url: &str,
state: &AppState,
) -> Result<GitAuthConfig, String> {
if validate_github_clone_url(clone_url).is_ok() {
return Ok(GitAuthConfig {
git_path: resolve_command("git")
.ok_or_else(|| "git was not found on PATH".to_string())?,
credential_helper: None,
nsec: String::new(),
allow_file_transport: false,
});
}
build_git_auth_config(state)
}
pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result<GitAuthConfig, String> {
let git_path = resolve_command("git").ok_or_else(|| "git was not found on PATH".to_string())?;
let credential_helper = resolve_command("git-credential-nostr");
@@ -288,6 +304,56 @@ pub(crate) fn validate_clone_url(clone_url: &str) -> Result<(), String> {
Ok(())
}
fn validate_github_clone_url(clone_url: &str) -> Result<(), String> {
let parsed = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?;
if parsed.scheme() != "https"
|| parsed.host_str() != Some("github.com")
|| parsed.port().is_some()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err("GitHub clone URL must use public https://github.com/owner/repository".into());
}
let segments = parsed
.path_segments()
.map(|segments| {
segments
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let valid_segment = |segment: &&str| {
!segment.starts_with('-')
&& !segment.contains("..")
&& segment.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
})
};
if segments.len() != 2 || !segments.iter().all(valid_segment) {
return Err("GitHub clone URL must name one owner and repository".into());
}
Ok(())
}
pub(crate) fn validate_local_clone_url(clone_url: &str) -> Result<(), String> {
if validate_clone_url(clone_url).is_ok() || validate_github_clone_url(clone_url).is_ok() {
return Ok(());
}
Err("clone URL must point at a Buzz repository or public GitHub repository".into())
}
pub(crate) fn validate_local_clone_url_for_workspace(
clone_url: &str,
state: &AppState,
) -> Result<(), String> {
if validate_github_clone_url(clone_url).is_ok() {
return Ok(());
}
validate_workspace_clone_url(clone_url, state)
}
pub(crate) fn clone_url_owner(clone_url: &str) -> Option<String> {
let parsed = Url::parse(clone_url).ok()?;
let segments = parsed
@@ -329,6 +395,7 @@ mod tests {
use super::{
clean_branch, clean_target_ref, credential_helper_config_value, git_needs_credentials,
git_subcommand, validate_clone_url, validate_clone_url_against_relay,
validate_local_clone_url,
};
#[test]
@@ -441,4 +508,15 @@ mod tests {
)
.is_err());
}
#[test]
fn local_clone_url_allows_only_public_github_https_urls() {
assert!(validate_local_clone_url("https://github.com/block/buzz").is_ok());
assert!(validate_local_clone_url("https://github.com/block/buzz.git").is_ok());
assert!(validate_local_clone_url("http://github.com/block/buzz").is_err());
assert!(validate_local_clone_url("https://github.com/block/buzz/issues").is_err());
assert!(validate_local_clone_url("https://user@github.com/block/buzz").is_err());
assert!(validate_local_clone_url("https://github.com.evil.test/block/buzz").is_err());
assert!(validate_local_clone_url("https://gitlab.com/block/buzz").is_err());
}
}
@@ -3,8 +3,9 @@
use super::project_git::{first_output_line, normalize_branch_option};
use super::project_git_diff::clean_commit;
use super::project_git_exec::{
build_git_auth_config, build_git_auth_config_for_keys, clone_url_owner, run_git,
validate_clone_url, validate_workspace_clone_url, GitAuthConfig,
build_git_auth_config_for_keys, build_git_clone_auth_config, clone_url_owner, run_git,
validate_local_clone_url, validate_local_clone_url_for_workspace, validate_workspace_clone_url,
GitAuthConfig,
};
use super::project_repo_paths::{
canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates,
@@ -410,7 +411,7 @@ pub(crate) fn clone_project_repository_blocking(
default_branch: Option<&str>,
auth: &GitAuthConfig,
) -> Result<ProjectRepoCloneResult, String> {
validate_clone_url(clone_url)?;
validate_local_clone_url(clone_url)?;
let branch = normalize_branch_option(default_branch);
if let Some(repo_dir) = find_local_repo_dir(repos_dir, project_dtag, Some(clone_url))? {
return Ok(ProjectRepoCloneResult {
@@ -468,8 +469,8 @@ pub async fn clone_project_repository(
default_branch: Option<String>,
state: State<'_, AppState>,
) -> Result<ProjectRepoCloneResult, String> {
validate_workspace_clone_url(&clone_url, &state)?;
let auth = build_git_auth_config(&state)?;
validate_local_clone_url_for_workspace(&clone_url, &state)?;
let auth = build_git_clone_auth_config(&clone_url, &state)?;
tauri::async_runtime::spawn_blocking(move || {
clone_project_repository_blocking(
repos_dir.as_deref(),
@@ -9,7 +9,10 @@ use crate::app_state::AppState;
use super::project_git::{first_output_line, normalize_branch_option};
use super::project_git_diff::clean_commit;
use super::project_git_exec::{build_git_auth_config, run_git, validate_workspace_clone_url};
use super::project_git_exec::{
build_git_auth_config, build_git_clone_auth_config, run_git,
validate_local_clone_url_for_workspace, validate_workspace_clone_url,
};
use super::project_git_workflow::clone_project_repository_blocking;
use super::project_repo_paths::find_local_repo_dir;
@@ -99,9 +102,8 @@ fn launch_terminal_at(path: &std::path::Path) -> Result<(), String> {
}
/// Opens the OS terminal at the project's local checkout. When there is no
/// local checkout yet, clones the repository from `clone_url` (authenticated
/// with the identity key, same as push/snapshot) into the repos dir first,
/// then opens the terminal at the fresh checkout.
/// local checkout yet, clones the repository from `clone_url` into the repos
/// dir first, then opens the terminal at the fresh checkout.
#[tauri::command]
pub async fn open_project_terminal(
repos_dir: Option<String>,
@@ -111,11 +113,16 @@ pub async fn open_project_terminal(
state: State<'_, AppState>,
) -> Result<ProjectTerminalResult, String> {
if let Some(clone_url) = clone_url.as_deref() {
validate_workspace_clone_url(clone_url, &state)?;
validate_local_clone_url_for_workspace(clone_url, &state)?;
}
// Auth is only needed for the clone path — keep the result outside the
// blocking task so it owns no borrowed Tauri state.
let auth = build_git_auth_config(&state);
// Public GitHub clones stay anonymous; Buzz remotes use the workspace
// identity. Keep the result outside the blocking task so it borrows no
// Tauri state.
let auth = if let Some(clone_url) = clone_url.as_deref() {
build_git_clone_auth_config(clone_url, &state)
} else {
build_git_auth_config(&state)
};
tauri::async_runtime::spawn_blocking(move || {
// An inaccessible repos root (fresh machine, nothing cloned yet) is
// not fatal here — the clone path below creates the default root. A
+2 -1
View File
@@ -718,11 +718,12 @@ export function useProjectRepoSnapshotQuery(
branchName?: string | null,
pullRequest?: ProjectPullRequest | null,
tag?: { name: string; commit: string } | null,
enabled = true,
) {
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
return useQuery({
enabled: Boolean(project?.cloneUrls[0]),
enabled: Boolean(enabled && project?.cloneUrls[0]),
queryKey: [
"project",
project?.id ?? "none",
@@ -2,6 +2,10 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { deriveRelayCloneUrl, effectiveCloneUrls } from "./projectCloneUrl.ts";
import {
projectRepoHost,
projectRepoHostForProject,
} from "./projectRepoHost.ts";
const OWNER = "a".repeat(64);
const ORIGIN = "https://relay.example";
@@ -60,3 +64,43 @@ test("effectiveCloneUrls derives a default when none is advertised", () => {
test("effectiveCloneUrls returns empty when no default can be derived", () => {
assert.deepEqual(effectiveCloneUrls([], null, OWNER, "repo"), []);
});
test("projectRepoHost recognizes a canonical repository on the relay", () => {
assert.deepEqual(projectRepoHost(`${ORIGIN}/git/${OWNER}/buzz`, ORIGIN), {
kind: "buzz",
});
});
test("projectRepoHost identifies an external repository by host", () => {
assert.deepEqual(
projectRepoHost("https://github.com/block/buzz.git", ORIGIN),
{ kind: "external", host: "github.com" },
);
});
test("projectRepoHost treats a non-repository relay path as external", () => {
assert.deepEqual(projectRepoHost(`${ORIGIN}/other/path`, ORIGIN), {
kind: "external",
host: "relay.example",
});
});
test("projectRepoHost fails closed while either URL is unresolved", () => {
assert.deepEqual(projectRepoHost(null, ORIGIN), { kind: "unresolved" });
assert.deepEqual(projectRepoHost(`${ORIGIN}/git/${OWNER}/buzz`, null), {
kind: "unresolved",
});
assert.deepEqual(projectRepoHost("not a URL", ORIGIN), {
kind: "unresolved",
});
});
test("projectRepoHostForProject recognizes an implicit relay repository", () => {
assert.deepEqual(
projectRepoHostForProject(
{ cloneUrls: [], dtag: "buzz", owner: OWNER },
ORIGIN,
),
{ kind: "buzz" },
);
});
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { projectCloneErrorPresentation } from "./projectGitError.ts";
test("explains unsupported authenticated GitHub clones without exposing git output", () => {
assert.deepEqual(
projectCloneErrorPresentation(
new Error(
"Cloning into '/Users/person/repos/app'... remote: repository requires SSH certificate authentication. fatal: requested URL returned error: 403",
),
"https://github.com/example/app.git",
),
{
title: "Repository access required",
description:
"This repository requires GitHub authentication. Buzz currently clones public GitHub repositories without credentials.",
},
);
});
test("presents missing and network failures clearly", () => {
assert.equal(
projectCloneErrorPresentation(new Error("Repository not found")).title,
"Repository not found",
);
assert.equal(
projectCloneErrorPresentation(new Error("Could not resolve host")).title,
"Couldn’t reach the repository",
);
});
test("uses a concise fallback", () => {
assert.deepEqual(projectCloneErrorPresentation(new Error("git failed")), {
title: "Couldn’t clone repository",
description:
"Try again. If the problem continues, contact the repository owner.",
});
});
@@ -0,0 +1,72 @@
export type ProjectGitErrorPresentation = {
title: string;
description: string;
};
function errorText(error: unknown) {
if (error instanceof Error) return error.message.toLowerCase();
return typeof error === "string" ? error.toLowerCase() : "";
}
function isGitHubUrl(cloneUrl: string | null | undefined) {
try {
return new URL(cloneUrl ?? "").hostname.toLowerCase() === "github.com";
} catch {
return false;
}
}
export function projectCloneErrorPresentation(
error: unknown,
cloneUrl?: string | null,
): ProjectGitErrorPresentation {
const message = errorText(error);
const github = isGitHubUrl(cloneUrl);
if (
/\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied|ssh certificate/.test(
message,
)
) {
return {
title: "Repository access required",
description: github
? "This repository requires GitHub authentication. Buzz currently clones public GitHub repositories without credentials."
: "Buzz could not authenticate with this repository. Check your access and try again.",
};
}
if (/\b404\b|repository not found|repository does not exist/.test(message)) {
return {
title: "Repository not found",
description:
"Check that the repository link is correct and that the repository still exists.",
};
}
if (
/timed? out|could not resolve host|failed to connect|connection (?:refused|reset)|network is unreachable|offline/.test(
message,
)
) {
return {
title: "Couldn’t reach the repository",
description: "Check your connection and try cloning again.",
};
}
if (
/already exists and is not an empty directory|destination path .* exists/.test(
message,
)
) {
return {
title: "Local folder already exists",
description:
"Choose a different repositories directory or remove the existing checkout.",
};
}
return {
title: "Couldn’t clone repository",
description: github
? "Try again, or open the repository on GitHub for more information."
: "Try again. If the problem continues, contact the repository owner.",
};
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { projectRepoUnavailableReason } from "./projectRepoAvailability.ts";
test("classifies a missing repository", () => {
assert.equal(
projectRepoUnavailableReason(new Error("remote: Repository not found")),
"missing",
);
assert.equal(projectRepoUnavailableReason(null), "missing");
});
test("classifies authentication failures before generic availability errors", () => {
assert.equal(
projectRepoUnavailableReason(
new Error("The requested URL returned error: 403"),
),
"authentication",
);
assert.equal(
projectRepoUnavailableReason(new Error("Authentication failed")),
"authentication",
);
});
test("classifies branch and network failures", () => {
assert.equal(
projectRepoUnavailableReason(
new Error("Remote branch main not found in upstream origin"),
),
"ref",
);
assert.equal(
projectRepoUnavailableReason(new Error("Could not resolve host: relay")),
"network",
);
assert.equal(
projectRepoUnavailableReason(new Error("git timed out after 300s")),
"network",
);
});
test("keeps unmatched failures generic", () => {
assert.equal(
projectRepoUnavailableReason(new Error("git exited with status 128")),
"unknown",
);
});
@@ -0,0 +1,48 @@
export type ProjectRepoUnavailableReason =
| "missing"
| "authentication"
| "network"
| "ref"
| "unknown";
export function projectRepoUnavailableReason(
error: unknown,
): ProjectRepoUnavailableReason {
const message =
error instanceof Error
? error.message.toLowerCase()
: typeof error === "string"
? error.toLowerCase()
: "";
if (!message) return "missing";
if (
/\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied/.test(
message,
)
) {
return "authentication";
}
if (
/\b404\b|repository not found|repository does not exist|not found on the relay/.test(
message,
)
) {
return "missing";
}
if (
/remote branch .* not found|could not resolve the requested repository ref|couldn't find remote ref/.test(
message,
)
) {
return "ref";
}
if (
/timed? out|could not resolve host|failed to connect|connection (?:refused|reset)|network is unreachable|offline/.test(
message,
)
) {
return "network";
}
return "unknown";
}
@@ -0,0 +1,53 @@
import { effectiveCloneUrls } from "./projectCloneUrl";
export type ProjectRepoHost =
| { kind: "buzz" }
| { kind: "external"; host: string }
| { kind: "unresolved" };
/**
* Classifies the canonical git remote using the same origin and path boundary
* enforced by the Tauri git commands. This is presentation/query gating only;
* Rust remains the security boundary for clone operations.
*/
export function projectRepoHost(
cloneUrl: string | null | undefined,
relayOrigin: string | null | undefined,
): ProjectRepoHost {
if (!cloneUrl || !relayOrigin) return { kind: "unresolved" };
try {
const clone = new URL(cloneUrl);
const relay = new URL(relayOrigin);
const isBuzzPath = /^\/git\/[0-9a-f]{64}\/[^/]+\/?$/i.test(clone.pathname);
if (clone.origin === relay.origin && isBuzzPath) {
return { kind: "buzz" };
}
return { kind: "external", host: clone.host };
} catch {
return { kind: "unresolved" };
}
}
export function projectRepoHostForProject(
project:
| {
cloneUrls: string[];
dtag: string;
owner: string;
}
| null
| undefined,
relayOrigin: string | null | undefined,
): ProjectRepoHost {
if (!project) return { kind: "unresolved" };
const cloneUrl = effectiveCloneUrls(
project.cloneUrls,
relayOrigin,
project.owner,
project.dtag,
)[0];
return projectRepoHost(cloneUrl, relayOrigin);
}
@@ -6,7 +6,12 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type ProjectsViewMode = "grid" | "list";
export type ProjectsRepositoryScope = "all" | "mine" | "local";
export type ProjectsRepositoryScope =
| "all"
| "mine"
| "local"
| "buzz"
| "linked";
export type ProjectsWorkItemScope = "all" | "mine";
export type ProjectsFilter =
| "all"
@@ -76,7 +81,14 @@ export function readStoredRepositoryScope(): ProjectsRepositoryScope {
const value = globalThis.localStorage?.getItem(
PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY,
);
if (value === "mine" || value === "local") return value;
if (
value === "mine" ||
value === "local" ||
value === "buzz" ||
value === "linked"
) {
return value;
}
const legacyFilter = globalThis.localStorage?.getItem(
PROJECTS_FILTER_STORAGE_KEY,
);
@@ -7,6 +7,7 @@ import {
pushProjectLocalRepository,
} from "@/shared/api/projectGit";
import type { Project, ProjectPullRequest } from "@/features/projects/hooks";
import { useProjectRepoHost } from "@/features/projects/useProjectRepoHost";
import { publishProjectPullRequestUpdate } from "./pullRequestMutations";
/** Local-vs-remote git sync status for a project checkout (ahead/behind
@@ -21,9 +22,10 @@ export function useProjectRepoSyncStatusQuery(
) {
const selectedBranch = branchName ?? project?.defaultBranch ?? null;
const selectedBaseBranch = baseBranch ?? project?.defaultBranch ?? null;
const host = useProjectRepoHost(project);
return useQuery({
enabled: Boolean(project?.cloneUrls[0]),
enabled: Boolean(host.kind === "buzz" && project?.cloneUrls[0]),
queryKey: [
"project",
project?.id ?? "none",
@@ -0,0 +1,9 @@
import type { SVGProps } from "react";
export function GitHubMark(props: SVGProps<SVGSVGElement>) {
return (
<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24" {...props}>
<path d="M12 .7a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2.23c-3.22.7-3.9-1.37-3.9-1.37-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.7.08-.7 1.17.08 1.78 1.2 1.78 1.2 1.04 1.77 2.72 1.26 3.38.96.1-.75.4-1.26.74-1.55-2.57-.29-5.28-1.28-5.28-5.69 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.16 1.18a10.95 10.95 0 0 1 5.76 0c2.19-1.49 3.15-1.18 3.15-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.83 1.19 3.09 0 4.42-2.71 5.39-5.29 5.68.42.36.79 1.06.79 2.14v3.18c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .7Z" />
</svg>
);
}
@@ -1,8 +1,10 @@
import {
CircleAlert,
CircleDot,
FolderGit2,
GitCommit,
GitPullRequest,
Globe,
TerminalSquare,
Trash2,
} from "lucide-react";
@@ -22,6 +24,8 @@ import {
getProjectUpdatedAt,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import { projectRepoHostForProject } from "@/features/projects/lib/projectRepoHost";
import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal";
import {
PROJECT_LIST_ROW_CLASS,
@@ -33,6 +37,7 @@ import {
} from "@/features/projects/ui/projectListRowStyles";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import {
AlertDialog,
AlertDialogAction,
@@ -44,10 +49,12 @@ import {
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { GitHubMark } from "./GitHubMark";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
function ProjectUpdatedLabel({
@@ -263,6 +270,95 @@ function StatusPill({ status }: { status: string }) {
);
}
function RepositoryUnavailableIndicator({
reason,
}: {
reason: ProjectRepoUnavailableReason | undefined;
}) {
if (!reason) return null;
const status = {
authentication: {
description: "Buzz could not authenticate with this repository.",
label: "Access failed",
},
missing: {
description: "No git repository was found on the Buzz relay.",
label: "Uninitialized",
},
network: {
description: "The Buzz git service could not be reached.",
label: "Unreachable",
},
ref: {
description: "The advertised branch is missing from the git remote.",
label: "Branch missing",
},
unknown: {
description: "Buzz could not load this repository.",
label: "Unavailable",
},
}[reason];
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={`Repository ${status.label.toLowerCase()}`}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-amber-600 hover:bg-amber-500/10 dark:text-amber-300"
role="img"
>
<CircleAlert className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-64">
<p className="font-medium">{status.label}</p>
<p className="text-muted-foreground">{status.description}</p>
</TooltipContent>
</Tooltip>
);
}
function ProjectHostIcon({
compact = false,
project,
}: {
compact?: boolean;
project: Project;
}) {
const relayOrigin = useRelayOrigin();
const host = projectRepoHostForProject(project, relayOrigin);
const label =
host.kind === "buzz"
? "Buzz-hosted repository"
: host.kind === "external"
? `Git data hosted on ${host.host}`
: "Repository host";
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"flex shrink-0 items-center justify-center border border-border/60 bg-muted/40 text-muted-foreground",
compact ? "h-7 w-7 rounded-md" : "h-9 w-9 rounded-lg",
)}
>
{host.kind === "buzz" ? (
<BuzzMark className={compact ? "h-3.5 w-4" : "h-4.5 w-5"} />
) : host.kind === "external" && host.host === "github.com" ? (
<GitHubMark className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
) : host.kind === "external" ? (
<Globe className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
) : (
<FolderGit2 className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
)}
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
@@ -399,6 +495,7 @@ type ProjectItemProps = {
people: string[];
profiles?: UserProfileLookup;
summary: ProjectActivitySummary | undefined;
repositoryUnavailableReason?: ProjectRepoUnavailableReason;
hasLocal: boolean;
canDelete: boolean;
deleteDisabled: boolean;
@@ -412,6 +509,7 @@ export function ProjectGridCard({
people,
profiles,
summary,
repositoryUnavailableReason,
hasLocal,
canDelete,
deleteDisabled,
@@ -428,13 +526,14 @@ export function ProjectGridCard({
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-w-0 items-center justify-between gap-3 px-4 pt-3">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40">
<FolderGit2 className="h-4.5 w-4.5 text-muted-foreground" />
</span>
<ProjectHostIcon project={project} />
<span className="min-w-0 truncate text-sm font-semibold text-foreground">
{project.name}
</span>
<StatusPill status={project.status} />
<RepositoryUnavailableIndicator
reason={repositoryUnavailableReason}
/>
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1">
<ProjectUpdatedLabel
@@ -483,6 +582,7 @@ export function ProjectListRow({
people,
profiles,
summary,
repositoryUnavailableReason,
hasLocal,
canDelete,
deleteDisabled,
@@ -498,9 +598,7 @@ export function ProjectListRow({
<ProjectCardButton onOpen={onOpen} project={project} />
<div className="flex min-w-0 items-start gap-2.5">
<div className="flex min-w-0 flex-1 items-start gap-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40">
<FolderGit2 className="h-4.5 w-4.5 text-muted-foreground" />
</span>
<ProjectHostIcon project={project} />
<div className="-mt-0.5 min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={PROJECT_LIST_ROW_TITLE_CLASS}>
@@ -515,6 +613,14 @@ export function ProjectListRow({
</div>
<div className={PROJECT_LIST_ROW_TRAILING_CLASS}>
<div
className="flex w-6 shrink-0 justify-center"
data-testid="projects-row-repository-status"
>
<RepositoryUnavailableIndicator
reason={repositoryUnavailableReason}
/>
</div>
<div
className="hidden items-center gap-3 xl:flex"
data-testid="projects-row-summary"
@@ -571,9 +677,7 @@ export function ProjectRailRow({
>
<ProjectCardButton onOpen={onOpen} project={project} />
<div className="flex min-w-0 items-start gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50">
<FolderGit2 className="h-3.5 w-3.5 text-muted-foreground" />
</span>
<ProjectHostIcon compact project={project} />
<div className="min-w-0 flex-1">
<span className="block min-w-0 truncate text-xs font-semibold text-foreground">
{project.name}
@@ -54,7 +54,6 @@ import {
} from "@/shared/layout/chromeLayout";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
import { cn } from "@/shared/lib/cn";
import { isSafeUrl } from "@/shared/lib/url";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
@@ -70,8 +69,10 @@ import {
resolveProjectDefaultBranch,
} from "@/features/projects/lib/projectBranches";
import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers";
import { useProjectRepoPresentation } from "@/features/projects/useProjectRepoHost";
import { WorkspaceTabs } from "./ProjectWorkspaceTabs";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
import { showProjectCloneErrorToast } from "./projectGitErrorToast";
import {
projectTerminalLabel,
useOpenProjectTerminal,
@@ -111,6 +112,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const projectQuery = useProjectQuery(projectId);
const projectsQuery = useProjectsQuery();
const project = projectQuery.data;
const repoRemote = useProjectRepoPresentation(project);
const repoStateQuery = useRepoStateQuery(project);
const pullRequestsQuery = useProjectPullRequestsQuery(project);
const defaultBranch = project
@@ -214,6 +216,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
activeBranch,
selectedTag ? null : selectedBranchPullRequest,
activeTag,
repoRemote.host.kind === "buzz",
);
const repoDiffQuery = useProjectRepoDiffQuery(
project,
@@ -375,9 +378,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
: repoSyncStatusQuery.data?.localPath || localRepoSnapshotQuery.data
? "Local"
: "Local missing",
remoteLabel: repoSnapshotQuery.isLoading ? "Remote checking" : "Remote",
...repoRemote.controls,
onCloneLocal:
!selectedTag && project?.cloneUrls[0]
!selectedTag && project?.cloneUrls[0] && repoRemote.canCloneLocally
? () => {
void handleCloneRepo();
}
@@ -566,11 +569,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
toast.success(result.message);
setRepoSource("local");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to clone repository",
);
showProjectCloneErrorToast(error, project?.cloneUrls[0]);
}
}, [cloneRepoMutation]);
}, [cloneRepoMutation, project?.cloneUrls]);
const handlePullRequestCreated = React.useCallback(
async (createdProject: Project, pullRequestId: string) => {
if (createdProject.id !== projectId) {
@@ -719,8 +720,6 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
}
const repoContributors = repoSnapshotQuery.data?.contributors ?? [];
const safeWebUrl =
project.webUrl && isSafeUrl(project.webUrl) ? project.webUrl : null;
const selectedPullRequest =
pullRequestsQuery.data?.find((item) => item.id === selectedPullRequestId) ??
null;
@@ -886,7 +885,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
<h2 className="truncate text-xl font-semibold tracking-tight">
{project.name}
</h2>
{safeWebUrl ? (
{repoRemote.webUrl &&
(repoRemote.host.kind !== "external" ||
repoSource === "local") ? (
<Button
asChild
aria-label="Open project web page"
@@ -895,7 +896,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
variant="ghost"
>
<a
href={safeWebUrl}
href={repoRemote.webUrl}
rel="noopener noreferrer"
target="_blank"
>
@@ -959,6 +960,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
pullRequestsError={pullRequestsQuery.error}
pullRequestsLoading={pullRequestsQuery.isLoading}
repoContributors={repoContributors}
repoHost={repoRemote.host}
repoSource={repoSource}
selectedCommitHash={selectedCommitHash}
selectedIssueId={selectedIssueId}
@@ -21,6 +21,7 @@ import {
languageForPath,
topLanguagesFromCounts,
} from "@/features/projects/lib/projectLanguages";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { ReadmePanel } from "./ProjectReadmePanel";
@@ -28,7 +29,10 @@ import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
type ProjectOverviewPanelProps = {
contributors: ProjectRepoContributor[];
externalHost?: string;
externalUrl?: string | null;
files: ProjectRepoFile[];
gitDataState: GitDataState;
project: Project;
onViewContributors: () => void;
profiles?: UserProfileLookup;
@@ -37,11 +41,10 @@ type ProjectOverviewPanelProps = {
snapshot: ProjectRepoSnapshot | null | undefined;
/** Branch picker + remote/local toggle for the readme header. */
sourceControls?: RepoSourceHeaderControls;
unavailableReason?: ProjectRepoUnavailableReason;
};
function shortHash(hash: string | undefined) {
return hash ? hash.slice(0, 7) : "None";
}
export type GitDataState = "checking" | "available" | "empty" | "unavailable";
function topLanguages(files: ProjectRepoFile[]) {
const counts: Record<string, number> = {};
@@ -138,7 +141,10 @@ export function OverviewRailSection({
export function ProjectOverviewPanel({
contributors,
externalHost,
externalUrl,
files,
gitDataState,
onViewContributors,
project,
profiles,
@@ -146,88 +152,113 @@ export function ProjectOverviewPanel({
readmeFile,
snapshot,
sourceControls,
unavailableReason,
}: ProjectOverviewPanelProps) {
const languages = topLanguages(files);
const people = projectPeople(project);
const latestCommit = snapshot?.latestCommit ?? null;
const gitDataAvailable = gitDataState === "available";
const unavailableSplash = gitDataState === "unavailable";
return (
<div className="grid overflow-hidden rounded-xl border border-border/60 bg-card xl:grid-cols-[minmax(0,1fr)_18rem]">
<div
className={cn(
"grid overflow-hidden rounded-xl border border-border/60 bg-card",
!unavailableSplash && "xl:grid-cols-[minmax(0,1fr)_18rem]",
)}
>
<div className="min-w-0">
{/* ReadmePanel renders its own "no README" fallback while keeping
the branch + source controls reachable. */}
<ReadmePanel file={readmeFile} sourceControls={sourceControls} />
<ReadmePanel
externalHost={externalHost}
externalUrl={externalUrl}
file={readmeFile}
gitDataState={gitDataState}
sourceControls={sourceControls}
unavailableReason={unavailableReason}
/>
</div>
<aside className="space-y-6 border-t border-border/60 p-4 xl:border-l xl:border-t-0">
<OverviewRailSection title="People">
<div className="flex items-center justify-between gap-3">
<PeopleAvatars people={people} profiles={profiles} />
<button
className="shrink-0 rounded-md text-xs font-medium text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onViewContributors}
type="button"
>
View all
</button>
</div>
</OverviewRailSection>
<OverviewRailSection title="Top Languages">
{languages.length > 0 ? (
<LanguageChips languages={languages} />
) : (
<p className="text-sm text-muted-foreground">
No language data is available yet.
</p>
)}
</OverviewRailSection>
<OverviewRailSection title="Repository">
<dl className="space-y-2 text-sm">
{!unavailableSplash ? (
<aside className="space-y-6 border-t border-border/60 p-4 xl:border-l xl:border-t-0">
<OverviewRailSection title="People">
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
Branch
</dt>
<dd className="font-medium text-foreground">
{project.defaultBranch}
</dd>
<PeopleAvatars people={people} profiles={profiles} />
<button
className="shrink-0 rounded-md text-xs font-medium text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onViewContributors}
type="button"
>
View all
</button>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitCommitHorizontal className="h-3.5 w-3.5" />
Latest
</dt>
<dd className="font-mono text-xs text-foreground">
{shortHash(latestCommit?.hash)}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<FileCode2 className="h-3.5 w-3.5" />
Files
</dt>
<dd className="font-medium text-foreground">{files.length}</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<Users className="h-3.5 w-3.5" />
Contributors
</dt>
<dd className="font-medium text-foreground">
{contributors.length}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitPullRequest className="h-3.5 w-3.5" />
Pull Requests
</dt>
<dd className="font-medium text-foreground">
{pullRequests.length}
</dd>
</div>
</dl>
</OverviewRailSection>
</aside>
</OverviewRailSection>
<OverviewRailSection title="Top Languages">
{languages.length > 0 ? (
<LanguageChips languages={languages} />
) : (
<p className="text-sm text-muted-foreground">
No language data is available yet.
</p>
)}
</OverviewRailSection>
<OverviewRailSection title="Buzz Activity">
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitPullRequest className="h-3.5 w-3.5" />
Pull Requests
</dt>
<dd className="font-medium text-foreground">
{pullRequests.length}
</dd>
</div>
</dl>
</OverviewRailSection>
<OverviewRailSection title="Git">
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
Branch
</dt>
<dd className="font-medium text-foreground">
{project.defaultBranch}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<GitCommitHorizontal className="h-3.5 w-3.5" />
Latest
</dt>
<dd className="font-mono text-xs text-foreground">
{gitDataAvailable && latestCommit
? latestCommit.hash.slice(0, 7)
: "—"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<FileCode2 className="h-3.5 w-3.5" />
Files
</dt>
<dd className="font-medium text-foreground">
{gitDataAvailable ? files.length : "—"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="flex items-center gap-1.5 text-muted-foreground">
<Users className="h-3.5 w-3.5" />
Contributors
</dt>
<dd className="font-medium text-foreground">
{gitDataAvailable ? contributors.length : "—"}
</dd>
</div>
</dl>
</OverviewRailSection>
</aside>
) : null}
</div>
);
}
@@ -1,6 +1,19 @@
import { BookOpen } from "lucide-react";
import {
BookOpen,
CircleAlert,
CloudOff,
DownloadCloud,
ExternalLink,
GitBranch,
Globe,
Loader2,
LockKeyhole,
RefreshCw,
} from "lucide-react";
import type { ProjectRepoFile } from "@/features/projects/hooks";
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import { Button } from "@/shared/ui/button";
import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
import {
baseName,
@@ -13,6 +26,7 @@ import {
RepoSyncActionButton,
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
import { GitHubMark } from "./GitHubMark";
export function findReadmeFile(files: ProjectRepoFile[]) {
const readmes = files.filter((file) =>
@@ -78,9 +92,17 @@ function normalizeReadmeMarkdown(content: string) {
export function ReadmePanel({
file,
gitDataState,
externalHost,
externalUrl,
sourceControls,
unavailableReason,
}: {
file: ProjectRepoFile | null;
gitDataState: "checking" | "available" | "empty" | "unavailable";
externalHost?: string;
externalUrl?: string | null;
unavailableReason?: ProjectRepoUnavailableReason;
/** Branch picker + remote/local toggle rendered in the panel header. */
sourceControls?: RepoSourceHeaderControls;
}) {
@@ -125,13 +147,146 @@ export function ReadmePanel({
</>
);
if (gitDataState === "checking") {
return (
<section className="overflow-hidden">
{header}
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading repository…
</div>
</section>
);
}
if (gitDataState === "unavailable") {
const reason = unavailableReason ?? "unknown";
const unavailableContent = {
authentication: {
description:
"Buzz could not authenticate with this repository. Check your access and try again.",
icon: LockKeyhole,
title: "Repository access failed",
},
missing: {
description:
"The project announcement exists, but its git repository was not found on the Buzz relay.",
icon: CircleAlert,
title: "Repository not initialized",
},
network: {
description:
"The Buzz git service could not be reached. Check your connection and try again.",
icon: CloudOff,
title: "Couldn’t reach repository",
},
ref: {
description:
"The selected branch is advertised by the project but is missing from its git remote.",
icon: GitBranch,
title: "Branch unavailable",
},
unknown: {
description:
"Buzz could not load this repository. Try again or contact the project owner.",
icon: CircleAlert,
title: "Repository unavailable",
},
} satisfies Record<
ProjectRepoUnavailableReason,
{
description: string;
icon: typeof CircleAlert;
title: string;
}
>;
const unavailable = unavailableContent[reason];
const UnavailableIcon = unavailable.icon;
return (
<section className="overflow-hidden">
<div className="flex min-h-64 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl border border-border/60 bg-muted/40 text-muted-foreground">
{externalHost === "github.com" ? (
<GitHubMark className="h-6 w-6" />
) : externalHost ? (
<Globe className="h-6 w-6" />
) : (
<UnavailableIcon className="h-6 w-6" />
)}
</div>
<h3 className="text-base font-semibold text-foreground">
{externalHost
? `Code hosted on ${externalHost}`
: unavailable.title}
</h3>
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
{externalHost
? "Clone this repository locally to explore its files, commits, and contributors in Buzz."
: unavailable.description}
</p>
{externalUrl ? (
<a
className="mt-2 max-w-lg truncate font-mono text-xs text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
href={externalUrl}
rel="noreferrer"
target="_blank"
>
{externalUrl}
</a>
) : null}
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
{!externalHost && sourceControls?.onFetch ? (
<Button
disabled={sourceControls.fetchPending}
onClick={sourceControls.onFetch}
size="sm"
variant="outline"
>
{sourceControls.fetchPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{sourceControls.fetchPending ? "Retrying…" : "Retry"}
</Button>
) : null}
{externalHost && sourceControls?.onCloneLocal ? (
<Button
disabled={sourceControls.clonePending}
onClick={sourceControls.onCloneLocal}
size="sm"
>
{sourceControls.clonePending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<DownloadCloud className="h-4 w-4" />
)}
{sourceControls.clonePending ? "Cloning…" : "Clone locally"}
</Button>
) : null}
{externalUrl ? (
<Button asChild size="sm" variant="outline">
<a href={externalUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
Open on {externalHost}
</a>
</Button>
) : null}
</div>
</div>
</section>
);
}
if (!file?.previewContent) {
return (
<section className="overflow-hidden">
{sourceControls ? header : null}
{header}
<div className="p-6 text-sm text-muted-foreground">
Add a README to this repository to describe setup, usage, and project
context.
{gitDataState === "empty"
? "No files have been pushed to this repository yet."
: "Add a README to this repository to describe setup, usage, and project context."}
</div>
</section>
);
@@ -614,6 +614,7 @@ export function RepositoryFilesPanel({
profiles,
fallbackAuthorPubkey,
sourceControls,
unavailableMessage,
}: {
files: ProjectRepoFile[];
snapshot: ProjectRepoSnapshot | null | undefined;
@@ -623,6 +624,7 @@ export function RepositoryFilesPanel({
fallbackAuthorPubkey?: string;
/** Branch picker + remote/local toggle rendered in the panel header. */
sourceControls?: RepoSourceHeaderControls;
unavailableMessage?: string;
}) {
const [currentPath, setCurrentPath] = React.useState("");
const [selectedFile, setSelectedFile] =
@@ -679,11 +681,13 @@ export function RepositoryFilesPanel({
// remote/local toggle must stay reachable when one source fails to load.
const stateMessage = isLoading
? "Loading repository files…"
: error
? "Could not load the repository file tree."
: files.length === 0
? "No files have been pushed yet."
: null;
: unavailableMessage
? unavailableMessage
: error
? "Could not load the repository file tree."
: files.length === 0
? "No files have been pushed yet."
: null;
if (stateMessage) {
if (!sourceControls) {
return (
@@ -2,7 +2,9 @@ import {
ChevronDown,
Cloud,
DownloadCloud,
ExternalLink,
GitBranch,
Globe,
HardDrive,
Loader2,
Plus,
@@ -23,6 +25,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { GitHubMark } from "./GitHubMark";
import { PROJECT_PANEL_ACTION_BUTTON_CLASS } from "./projectPanelStyles";
/** Branch picker shared by the readme and files panel headers. */
@@ -181,6 +184,8 @@ export type RepoSourceHeaderControls = {
localDisabled: boolean;
localLabel: string;
remoteLabel: string;
remoteKind?: "buzz" | "external";
externalUrl?: string | null;
/** Clones the repository when no local checkout is available. */
onCloneLocal?: () => void;
clonePending?: boolean;
@@ -213,7 +218,13 @@ export function RepoSourceDropdown({
}) {
const isLocal = controls.source === "local";
const cloneLocal = controls.localDisabled && controls.onCloneLocal;
const SourceIcon = isLocal ? HardDrive : Cloud;
const RemoteIcon =
controls.remoteKind === "external"
? controls.remoteLabel === "github.com"
? GitHubMark
: Globe
: Cloud;
const SourceIcon = isLocal ? HardDrive : RemoteIcon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -238,7 +249,7 @@ export function RepoSourceDropdown({
value={controls.source}
>
<DropdownMenuRadioItem value="remote">
<Cloud className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
<RemoteIcon className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
{controls.remoteLabel}
</DropdownMenuRadioItem>
{!cloneLocal ? (
@@ -280,6 +291,23 @@ export function RepoSyncActionButton({
}: {
controls: RepoSourceHeaderControls;
}) {
if (controls.remoteKind === "external") {
return controls.externalUrl ? (
<Button
asChild
className={PROJECT_PANEL_ACTION_BUTTON_CLASS}
size="sm"
title={`Open repository on ${controls.remoteLabel}`}
variant="ghost"
>
<a href={controls.externalUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
Open
</a>
</Button>
) : null;
}
const pull = controls.canPull && controls.onPull;
const push = controls.canPush && controls.onPush;
@@ -21,6 +21,8 @@ import {
commitAuthorPubkeysFromPullRequests,
type ViewerGitIdentity,
} from "@/features/projects/lib/projectContributorMatching";
import type { ProjectRepoHost } from "@/features/projects/lib/projectRepoHost";
import { projectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
import { Tabs, TabsContent } from "@/shared/ui/tabs";
@@ -31,7 +33,10 @@ import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel";
import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels";
import { ProjectIssuesPanel } from "./ProjectIssuesPanel";
import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton";
import { ProjectOverviewPanel } from "./ProjectOverviewPanel";
import {
type GitDataState,
ProjectOverviewPanel,
} from "./ProjectOverviewPanel";
import {
PullRequestDetailHeader,
PullRequestMetaRail,
@@ -134,6 +139,7 @@ export function WorkspaceTabs({
profiles,
repoContributors,
repoSource,
repoHost,
sourceControls,
terminalTitle,
viewerGitIdentity,
@@ -171,6 +177,7 @@ export function WorkspaceTabs({
profiles?: UserProfileLookup;
repoContributors: ProjectRepoContributor[];
repoSource: "remote" | "local";
repoHost: ProjectRepoHost;
/** Branch picker + remote/local toggle for the Code tab header. */
sourceControls?: RepoSourceHeaderControls;
terminalTitle?: string;
@@ -187,6 +194,23 @@ export function WorkspaceTabs({
displayedSnapshot?.contributors ?? repoContributors;
const files = displayedSnapshot?.files ?? [];
const readmeFile = React.useMemo(() => findReadmeFile(files), [files]);
const externalHost =
repoSource === "remote" && repoHost.kind === "external"
? repoHost.host
: undefined;
const gitDataState: GitDataState = displayedSnapshotLoading
? "checking"
: externalHost || displayedSnapshotError || !displayedSnapshot
? "unavailable"
: files.length === 0
? "empty"
: "available";
const unavailableReason =
gitDataState === "unavailable" && !externalHost
? projectRepoUnavailableReason(displayedSnapshotError)
: undefined;
const repositoryLoaded =
gitDataState === "available" || gitDataState === "empty";
const commitAuthorPubkeys = React.useMemo(
() => commitAuthorPubkeysFromPullRequests(pullRequests),
[pullRequests],
@@ -274,34 +298,36 @@ export function WorkspaceTabs({
onValueChange={handleTabChange}
value={selectedTab}
>
<div className="flex h-10 min-w-0 items-center gap-1">
<ProjectTabsList prsActive={isPullRequestSelected} />
{onOpenTerminal ? (
<Button
aria-label="Open terminal"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => onOpenTerminal()}
size="icon"
title={terminalTitle ?? "Open terminal"}
variant="ghost"
>
<SquareTerminal className="h-[1.125rem] w-[1.125rem]" />
</Button>
) : null}
{updatePullRequestAction ? (
<Button
className="h-8 shrink-0 gap-1.5"
disabled={updatePullRequestAction.pending}
onClick={updatePullRequestAction.onUpdate}
size="sm"
title="Publish the pushed commit to this pull request"
variant="outline"
>
<RefreshCw className="h-4 w-4" />
{updatePullRequestAction.pending ? "Updating…" : "Update PR"}
</Button>
) : null}
</div>
{repositoryLoaded ? (
<div className="flex h-10 min-w-0 items-center gap-1">
<ProjectTabsList prsActive={isPullRequestSelected} />
{onOpenTerminal ? (
<Button
aria-label="Open terminal"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => onOpenTerminal()}
size="icon"
title={terminalTitle ?? "Open terminal"}
variant="ghost"
>
<SquareTerminal className="h-[1.125rem] w-[1.125rem]" />
</Button>
) : null}
{updatePullRequestAction ? (
<Button
className="h-8 shrink-0 gap-1.5"
disabled={updatePullRequestAction.pending}
onClick={updatePullRequestAction.onUpdate}
size="sm"
title="Publish the pushed commit to this pull request"
variant="outline"
>
<RefreshCw className="h-4 w-4" />
{updatePullRequestAction.pending ? "Updating…" : "Update PR"}
</Button>
) : null}
</div>
) : null}
{selectedPullRequest ? (
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
{/* Two full-height columns: the meta rail runs all the way to the
@@ -366,7 +392,10 @@ export function WorkspaceTabs({
<TabsContent className="m-0" value="overview">
<ProjectOverviewPanel
contributors={displayedContributors}
externalHost={externalHost}
externalUrl={externalHost ? sourceControls?.externalUrl : null}
files={files}
gitDataState={gitDataState}
onViewContributors={() => setSelectedTab("contributors")}
profiles={profiles}
project={project}
@@ -374,6 +403,7 @@ export function WorkspaceTabs({
readmeFile={readmeFile}
snapshot={displayedSnapshot}
sourceControls={sourceControls}
unavailableReason={unavailableReason}
/>
</TabsContent>
@@ -471,6 +501,11 @@ export function WorkspaceTabs({
profiles={profiles}
snapshot={displayedSnapshot}
sourceControls={sourceControls}
unavailableMessage={
externalHost
? `Not mirrored on Buzz. Repository files are hosted on ${externalHost}.`
: undefined
}
/>
</TabsContent>
@@ -15,6 +15,7 @@ import {
} from "@/features/projects/hooks";
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
import { projectRepoHostForProject } from "@/features/projects/lib/projectRepoHost";
import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed";
import {
EmptyFilteredState,
@@ -70,6 +71,7 @@ import { useIdentityQuery } from "@/shared/api/hooks";
import { topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { Button } from "@/shared/ui/button";
import { PageHeader } from "@/shared/ui/PageHeader";
@@ -81,6 +83,8 @@ const REPOSITORY_SCOPE_OPTIONS: Array<{
{ label: "All", value: "all" },
{ label: "My Repositories", value: "mine" },
{ label: "Local", value: "local" },
{ label: "Buzz-hosted", value: "buzz" },
{ label: "Linked", value: "linked" },
];
const PULL_REQUEST_SCOPE_OPTIONS: Array<{
label: string;
@@ -100,6 +104,7 @@ const ISSUE_SCOPE_OPTIONS: Array<{
export function ProjectsView() {
const { goProject } = useAppNavigation();
const { activeCommunity } = useCommunities();
const relayOrigin = useRelayOrigin();
const scrollIdleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
@@ -164,11 +169,17 @@ export function ProjectsView() {
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(
filter === "all" || filter === "prs" || filter === "issues" ? projects : [],
);
// One blobless clone per unique repository — only scan while the overview
// header (filter === "all") is actually visible.
// One blobless clone per unique Buzz repository. The repository view also
// scans so metadata-only announcements can be labelled accurately.
const snapshotProjects = React.useMemo(
() => (filter === "all" ? uniqueRepositories(projects) : []),
[filter, projects],
() =>
filter === "all" || filter === "repositories"
? uniqueRepositories(projects).filter(
(project) =>
projectRepoHostForProject(project, relayOrigin).kind === "buzz",
)
: [],
[filter, projects, relayOrigin],
);
const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery(
snapshotProjects,
@@ -299,6 +310,14 @@ export function ProjectsView() {
return isProjectMine(project, currentPubkey);
if (repositoryScope === "local")
return hasLocalCheckout(project, localRepoNames);
if (repositoryScope === "buzz")
return (
projectRepoHostForProject(project, relayOrigin).kind === "buzz"
);
if (repositoryScope === "linked")
return (
projectRepoHostForProject(project, relayOrigin).kind === "external"
);
if (filter === "agents") {
return projectHasAgent(project, people, profiles);
}
@@ -330,6 +349,7 @@ export function ProjectsView() {
localRepoNames,
profiles,
projects,
relayOrigin,
repositoryScope,
sort,
]);
@@ -475,6 +495,9 @@ export function ProjectsView() {
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={
repoSnapshotsQuery.data?.unavailable[project.id]
}
summary={summary}
/>
);
@@ -496,6 +519,9 @@ export function ProjectsView() {
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={
repoSnapshotsQuery.data?.unavailable[project.id]
}
summary={summary}
/>
);
@@ -555,7 +581,7 @@ export function ProjectsView() {
profiles={profiles}
projects={projects}
pullRequests={projectsWorkItemsQuery.data?.pullRequests.items ?? []}
snapshots={repoSnapshotsQuery.data}
snapshots={repoSnapshotsQuery.data?.snapshots}
/>
</>
);
@@ -0,0 +1,11 @@
import { toast } from "sonner";
import { projectCloneErrorPresentation } from "@/features/projects/lib/projectGitError";
export function showProjectCloneErrorToast(
error: unknown,
cloneUrl?: string | null,
) {
const presentation = projectCloneErrorPresentation(error, cloneUrl);
toast.error(presentation.title, { description: presentation.description });
}
@@ -3,6 +3,7 @@ import * as React from "react";
import { toast } from "sonner";
import type { Project } from "@/features/projects/hooks";
import { projectCloneErrorPresentation } from "@/features/projects/lib/projectGitError";
import { openProjectTerminal } from "@/shared/api/projectGit";
export function projectTerminalLabel(hasLocalCheckout: boolean) {
@@ -42,10 +43,17 @@ export function useOpenProjectTerminal(reposDir?: string | null) {
toast.dismiss(toastId);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to open terminal",
{ id: toastId },
);
const presentation = options.hasLocalCheckout
? {
title: "Couldn’t open terminal",
description:
"Buzz could not open this checkout in your configured terminal.",
}
: projectCloneErrorPresentation(error, project.cloneUrls[0]);
toast.error(presentation.title, {
description: presentation.description,
id: toastId,
});
}
},
[queryClient, reposDir],
@@ -0,0 +1,39 @@
import type { Project } from "@/features/projects/hooks";
import {
type ProjectRepoHost,
projectRepoHostForProject,
} from "@/features/projects/lib/projectRepoHost";
import { isSafeUrl } from "@/shared/lib/url";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
export function useProjectRepoHost(
project: Project | null | undefined,
): ProjectRepoHost {
return projectRepoHostForProject(project, useRelayOrigin());
}
export function useProjectRepoPresentation(
project: Project | null | undefined,
) {
const host = useProjectRepoHost(project);
const webUrl =
project?.webUrl && isSafeUrl(project.webUrl) ? project.webUrl : null;
return {
host,
webUrl,
canCloneLocally:
host.kind === "buzz" ||
(host.kind === "external" && host.host === "github.com"),
controls: {
externalUrl: host.kind === "external" ? webUrl : null,
remoteKind: host.kind === "unresolved" ? undefined : host.kind,
remoteLabel:
host.kind === "external"
? host.host
: host.kind === "buzz"
? "Buzz"
: "Remote",
},
};
}
@@ -7,6 +7,10 @@ import {
} from "@/shared/api/projectGit";
import type { ProjectRepoSnapshot } from "@/shared/api/types";
import type { Project } from "./hooks";
import {
type ProjectRepoUnavailableReason,
projectRepoUnavailableReason,
} from "./lib/projectRepoAvailability";
// Remote snapshots are backed by a blobless `git clone` per repository, so the
// overview scan is deliberately throttled and cached for a long time.
@@ -52,8 +56,12 @@ async function fetchProjectSnapshot(
async function fetchProjectsRepoSnapshots(
projects: Project[],
reposDir: string | null | undefined,
): Promise<Record<string, ProjectRepoSnapshot>> {
): Promise<{
snapshots: Record<string, ProjectRepoSnapshot>;
unavailable: Record<string, ProjectRepoUnavailableReason>;
}> {
const snapshots: Record<string, ProjectRepoSnapshot> = {};
const unavailable: Record<string, ProjectRepoUnavailableReason> = {};
const queue = [...projects];
const workers = Array.from(
@@ -64,16 +72,20 @@ async function fetchProjectsRepoSnapshots(
if (!project) return;
try {
const snapshot = await fetchProjectSnapshot(project, reposDir);
if (snapshot) snapshots[project.id] = snapshot;
} catch {
// Best-effort: unreachable or empty repositories are skipped.
if (snapshot) {
snapshots[project.id] = snapshot;
} else {
unavailable[project.id] = "missing";
}
} catch (error) {
unavailable[project.id] = projectRepoUnavailableReason(error);
}
}
},
);
await Promise.all(workers);
return snapshots;
return { snapshots, unavailable };
}
/**