From 0d2bfcaa687e34fc37dd208dcd4cb15c4c09e413 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 29 Jul 2026 18:33:07 +0200 Subject: [PATCH] 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 --- .../src/commands/project_git_exec.rs | 78 ++++++++ .../src/commands/project_git_workflow.rs | 11 +- .../src/commands/project_terminal.rs | 23 ++- desktop/src/features/projects/hooks.ts | 3 +- .../projects/lib/projectCloneUrl.test.mjs | 44 +++++ .../projects/lib/projectGitError.test.mjs | 39 ++++ .../features/projects/lib/projectGitError.ts | 72 +++++++ .../lib/projectRepoAvailability.test.mjs | 49 +++++ .../projects/lib/projectRepoAvailability.ts | 48 +++++ .../features/projects/lib/projectRepoHost.ts | 53 ++++++ .../projects/lib/projectsViewHelpers.ts | 16 +- .../src/features/projects/repoSyncHooks.ts | 4 +- .../src/features/projects/ui/GitHubMark.tsx | 9 + .../src/features/projects/ui/ProjectCards.tsx | 122 +++++++++++- .../projects/ui/ProjectDetailScreen.tsx | 24 +-- .../projects/ui/ProjectOverviewPanel.tsx | 177 ++++++++++-------- .../projects/ui/ProjectReadmePanel.tsx | 163 +++++++++++++++- .../projects/ui/ProjectRepositoryPanel.tsx | 14 +- .../projects/ui/ProjectRepositorySource.tsx | 32 +++- .../projects/ui/ProjectWorkspaceTabs.tsx | 93 ++++++--- .../src/features/projects/ui/ProjectsView.tsx | 36 +++- .../projects/ui/projectGitErrorToast.ts | 11 ++ .../projects/ui/useOpenProjectTerminal.ts | 16 +- .../features/projects/useProjectRepoHost.ts | 39 ++++ .../projects/useProjectsRepoSnapshots.ts | 22 ++- 25 files changed, 1034 insertions(+), 164 deletions(-) create mode 100644 desktop/src/features/projects/lib/projectGitError.test.mjs create mode 100644 desktop/src/features/projects/lib/projectGitError.ts create mode 100644 desktop/src/features/projects/lib/projectRepoAvailability.test.mjs create mode 100644 desktop/src/features/projects/lib/projectRepoAvailability.ts create mode 100644 desktop/src/features/projects/lib/projectRepoHost.ts create mode 100644 desktop/src/features/projects/ui/GitHubMark.tsx create mode 100644 desktop/src/features/projects/ui/projectGitErrorToast.ts create mode 100644 desktop/src/features/projects/useProjectRepoHost.ts diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index e4a8ad7b4..c616d39db 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -203,6 +203,22 @@ pub(crate) fn build_git_auth_config(state: &AppState) -> Result Result { + 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 { 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::>() + }) + .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 { 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()); + } } diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 39832feb1..767d85521 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -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 { - 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, state: State<'_, AppState>, ) -> Result { - 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(), diff --git a/desktop/src-tauri/src/commands/project_terminal.rs b/desktop/src-tauri/src/commands/project_terminal.rs index 31dbc74c6..c583dd0db 100644 --- a/desktop/src-tauri/src/commands/project_terminal.rs +++ b/desktop/src-tauri/src/commands/project_terminal.rs @@ -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, @@ -111,11 +113,16 @@ pub async fn open_project_terminal( state: State<'_, AppState>, ) -> Result { 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 diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a51191d47..a2d6bebd4 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -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", diff --git a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs index 6179c46a0..cc00d5f63 100644 --- a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs +++ b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs @@ -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" }, + ); +}); diff --git a/desktop/src/features/projects/lib/projectGitError.test.mjs b/desktop/src/features/projects/lib/projectGitError.test.mjs new file mode 100644 index 000000000..cc691bb0d --- /dev/null +++ b/desktop/src/features/projects/lib/projectGitError.test.mjs @@ -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.", + }); +}); diff --git a/desktop/src/features/projects/lib/projectGitError.ts b/desktop/src/features/projects/lib/projectGitError.ts new file mode 100644 index 000000000..b99933f1e --- /dev/null +++ b/desktop/src/features/projects/lib/projectGitError.ts @@ -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.", + }; +} diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs new file mode 100644 index 000000000..b1a40ca9b --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs @@ -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", + ); +}); diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.ts b/desktop/src/features/projects/lib/projectRepoAvailability.ts new file mode 100644 index 000000000..803548d3d --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoAvailability.ts @@ -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"; +} diff --git a/desktop/src/features/projects/lib/projectRepoHost.ts b/desktop/src/features/projects/lib/projectRepoHost.ts new file mode 100644 index 000000000..8838ca3a6 --- /dev/null +++ b/desktop/src/features/projects/lib/projectRepoHost.ts @@ -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); +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 032827144..511906581 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -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, ); diff --git a/desktop/src/features/projects/repoSyncHooks.ts b/desktop/src/features/projects/repoSyncHooks.ts index 457ccca17..a0973066a 100644 --- a/desktop/src/features/projects/repoSyncHooks.ts +++ b/desktop/src/features/projects/repoSyncHooks.ts @@ -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", diff --git a/desktop/src/features/projects/ui/GitHubMark.tsx b/desktop/src/features/projects/ui/GitHubMark.tsx new file mode 100644 index 000000000..29c960210 --- /dev/null +++ b/desktop/src/features/projects/ui/GitHubMark.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from "react"; + +export function GitHubMark(props: SVGProps) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 52308f7a0..af8f34a75 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -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 ( + + + + + + + +

{status.label}

+

{status.description}

