mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user