+
+
+ ); +} + +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 ( + + + + {host.kind === "buzz" ? ( + + ) : host.kind === "external" && host.host === "github.com" ? ( + + ) : host.kind === "external" ? ( + + ) : ( + + )} + + + {label} + + ); +} + export function EmptyState() { return (
@@ -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({
- - - + {project.name} +
- - - +
@@ -515,6 +613,14 @@ export function ProjectListRow({
+
+ +
- - - +
{project.name} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 587dd0d11..30db788a2 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -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) {

{project.name}

- {safeWebUrl ? ( + {repoRemote.webUrl && + (repoRemote.host.kind !== "external" || + repoSource === "local") ? ( -
- - - {languages.length > 0 ? ( - - ) : ( -

- No language data is available yet. -

- )} -
- -
+ {!unavailableSplash ? ( +
-
- + + + {languages.length > 0 ? ( + + ) : ( +

+ No language data is available yet. +

+ )} +
+ +
+
+
+ + Pull Requests +
+
+ {pullRequests.length} +
+
+
+
+ +
+
+
+ + Branch +
+
+ {project.defaultBranch} +
+
+
+
+ + Latest +
+
+ {gitDataAvailable && latestCommit + ? latestCommit.hash.slice(0, 7) + : "—"} +
+
+
+
+ + Files +
+
+ {gitDataAvailable ? files.length : "—"} +
+
+
+
+ + Contributors +
+
+ {gitDataAvailable ? contributors.length : "—"} +
+
+
+
+ + ) : null}
); } diff --git a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx index 9150f5a0c..290733aec 100644 --- a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx +++ b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx @@ -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 ( +
+ {header} +
+ + Loading repository… +
+
+ ); + } + + 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 ( +
+
+
+ {externalHost === "github.com" ? ( + + ) : externalHost ? ( + + ) : ( + + )} +
+

+ {externalHost + ? `Code hosted on ${externalHost}` + : unavailable.title} +

+

+ {externalHost + ? "Clone this repository locally to explore its files, commits, and contributors in Buzz." + : unavailable.description} +

+ {externalUrl ? ( +
+ {externalUrl} + + ) : null} +
+ {!externalHost && sourceControls?.onFetch ? ( + + ) : null} + {externalHost && sourceControls?.onCloneLocal ? ( + + ) : null} + {externalUrl ? ( + + ) : null} +
+
+
+ ); + } + if (!file?.previewContent) { return (
- {sourceControls ? header : null} + {header}
- 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."}
); diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index 93965b29d..6335aa480 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -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 ( diff --git a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx index 83943b283..f1c9ecd45 100644 --- a/desktop/src/features/projects/ui/ProjectRepositorySource.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositorySource.tsx @@ -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 ( @@ -238,7 +249,7 @@ export function RepoSourceDropdown({ value={controls.source} > - + {controls.remoteLabel} {!cloneLocal ? ( @@ -280,6 +291,23 @@ export function RepoSyncActionButton({ }: { controls: RepoSourceHeaderControls; }) { + if (controls.remoteKind === "external") { + return controls.externalUrl ? ( + + ) : null; + } + const pull = controls.canPull && controls.onPull; const push = controls.canPush && controls.onPush; diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 4dc97635d..d14942fd2 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -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} > -
- - {onOpenTerminal ? ( - - ) : null} - {updatePullRequestAction ? ( - - ) : null} -
+ {repositoryLoaded ? ( +
+ + {onOpenTerminal ? ( + + ) : null} + {updatePullRequestAction ? ( + + ) : null} +
+ ) : null} {selectedPullRequest ? (
{/* Two full-height columns: the meta rail runs all the way to the @@ -366,7 +392,10 @@ export function WorkspaceTabs({ setSelectedTab("contributors")} profiles={profiles} project={project} @@ -374,6 +403,7 @@ export function WorkspaceTabs({ readmeFile={readmeFile} snapshot={displayedSnapshot} sourceControls={sourceControls} + unavailableReason={unavailableReason} /> @@ -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 + } /> diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 2f22bfe0c..87c9050c7 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -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 | 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} /> ); diff --git a/desktop/src/features/projects/ui/projectGitErrorToast.ts b/desktop/src/features/projects/ui/projectGitErrorToast.ts new file mode 100644 index 000000000..93fc88e59 --- /dev/null +++ b/desktop/src/features/projects/ui/projectGitErrorToast.ts @@ -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 }); +} diff --git a/desktop/src/features/projects/ui/useOpenProjectTerminal.ts b/desktop/src/features/projects/ui/useOpenProjectTerminal.ts index 64d241553..076f5ac12 100644 --- a/desktop/src/features/projects/ui/useOpenProjectTerminal.ts +++ b/desktop/src/features/projects/ui/useOpenProjectTerminal.ts @@ -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], diff --git a/desktop/src/features/projects/useProjectRepoHost.ts b/desktop/src/features/projects/useProjectRepoHost.ts new file mode 100644 index 000000000..cffd1dfc1 --- /dev/null +++ b/desktop/src/features/projects/useProjectRepoHost.ts @@ -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", + }, + }; +} diff --git a/desktop/src/features/projects/useProjectsRepoSnapshots.ts b/desktop/src/features/projects/useProjectsRepoSnapshots.ts index 256c90a1e..2bd0c82b0 100644 --- a/desktop/src/features/projects/useProjectsRepoSnapshots.ts +++ b/desktop/src/features/projects/useProjectsRepoSnapshots.ts @@ -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> { +): Promise<{ + snapshots: Record; + unavailable: Record; +}> { const snapshots: Record = {}; + const unavailable: Record = {}; 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 }; } /**