From cc0b0cc99e98979b465c0cc40f10692cd29b7445 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 24 Jul 2026 09:14:16 +0200 Subject: [PATCH 01/19] feat(projects): introduce multi-repository project model Add an owner-authored project event that groups NIP-34 repositories while preserving standalone repositories as legacy single-repository projects. --- crates/buzz-core/src/kind.rs | 9 + crates/buzz-relay/src/handlers/ingest.rs | 20 +- crates/buzz-sdk/src/builders.rs | 227 +++++++++++++-- .../features/projects/projectModels.test.mjs | 119 ++++++++ .../src/features/projects/projectModels.ts | 266 ++++++++++++++++++ desktop/src/shared/constants/kinds.ts | 2 + docs/nips/NIP-BP.md | 88 ++++++ 7 files changed, 708 insertions(+), 23 deletions(-) create mode 100644 desktop/src/features/projects/projectModels.test.mjs create mode 100644 desktop/src/features/projects/projectModels.ts create mode 100644 docs/nips/NIP-BP.md diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b912169801..820fe27328 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -305,6 +305,13 @@ pub const KIND_WINDOW_BOUNDS: u32 = 39006; /// Workflow definition (parameterized replaceable, d=workflow_uuid). pub const KIND_WORKFLOW_DEF: u32 = 30620; +/// Buzz project announcement (parameterized replaceable, d=project-id). +/// +/// A project is an owner-authored grouping above NIP-34 repositories. Repeated +/// `a` tags reference kind:30617 repository coordinates; exactly one is marked +/// `primary` when the project contains repositories. +pub const KIND_PROJECT_ANNOUNCEMENT: u32 = 30621; + /// NIP-DV: per-viewer DM visibility snapshot (relay-signed, parameterized /// replaceable, d=viewer_pubkey). Carries one `h` tag per DM the viewer has /// hidden from their sidebar. Re-published by the relay on every hide/unhide so @@ -579,6 +586,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MEMBER_REMOVED_NOTIFICATION, KIND_AGENT_TURN_METRIC, KIND_WORKFLOW_DEF, + KIND_PROJECT_ANNOUNCEMENT, KIND_LONG_FORM, KIND_USER_STATUS, KIND_READ_STATE, @@ -709,6 +717,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT_ANNOUNCEMENT)); // 30621 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ca529d1db6..2f94606546 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -28,8 +28,8 @@ use buzz_core::kind::{ KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT_ANNOUNCEMENT, KIND_REACTION, KIND_READ_STATE, + KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -287,7 +287,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), // NIP-34: Git repository events - KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite), + KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE | KIND_PROJECT_ANNOUNCEMENT => { + Ok(Scope::ReposWrite) + } KIND_GIT_PATCH | KIND_GIT_PULL_REQUEST | KIND_GIT_PR_UPDATE @@ -414,6 +416,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE + | KIND_PROJECT_ANNOUNCEMENT | KIND_GIT_PATCH | KIND_GIT_PULL_REQUEST | KIND_GIT_PR_UPDATE @@ -2660,6 +2663,17 @@ mod tests { assert!(is_global_only_kind(KIND_LONG_FORM)); } + #[test] + fn project_announcement_uses_repo_scope_and_global_storage() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PROJECT_ANNOUNCEMENT, &dummy).unwrap(), + Scope::ReposWrite, + ); + assert!(is_global_only_kind(KIND_PROJECT_ANNOUNCEMENT)); + assert!(!requires_h_channel_scope(KIND_PROJECT_ANNOUNCEMENT)); + } + #[test] fn user_status_requires_users_write_scope() { let dummy = make_dummy_event(); diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index edad401bf9..7fde3370fa 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, - KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, + KIND_PROJECT_ANNOUNCEMENT, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -89,37 +89,41 @@ fn check_hex_exact(s: &str, len: usize, field: &str) -> Result /// no `..`. Shared by `build_repo_announcement` and `GitRepoCoord` so a /// repo coordinate built directly through the SDK (bypassing CLI-side /// `validate_repo_id`) can't slip an invalid `d`-tag into an `a`-tag value. -fn check_repo_id(repo_id: &str) -> Result<(), SdkError> { - if repo_id.is_empty() { - return Err(SdkError::InvalidInput("repo_id must not be empty".into())); +fn check_addressable_id(id: &str, field: &str) -> Result<(), SdkError> { + if id.is_empty() { + return Err(SdkError::InvalidInput(format!("{field} must not be empty"))); } - if repo_id.len() > 64 { + if id.len() > 64 { return Err(SdkError::InvalidInput(format!( - "repo_id exceeds 64 characters (got {})", - repo_id.len() + "{field} exceeds 64 characters (got {})", + id.len() ))); } - if !repo_id + if !id .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') { - return Err(SdkError::InvalidInput( - "repo_id may only contain [a-zA-Z0-9._-]".into(), - )); + return Err(SdkError::InvalidInput(format!( + "{field} may only contain [a-zA-Z0-9._-]" + ))); } - if repo_id.starts_with('.') { - return Err(SdkError::InvalidInput( - "repo_id must not start with a dot".into(), - )); + if id.starts_with('.') { + return Err(SdkError::InvalidInput(format!( + "{field} must not start with a dot" + ))); } - if repo_id.contains("..") { - return Err(SdkError::InvalidInput( - "repo_id must not contain '..'".into(), - )); + if id.contains("..") { + return Err(SdkError::InvalidInput(format!( + "{field} must not contain '..'" + ))); } Ok(()) } +fn check_repo_id(repo_id: &str) -> Result<(), SdkError> { + check_addressable_id(repo_id, "repo_id") +} + /// Validate and normalize a NIP-30 custom emoji shortcode. /// /// Shortcodes are case-insensitive in Buzz's relay-global set; lowercase @@ -980,6 +984,81 @@ impl GitRepoCoord { } } +/// A repository membership entry in a Buzz project announcement. +pub struct ProjectRepositoryRef<'a> { + /// The NIP-34 repository coordinate referenced by the project. + pub repository: &'a GitRepoCoord, + /// Whether this is the project's default repository. + pub primary: bool, +} + +/// Build a Buzz project announcement event (kind:30621). +/// +/// Projects are owner-authored, parameterized replaceable events that group +/// existing NIP-34 repositories by their full kind:30617 coordinates. Empty +/// projects are valid; a non-empty project must mark exactly one repository as +/// primary. Publishing again with the same `project_id` updates the project. +pub fn build_project_announcement( + project_id: &str, + name: &str, + description: Option<&str>, + repositories: &[ProjectRepositoryRef<'_>], + channel_id: Option, +) -> Result { + check_addressable_id(project_id, "project_id")?; + if name.trim().is_empty() { + return Err(SdkError::InvalidInput("name must not be empty".into())); + } + if name.len() > 128 { + return Err(SdkError::InvalidInput(format!( + "name exceeds 128 characters (got {})", + name.len() + ))); + } + + let description = description.unwrap_or(""); + check_content(description, 1024)?; + if repositories.len() > 100 { + return Err(SdkError::InvalidInput(format!( + "too many repositories (max 100, got {})", + repositories.len() + ))); + } + + let primary_count = repositories.iter().filter(|repo| repo.primary).count(); + if (!repositories.is_empty() && primary_count != 1) + || (repositories.is_empty() && primary_count != 0) + { + return Err(SdkError::InvalidInput( + "a non-empty project must have exactly one primary repository".into(), + )); + } + + let mut tags = Vec::with_capacity(repositories.len() + 3); + tags.push(tag(&["d", project_id])?); + tags.push(tag(&["name", name])?); + if let Some(channel_id) = channel_id { + tags.push(tag(&["h", &channel_id.to_string()])?); + } + + let mut seen = std::collections::HashSet::with_capacity(repositories.len()); + for repository in repositories { + let coordinate = repository.repository.to_a_tag_value()?; + if !seen.insert(coordinate.clone()) { + return Err(SdkError::InvalidInput(format!( + "duplicate repository coordinate {coordinate}" + ))); + } + if repository.primary { + tags.push(tag(&["a", &coordinate, "", "primary"])?); + } else { + tags.push(tag(&["a", &coordinate])?); + } + } + + Ok(EventBuilder::new(Kind::Custom(KIND_PROJECT_ANNOUNCEMENT as u16), description).tags(tags)) +} + /// Metadata for a git patch event (kind:1617, NIP-34). #[derive(Default)] pub struct GitPatchMeta { @@ -2919,6 +2998,114 @@ mod tests { assert_eq!(vals[1], "ssh://git@github.com/org/multi-clone.git"); } + #[test] + fn project_announcement_groups_repositories_and_marks_primary() { + let channel_id = uuid(); + let primary = GitRepoCoord { + owner: "a".repeat(64), + id: "frontend".to_string(), + }; + let secondary = GitRepoCoord { + owner: "b".repeat(64), + id: "backend".to_string(), + }; + let repositories = [ + ProjectRepositoryRef { + repository: &primary, + primary: true, + }, + ProjectRepositoryRef { + repository: &secondary, + primary: false, + }, + ]; + + let event = sign( + build_project_announcement( + "sprout", + "Sprout", + Some("The Sprout product"), + &repositories, + Some(channel_id), + ) + .unwrap(), + ); + + assert_eq!(event.kind.as_u16(), KIND_PROJECT_ANNOUNCEMENT as u16); + assert_eq!(event.content, "The Sprout product"); + assert!(has_tag(&event, "d", "sprout")); + assert!(has_tag(&event, "name", "Sprout")); + assert!(has_tag(&event, "h", &channel_id.to_string())); + + let repository_tags = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("a")) + .map(|tag| tag.as_slice()) + .collect::>(); + assert_eq!(repository_tags.len(), 2); + assert_eq!( + repository_tags[0], + [ + "a", + &format!("30617:{}:frontend", "a".repeat(64)), + "", + "primary" + ] + ); + assert_eq!( + repository_tags[1], + ["a", &format!("30617:{}:backend", "b".repeat(64))] + ); + } + + #[test] + fn project_announcement_allows_empty_repository_set() { + let event = sign(build_project_announcement("sprout", "Sprout", None, &[], None).unwrap()); + + assert_eq!(event.kind.as_u16(), KIND_PROJECT_ANNOUNCEMENT as u16); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("a"))); + } + + #[test] + fn project_announcement_requires_exactly_one_primary_when_non_empty() { + let primary = GitRepoCoord { + owner: "a".repeat(64), + id: "frontend".to_string(), + }; + let secondary = GitRepoCoord { + owner: "b".repeat(64), + id: "backend".to_string(), + }; + + let no_primary = [ProjectRepositoryRef { + repository: &primary, + primary: false, + }]; + assert!(matches!( + build_project_announcement("sprout", "Sprout", None, &no_primary, None), + Err(SdkError::InvalidInput(_)) + )); + + let duplicate_primary = [ + ProjectRepositoryRef { + repository: &primary, + primary: true, + }, + ProjectRepositoryRef { + repository: &secondary, + primary: true, + }, + ]; + assert!(matches!( + build_project_announcement("sprout", "Sprout", None, &duplicate_primary, None), + Err(SdkError::InvalidInput(_)) + )); + } + #[test] fn git_patch_happy_path_minimal() { let owner = "a".repeat(64); diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs new file mode 100644 index 0000000000..8c43d481ad --- /dev/null +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProjectReadModels, eventToRepository } from "./projectModels.ts"; + +const PROJECT_OWNER = "a".repeat(64); +const FRONTEND_OWNER = "b".repeat(64); +const BACKEND_OWNER = "c".repeat(64); +const RELAY_ORIGIN = "https://relay.example"; + +function repositoryEvent(owner, id, createdAt = 100) { + return { + id: `${id}-${createdAt}`, + kind: 30617, + pubkey: owner, + created_at: createdAt, + content: "", + tags: [ + ["d", id], + ["name", id], + ], + }; +} + +function projectEvent(repositoryTags, overrides = {}) { + return { + id: "project-event", + kind: 30621, + pubkey: PROJECT_OWNER, + created_at: 200, + content: "A multi-repository project", + tags: [ + ["d", "sprout"], + ["name", "Sprout"], + ["h", "11111111-1111-4111-8111-111111111111"], + ...repositoryTags, + ], + ...overrides, + }; +} + +test("eventToRepository preserves repository-scoped identity and clone data", () => { + const repository = eventToRepository( + repositoryEvent(FRONTEND_OWNER, "frontend"), + RELAY_ORIGIN, + ); + + assert.equal(repository.id, `${FRONTEND_OWNER}:frontend`); + assert.equal(repository.repoAddress, `30617:${FRONTEND_OWNER}:frontend`); + assert.deepEqual(repository.cloneUrls, [ + `${RELAY_ORIGIN}/git/${FRONTEND_OWNER}/frontend`, + ]); +}); + +test("buildProjectReadModels resolves ordered repositories and primary", () => { + const frontendAddress = `30617:${FRONTEND_OWNER}:frontend`; + const backendAddress = `30617:${BACKEND_OWNER}:backend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress, "", "primary"], + ["a", backendAddress], + ]), + ], + repositoryEvents: [ + repositoryEvent(FRONTEND_OWNER, "frontend"), + repositoryEvent(BACKEND_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0].id, `${PROJECT_OWNER}:sprout`); + assert.equal(projects[0].projectAddress, `30621:${PROJECT_OWNER}:sprout`); + assert.equal(projects[0].primaryRepositoryAddress, frontendAddress); + assert.deepEqual( + projects[0].repositories.map((repository) => repository.repoAddress), + [frontendAddress, backendAddress], + ); +}); + +test("buildProjectReadModels keeps ungrouped repositories as legacy projects", () => { + const frontendAddress = `30617:${FRONTEND_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [projectEvent([["a", frontendAddress, "", "primary"]])], + repositoryEvents: [ + repositoryEvent(FRONTEND_OWNER, "frontend"), + repositoryEvent(BACKEND_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 2); + assert.equal(projects[0].legacy, false); + assert.equal(projects[1].legacy, true); + assert.equal( + projects[1].primaryRepositoryAddress, + projects[1].projectAddress, + ); + assert.equal(projects[1].repositories[0].dtag, "backend"); +}); + +test("buildProjectReadModels ignores malformed primary membership", () => { + const frontendAddress = `30617:${FRONTEND_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress], + [`a`, `30617:${BACKEND_OWNER}:backend`], + ]), + ], + repositoryEvents: [repositoryEvent(FRONTEND_OWNER, "frontend")], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal(projects.length, 1); + assert.equal(projects[0].legacy, true); + assert.equal(projects[0].repositories[0].dtag, "frontend"); +}); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts new file mode 100644 index 0000000000..58dbffe455 --- /dev/null +++ b/desktop/src/features/projects/projectModels.ts @@ -0,0 +1,266 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { effectiveCloneUrls } from "./lib/projectCloneUrl"; + +export type Repository = { + id: string; + dtag: string; + name: string; + description: string; + cloneUrls: string[]; + webUrl: string | null; + owner: string; + contributors: string[]; + createdAt: number; + status: string; + defaultBranch: string; + repoAddress: string; +}; + +export type Project = { + id: string; + dtag: string; + name: string; + description: string; + owner: string; + createdAt: number; + projectChannelId: string | null; + status: string; + projectAddress: string; + primaryRepositoryAddress: string | null; + repositoryAddresses: string[]; + repositories: Repository[]; + legacy: boolean; +}; + +type BuildProjectReadModelsInput = { + projectEvents: RelayEvent[]; + repositoryEvents: RelayEvent[]; + relayOrigin?: string | null; +}; + +function getTag(event: RelayEvent, name: string): string | undefined { + const value = event.tags.find((tag) => tag[0] === name)?.[1]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function getAllTags(event: RelayEvent, name: string): string[] { + return event.tags + .filter( + (tag) => + tag[0] === name && typeof tag[1] === "string" && tag[1].length > 0, + ) + .map((tag) => tag[1]); +} + +function getCloneUrls(event: RelayEvent): string[] { + const tag = event.tags.find((candidate) => candidate[0] === "clone"); + return tag?.slice(1).filter((value) => value.length > 0) ?? []; +} + +function isValidIdentifier(value: string): boolean { + return ( + value.length > 0 && + value.length <= 64 && + !value.startsWith(".") && + !value.includes("..") && + /^[a-zA-Z0-9._-]+$/.test(value) + ); +} + +function isValidPubkey(value: string): boolean { + return /^[a-fA-F0-9]{64}$/.test(value); +} + +function deduplicateAddressableEvents(events: RelayEvent[]): RelayEvent[] { + const latest = new Map(); + for (const event of events) { + const dtag = getTag(event, "d"); + if (!dtag) continue; + const key = `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`; + const current = latest.get(key); + if ( + !current || + event.created_at > current.created_at || + (event.created_at === current.created_at && event.id < current.id) + ) { + latest.set(key, event); + } + } + return [...latest.values()]; +} + +function parseRepositoryAddress( + value: string, +): { owner: string; dtag: string } | null { + const firstSeparator = value.indexOf(":"); + const secondSeparator = value.indexOf(":", firstSeparator + 1); + if ( + value.slice(0, firstSeparator) !== String(KIND_REPO_ANNOUNCEMENT) || + secondSeparator < 0 + ) { + return null; + } + + const owner = value.slice(firstSeparator + 1, secondSeparator); + const dtag = value.slice(secondSeparator + 1); + return isValidPubkey(owner) && isValidIdentifier(dtag) + ? { owner: owner.toLowerCase(), dtag } + : null; +} + +export function eventToRepository( + event: RelayEvent, + relayOrigin?: string | null, +): Repository | null { + const dtag = getTag(event, "d"); + if ( + event.kind !== KIND_REPO_ANNOUNCEMENT || + !dtag || + !isValidIdentifier(dtag) || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + const owner = event.pubkey.toLowerCase(); + const setupUsers = getAllTags(event, "auth"); + return { + id: `${owner}:${dtag}`, + dtag, + name: getTag(event, "name") ?? dtag, + description: getTag(event, "description") ?? event.content ?? "", + cloneUrls: effectiveCloneUrls( + getCloneUrls(event), + relayOrigin, + owner, + dtag, + ), + webUrl: getTag(event, "web") ?? null, + owner, + contributors: [...new Set([...getAllTags(event, "p"), ...setupUsers])], + createdAt: event.created_at, + status: getTag(event, "status") ?? "active", + defaultBranch: getTag(event, "default-branch") ?? "main", + repoAddress: `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`, + }; +} + +function eventToExplicitProject( + event: RelayEvent, + repositoriesByAddress: ReadonlyMap, +): Project | null { + const dtag = getTag(event, "d"); + const name = getTag(event, "name"); + if ( + event.kind !== KIND_PROJECT_ANNOUNCEMENT || + !dtag || + !name || + !isValidIdentifier(dtag) || + !isValidPubkey(event.pubkey) + ) { + return null; + } + + const membershipTags = event.tags.filter((tag) => tag[0] === "a"); + const repositoryAddresses: string[] = []; + const seen = new Set(); + let primaryRepositoryAddress: string | null = null; + for (const membershipTag of membershipTags) { + const repositoryAddress = membershipTag[1]; + if ( + !repositoryAddress || + !parseRepositoryAddress(repositoryAddress) || + seen.has(repositoryAddress) + ) { + return null; + } + seen.add(repositoryAddress); + repositoryAddresses.push(repositoryAddress); + if (membershipTag[3] === "primary") { + if (primaryRepositoryAddress) return null; + primaryRepositoryAddress = repositoryAddress; + } + } + + if ( + (repositoryAddresses.length > 0 && !primaryRepositoryAddress) || + (repositoryAddresses.length === 0 && primaryRepositoryAddress) + ) { + return null; + } + + const owner = event.pubkey.toLowerCase(); + return { + id: `${owner}:${dtag}`, + dtag, + name, + description: event.content ?? "", + owner, + createdAt: event.created_at, + projectChannelId: getTag(event, "h") ?? null, + status: getTag(event, "status") ?? "active", + projectAddress: `${KIND_PROJECT_ANNOUNCEMENT}:${owner}:${dtag}`, + primaryRepositoryAddress, + repositoryAddresses, + repositories: repositoryAddresses.flatMap((address) => { + const repository = repositoriesByAddress.get(address); + return repository ? [repository] : []; + }), + legacy: false, + }; +} + +function repositoryToLegacyProject(repository: Repository): Project { + return { + id: repository.id, + dtag: repository.dtag, + name: repository.name, + description: repository.description, + owner: repository.owner, + createdAt: repository.createdAt, + projectChannelId: null, + status: repository.status, + projectAddress: repository.repoAddress, + primaryRepositoryAddress: repository.repoAddress, + repositoryAddresses: [repository.repoAddress], + repositories: [repository], + legacy: true, + }; +} + +export function buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin, +}: BuildProjectReadModelsInput): Project[] { + const repositories = deduplicateAddressableEvents(repositoryEvents).flatMap( + (event) => { + const repository = eventToRepository(event, relayOrigin); + return repository ? [repository] : []; + }, + ); + const repositoriesByAddress = new Map( + repositories.map((repository) => [repository.repoAddress, repository]), + ); + + const explicitProjects = deduplicateAddressableEvents(projectEvents).flatMap( + (event) => { + const project = eventToExplicitProject(event, repositoriesByAddress); + return project ? [project] : []; + }, + ); + const referencedRepositories = new Set( + explicitProjects.flatMap((project) => project.repositoryAddresses), + ); + const legacyProjects = repositories + .filter((repository) => !referencedRepositories.has(repository.repoAddress)) + .map(repositoryToLegacyProject); + + return [...explicitProjects, ...legacyProjects].sort( + (left, right) => right.createdAt - left.createdAt, + ); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c5..058b68cd4b 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -58,6 +58,8 @@ export const KIND_AGENT_TURN_METRIC = 44200; export const KIND_EVENT_REMINDER = 30300; export const KIND_REPO_ANNOUNCEMENT = 30617; export const KIND_REPO_STATE = 30618; +// NIP-BP: owner-authored project grouping above NIP-34 repositories. +export const KIND_PROJECT_ANNOUNCEMENT = 30621; export const KIND_GIT_PATCH = 1617; export const KIND_GIT_PULL_REQUEST = 1618; export const KIND_GIT_PR_UPDATE = 1619; diff --git a/docs/nips/NIP-BP.md b/docs/nips/NIP-BP.md new file mode 100644 index 0000000000..e2209a9550 --- /dev/null +++ b/docs/nips/NIP-BP.md @@ -0,0 +1,88 @@ +NIP-BP +====== + +Buzz Projects +------------- + +`draft` `optional` + +This NIP defines `kind:30621` project announcements. A project is an +owner-authored grouping above one or more NIP-34 repositories. It does not +change repository identity, Git transport, repository permissions, issues, or +pull requests. + +## Kind and address + +Kind `30621` is addressable under NIP-01. Its coordinate is: + +``` +30621:: +``` + +The plaintext `d` tag is the stable project identifier. It follows the same +identifier rules as Buzz repository IDs: `[a-zA-Z0-9._-]{1,64}`, with no +leading dot and no `..`. + +## Event envelope + +```jsonc +{ + "kind": 30621, + "pubkey": "", + "tags": [ + ["d", "sprout"], + ["name", "Sprout"], + ["h", ""], + ["a", "30617::frontend", "", "primary"], + ["a", "30617::backend"] + ], + "content": "Project description" +} +``` + +Writers MUST emit exactly one `d` tag and one non-empty `name` tag. The +description is plaintext `content` and MUST NOT exceed 1,024 bytes. + +Each repository member is a full NIP-34 repository coordinate in an `a` tag. +Repositories MAY have different owners. Duplicate coordinates are invalid. +A non-empty project MUST mark exactly one repository with the `primary` marker +in position four. An empty project has no repository tags and no primary +repository. + +The optional `h` tag associates a NIP-29 channel with the project. It is an +association for clients, not an authorization grant. + +## Replacement and deletion + +Publishing a later event with the same `(pubkey, kind:30621, d_tag)` replaces +the previous project definition under NIP-01 addressable-event semantics. +Adding, removing, reordering, or changing the primary repository therefore +requires one project update; repository announcements are unchanged. + +Owners MAY delete a project with a NIP-09 deletion request that references the +project coordinate using an `a` tag. Deleting a project MUST NOT delete any +member repository. + +## Authorization and security + +Project membership is presentation metadata. It MUST NOT grant repository +read, push, administration, issue, or pull-request permissions. Those remain +controlled by each referenced NIP-34 repository and its relay policy. + +Readers MUST NOT infer project ownership from a channel or from repository +authors. The signer of the kind `30621` event is the project owner and the +authoritative source of membership. + +References to missing, malformed, or inaccessible repositories MUST be ignored +without making the whole project unreadable. Clients SHOULD visibly distinguish +unavailable members from valid members when enough metadata is available. + +Project events are public plaintext and MUST NOT contain credentials, private +clone URLs with embedded tokens, or other secrets. + +## Backward compatibility + +Clients that do not understand kind `30621` continue to see standard NIP-34 +repositories. Supporting clients MAY present an unreferenced kind `30617` +repository as an implicit single-repository project, preserving existing +repository links and data without migration. From efd2b36d8dd58295215ee7678da5f49b582e8389 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 24 Jul 2026 11:24:51 +0200 Subject: [PATCH 02/19] feat(projects): enable multi-repository project navigation Load grouped project announcements throughout the desktop experience and keep Git operations scoped to the selected repository. Preserve legacy single-repository projects while adding shareable repository selection and end-to-end coverage. --- .../src/app/navigation/useAppNavigation.ts | 4 + .../src/app/routes/projects.$projectId.tsx | 6 +- .../src/features/projects/branchMutations.ts | 2 +- desktop/src/features/projects/hooks.ts | 287 ++++++++---------- .../src/features/projects/issueMutations.ts | 2 +- .../projects/lib/projectLocalRepos.ts | 10 +- .../projects/lib/projectsViewHelpers.ts | 20 +- .../features/projects/projectActivity.d.mts | 4 +- .../features/projects/projectModels.test.mjs | 33 +- .../src/features/projects/projectModels.ts | 23 ++ .../src/features/projects/projectWorkItems.ts | 61 ++-- .../features/projects/pullRequestMutations.ts | 2 +- .../features/projects/pullRequestReviews.ts | 2 +- .../src/features/projects/repoSyncHooks.ts | 5 +- .../projects/ui/CreateProjectIssueDialog.tsx | 57 ++-- .../projects/ui/CreatePullRequestDialog.tsx | 81 +++-- .../projects/ui/MergePullRequestButton.tsx | 5 +- .../src/features/projects/ui/ProjectCards.tsx | 10 + .../projects/ui/ProjectDetailChrome.tsx | 123 ++++++++ .../projects/ui/ProjectDetailScreen.tsx | 270 ++++++++-------- .../projects/ui/ProjectIssuesPanel.tsx | 2 +- .../projects/ui/ProjectOverviewPanel.tsx | 2 +- .../ProjectPullRequestFilesChangedPanel.tsx | 2 +- .../projects/ui/ProjectPullRequestsPanel.tsx | 2 +- .../projects/ui/ProjectRepositoryPicker.tsx | 55 ++++ .../projects/ui/ProjectWorkspaceTabs.tsx | 9 +- .../projects/ui/ProjectsActivityFeed.tsx | 46 ++- .../projects/ui/ProjectsAgentPromptPage.tsx | 15 +- .../projects/ui/ProjectsIssuesList.tsx | 23 +- .../projects/ui/ProjectsOverviewPanel.tsx | 7 +- .../projects/ui/ProjectsOverviewRail.tsx | 9 +- .../projects/ui/ProjectsPullRequestsList.tsx | 23 +- .../src/features/projects/ui/ProjectsView.tsx | 62 ++-- .../projects/ui/PullRequestReviewCard.tsx | 5 +- .../projects/ui/PullRequestReviewersRow.tsx | 5 +- .../projects/ui/projectDetailHelpers.ts | 5 +- .../projects/ui/useOpenProjectTerminal.ts | 4 +- .../src/features/projects/useCreateProject.ts | 12 +- .../features/projects/useProjectCommitDiff.ts | 2 +- .../projects/useProjectsRepoSnapshots.ts | 17 +- desktop/src/testing/e2eBridge.ts | 21 ++ .../tests/e2e/project-commit-detail.spec.ts | 35 ++- 42 files changed, 905 insertions(+), 465 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectDetailChrome.tsx create mode 100644 desktop/src/features/projects/ui/ProjectRepositoryPicker.tsx diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f928970610..e91e4beb07 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -97,6 +97,7 @@ export function useAppNavigation() { commitHash?: string; pullRequestId?: string; issueId?: string; + repositoryId?: string; }, ) => commitNavigation( @@ -113,6 +114,9 @@ export function useAppNavigation() { ? { pullRequestId: behavior.pullRequestId } : {}), ...(behavior?.issueId ? { issueId: behavior.issueId } : {}), + ...(behavior?.repositoryId + ? { repositoryId: behavior.repositoryId } + : {}), }, }, behavior, diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 3ce58efa8c..4954428748 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -19,13 +19,16 @@ export const Route = createFileRoute("/projects/$projectId")({ ? search.pullRequestId : undefined, issueId: typeof search.issueId === "string" ? search.issueId : undefined, + repositoryId: + typeof search.repositoryId === "string" ? search.repositoryId : undefined, }), }); function ProjectDetailRouteComponent() { usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); - const { commitHash, pullRequestId, issueId } = Route.useSearch(); + const { commitHash, pullRequestId, issueId, repositoryId } = + Route.useSearch(); return ( }> @@ -34,6 +37,7 @@ function ProjectDetailRouteComponent() { issueId={issueId} projectId={projectId} pullRequestId={pullRequestId} + repositoryId={repositoryId} /> ); diff --git a/desktop/src/features/projects/branchMutations.ts b/desktop/src/features/projects/branchMutations.ts index 55874dc776..647e6fac2b 100644 --- a/desktop/src/features/projects/branchMutations.ts +++ b/desktop/src/features/projects/branchMutations.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; -import type { Project } from "@/features/projects/hooks"; +import type { Repository as Project } from "@/features/projects/hooks"; import { createProjectRemoteBranch, deleteProjectRemoteBranch, diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index a1761f0b11..23bb407d96 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -23,6 +23,7 @@ import { KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_TEXT_NOTE, @@ -39,8 +40,6 @@ import type { RelayEvent, } from "@/shared/api/types"; import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; -import { resolveProjectDefaultBranch } from "./lib/projectBranches"; -import { effectiveCloneUrls } from "./lib/projectCloneUrl"; import type { ProjectIssue } from "./projectIssues.mjs"; import { projectIssueEventsToIssues } from "./projectIssues.mjs"; import type { @@ -53,31 +52,23 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; import { fetchProjectsWorkItems } from "./projectWorkItems"; +import { + buildProjectReadModels, + eventToRepository, + type Project, + type Repository, +} from "./projectModels"; export type { + Project, ProjectIssue, ProjectPullRequest, ProjectPullRequestCommentAnchor, + Repository, }; const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; -export type Project = { - id: string; - dtag: string; - name: string; - description: string; - cloneUrls: string[]; - webUrl: string | null; - owner: string; - contributors: string[]; - createdAt: number; - projectChannelId: string | null; - status: string; - defaultBranch: string; - repoAddress: string; -}; - export type RepoState = { branches: Array<{ name: string; commit: string }>; tags: Array<{ name: string; commit: string }>; @@ -116,34 +107,16 @@ export type { export type ProjectPullRequestListItem = { project: Project; + repository: Repository; pullRequest: ProjectPullRequest; }; export type ProjectIssueListItem = { project: Project; + repository: Repository; issue: ProjectIssue; }; -function getTag(event: RelayEvent, name: string): string | undefined { - const value = event.tags.find((t) => t[0] === name)?.[1]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function getAllTags(event: RelayEvent, name: string): string[] { - return event.tags - .filter((t) => t[0] === name && typeof t[1] === "string" && t[1].length > 0) - .map((t) => t[1]); -} - -function getCloneUrls(event: RelayEvent): string[] { - const tag = event.tags.find((t) => t[0] === "clone"); - return tag ? tag.slice(1) : []; -} - -function projectCoordinate(project: Pick): string { - return `${KIND_REPO_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; -} - function readHiddenProjectCards(): string[] { if (typeof window === "undefined") { return []; @@ -162,17 +135,18 @@ function readHiddenProjectCards(): string[] { } function isHiddenLocally(project: Project): boolean { - return readHiddenProjectCards().includes(projectCoordinate(project)); + return readHiddenProjectCards().includes(project.projectAddress); } function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { - const coordinate = projectCoordinate(project); // NIP-09: a deletion is only valid when signed by the author of the // referenced event — otherwise anyone could hide someone else's project. return deletionEvents.some( (event) => event.pubkey.toLowerCase() === project.owner.toLowerCase() && - event.tags.some((tag) => tag[0] === "a" && tag[1] === coordinate), + event.tags.some( + (tag) => tag[0] === "a" && tag[1] === project.projectAddress, + ), ); } @@ -187,63 +161,20 @@ function isDeletedByA(project: Project, deletionEvents: RelayEvent[]): boolean { export function eventToProject( event: RelayEvent, relayOrigin?: string | null, -): Project { - const d = getTag(event, "d") ?? event.id; - const name = getTag(event, "name") || d; - const description = getTag(event, "description") || event.content || ""; - const cloneUrls = effectiveCloneUrls( - getCloneUrls(event), - relayOrigin, - event.pubkey, - d, - ); - const webUrl = getTag(event, "web") ?? null; - const setupUsers = getAllTags(event, "auth"); - const contributors = [...new Set([...getAllTags(event, "p"), ...setupUsers])]; - // `h`/`project-channel`, `status`, and `default-branch` are NOT part of - // NIP-34 — they are read-side tolerance for extension tags no code writes - // today (the write path that emitted them was removed). If a write path is - // reintroduced it must go through the buzz-sdk repo-announcement builder; - // the canonical NIP-34 source for the default branch is the kind:30618 - // state event's HEAD ref, not a 30617 tag. - const projectChannelId = - getTag(event, "h") ?? getTag(event, "project-channel") ?? null; - - return { - id: `${event.pubkey}:${d}`, - dtag: d, - name, - description, - cloneUrls, - webUrl, - owner: event.pubkey, - contributors, - createdAt: event.created_at, - projectChannelId, - status: getTag(event, "status") ?? "active", - defaultBranch: getTag(event, "default-branch") ?? "main", - repoAddress: projectCoordinate({ owner: event.pubkey, dtag: d }), - }; -} - -function dedup(events: RelayEvent[]): RelayEvent[] { - const best = new Map(); - - for (const e of events) { - const d = getTag(e, "d") ?? ""; - const key = `${e.pubkey}:${e.kind}:${d}`; - const prev = best.get(key); - - if (!prev || e.created_at > prev.created_at) { - best.set(key, e); - } +): Repository { + const repository = eventToRepository(event, relayOrigin); + if (!repository) { + throw new Error("Invalid repository announcement."); } - - return [...best.values()]; + return repository; } export async function fetchProjects(): Promise { - const [events, deletionEvents] = await Promise.all([ + const [projectEvents, repositoryEvents, deletionEvents] = await Promise.all([ + relayClient.fetchEvents({ + kinds: [KIND_PROJECT_ANNOUNCEMENT], + limit: 200, + }), relayClient.fetchEvents({ kinds: [KIND_REPO_ANNOUNCEMENT], limit: 200, @@ -254,8 +185,11 @@ export async function fetchProjects(): Promise { }), ]); - return dedup(events) - .map((event) => eventToProject(event, getCachedRelayOrigin())) + return buildProjectReadModels({ + projectEvents, + repositoryEvents, + relayOrigin: getCachedRelayOrigin(), + }) .filter( (project) => !isHiddenLocally(project) && !isDeletedByA(project, deletionEvents), @@ -283,40 +217,13 @@ function parseProjectRouteId(projectId: string): { async function fetchProject(projectId: string): Promise { const { owner, dtag } = parseProjectRouteId(projectId); - const events = await relayClient.fetchEvents({ - kinds: [KIND_REPO_ANNOUNCEMENT], - ...(owner ? { authors: [owner] } : {}), - "#d": [dtag], - limit: 10, - }); - - const deduped = dedup(events).filter( - (event) => !owner || event.pubkey.toLowerCase() === owner, + return ( + (await fetchProjects()).find( + (project) => + project.dtag === dtag && + (!owner || project.owner.toLowerCase() === owner), + ) ?? null ); - const project = - deduped.length > 0 - ? eventToProject(deduped[0], getCachedRelayOrigin()) - : null; - if (!project) { - return null; - } - - const deletionEvents = await relayClient.fetchEvents({ - kinds: [KIND_DELETION], - authors: [project.owner], - "#a": [project.repoAddress], - limit: 10, - }); - - if (isDeletedByA(project, deletionEvents)) return null; - const repoState = await fetchRepoState(project); - return { - ...project, - defaultBranch: resolveProjectDefaultBranch( - project.defaultBranch, - repoState, - ), - }; } function eventToRepoState(event: RelayEvent): RepoState { @@ -345,7 +252,7 @@ function eventToRepoState(event: RelayEvent): RepoState { }; } -async function fetchRepoState(project: Project): Promise { +async function fetchRepoState(project: Repository): Promise { const relaySelf = await getRelaySelf(); const trustedAuthors = [ ...new Set( @@ -364,7 +271,9 @@ async function fetchRepoState(project: Project): Promise { return events.length > 0 ? eventToRepoState(events[0]) : null; } -async function fetchProjectIssues(project: Project): Promise { +async function fetchProjectIssues( + project: Repository, +): Promise { const [issueEvents, statusEvents, commentEvents] = await Promise.all([ relayClient.fetchEvents({ kinds: [KIND_GIT_ISSUE], @@ -392,7 +301,7 @@ async function fetchProjectIssues(project: Project): Promise { } async function fetchProjectPullRequests( - project: Project, + project: Repository, ): Promise { const [pullRequestEvents, updateEvents, commentEvents, statusEvents] = await Promise.all([ @@ -448,7 +357,7 @@ async function createProjectPullRequestComment({ content: string; mediaTags?: string[][]; mentionPubkeys?: string[]; - project: Project; + project: Repository; pullRequest: ProjectPullRequest; }): Promise { const body = content.trim(); @@ -511,7 +420,7 @@ async function createProjectIssueComment({ mediaTags?: string[][]; mentionPubkeys?: string[]; issue: ProjectIssue; - project: Project; + project: Repository; }): Promise { const body = content.trim(); if (!body) { @@ -545,7 +454,7 @@ async function createProjectIssueComment({ } async function fetchProjectRepoSnapshot( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, @@ -567,7 +476,7 @@ async function fetchProjectRepoSnapshot( } async function fetchProjectRepoDiff( - project: Project, + project: Repository, branchName?: string | null, pullRequest?: ProjectPullRequest | null, ): Promise { @@ -584,7 +493,7 @@ async function fetchProjectRepoDiff( } async function fetchProjectLocalRepoDiff( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -605,7 +514,7 @@ async function fetchProjectLocalRepoDiff( } async function fetchProjectLocalRepoSnapshot( - project: Project, + project: Repository, reposDir?: string | null, branchName?: string | null, ): Promise { @@ -623,6 +532,13 @@ async function fetchProjectActivitySummaries( ): Promise> { if (projects.length === 0) return {}; + const repositories = [ + ...new Map( + projects + .flatMap((project) => project.repositories) + .map((repository) => [repository.repoAddress, repository]), + ).values(), + ]; const events = await relayClient.fetchEvents({ kinds: [ KIND_GIT_ISSUE, @@ -634,14 +550,72 @@ async function fetchProjectActivitySummaries( KIND_GIT_PULL_REQUEST, KIND_GIT_PR_UPDATE, ], - "#a": projects.map((project) => project.repoAddress), + "#a": repositories.map((repository) => repository.repoAddress), limit: 1_000, }); - return summarizeProjectActivityEvents(events, projects) as Record< - string, - ProjectActivitySummary - >; + const summariesByRepository = summarizeProjectActivityEvents( + events, + repositories, + ) as Record; + return Object.fromEntries( + projects.map((project) => { + const summaries = project.repositories.map( + (repository) => summariesByRepository[repository.repoAddress], + ); + const latestCommit = + summaries + .map((summary) => summary?.latestCommit) + .filter( + ( + commit, + ): commit is NonNullable => + Boolean(commit), + ) + .sort((left, right) => right.createdAt - left.createdAt)[0] ?? null; + const activityByDay: Record = {}; + for (const summary of summaries) { + for (const [day, count] of Object.entries( + summary?.activityByDay ?? {}, + )) { + activityByDay[day] = (activityByDay[day] ?? 0) + count; + } + } + return [ + project.id, + { + repoAddress: project.projectAddress, + issueCount: summaries.reduce( + (count, summary) => count + (summary?.issueCount ?? 0), + 0, + ), + prCount: summaries.reduce( + (count, summary) => count + (summary?.prCount ?? 0), + 0, + ), + commitCount: summaries.reduce( + (count, summary) => count + (summary?.commitCount ?? 0), + 0, + ), + activityCount: summaries.reduce( + (count, summary) => count + (summary?.activityCount ?? 0), + 0, + ), + updatedAt: Math.max( + 0, + ...summaries.map((summary) => summary?.updatedAt ?? 0), + ), + participantPubkeys: [ + ...new Set( + summaries.flatMap((summary) => summary?.participantPubkeys ?? []), + ), + ], + latestCommit, + activityByDay, + } satisfies ProjectActivitySummary, + ]; + }), + ); } async function deleteProject(project: Project): Promise { @@ -653,7 +627,7 @@ async function deleteProject(project: Project): Promise { const event = await signRelayEvent({ kind: KIND_DELETION, content: `Delete project ${project.name}`, - tags: [["a", project.repoAddress]], + tags: [["a", project.projectAddress]], }); await relayClient.publishEvent( @@ -681,7 +655,7 @@ export function useProjectQuery(projectId: string) { }); } -export function useRepoStateQuery(project: Project | null | undefined) { +export function useRepoStateQuery(project: Repository | null | undefined) { return useQuery({ enabled: Boolean(project), queryKey: ["project", project?.id ?? "none", "repo-state"], @@ -694,7 +668,7 @@ export function useRepoStateQuery(project: Project | null | undefined) { } export function useProjectRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, branchName?: string | null, pullRequest?: ProjectPullRequest | null, tag?: { name: string; commit: string } | null, @@ -728,7 +702,7 @@ export function useProjectRepoSnapshotQuery( } export function useProjectRepoDiffQuery( - project: Project | null | undefined, + project: Repository | null | undefined, branchName?: string | null, pullRequest?: ProjectPullRequest | null, enabled = true, @@ -755,7 +729,7 @@ export function useProjectRepoDiffQuery( } export function useProjectLocalRepoDiffQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, pullRequest?: ProjectPullRequest | null, @@ -789,7 +763,7 @@ export function useProjectLocalRepoDiffQuery( } export function useProjectLocalRepoSnapshotQuery( - project: Project | null | undefined, + project: Repository | null | undefined, reposDir?: string | null, branchName?: string | null, ) { @@ -822,7 +796,7 @@ export function useProjectLocalRepositoriesQuery(reposDir?: string | null) { }); } -export function useProjectIssuesQuery(project: Project | null | undefined) { +export function useProjectIssuesQuery(project: Repository | null | undefined) { return useQuery({ enabled: Boolean(project), queryKey: ["project", project?.id ?? "none", "issues"], @@ -835,7 +809,7 @@ export function useProjectIssuesQuery(project: Project | null | undefined) { } export function useProjectPullRequestsQuery( - project: Project | null | undefined, + project: Repository | null | undefined, ) { return useQuery({ enabled: Boolean(project), @@ -859,7 +833,7 @@ export function useProjectsWorkItemsQuery(projects: Project[]) { } export function useCreateProjectIssueCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -899,7 +873,7 @@ export function useCreateProjectIssueCommentMutation( } export function useCreateProjectPullRequestCommentMutation( - project: Project | null | undefined, + project: Repository | null | undefined, ) { const queryClient = useQueryClient(); @@ -943,7 +917,12 @@ export function useCreateProjectPullRequestCommentMutation( export function useProjectActivitySummariesQuery(projects: Project[]) { const repoAddresses = React.useMemo( - () => projects.map((project) => project.repoAddress).sort(), + () => + projects + .flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ) + .sort(), [projects], ); diff --git a/desktop/src/features/projects/issueMutations.ts b/desktop/src/features/projects/issueMutations.ts index 0d18e47226..57834f4401 100644 --- a/desktop/src/features/projects/issueMutations.ts +++ b/desktop/src/features/projects/issueMutations.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { relayClient } from "@/shared/api/relayClient"; import { signRelayEvent } from "@/shared/api/tauri"; import { KIND_GIT_ISSUE } from "@/shared/constants/kinds"; -import type { Project } from "./hooks"; +import type { Repository as Project } from "./hooks"; import { buildGitIssueTags } from "./projectIssues.mjs"; type CreateProjectIssueInput = { diff --git a/desktop/src/features/projects/lib/projectLocalRepos.ts b/desktop/src/features/projects/lib/projectLocalRepos.ts index e89e323129..7e92275a7b 100644 --- a/desktop/src/features/projects/lib/projectLocalRepos.ts +++ b/desktop/src/features/projects/lib/projectLocalRepos.ts @@ -1,4 +1,4 @@ -import type { Project } from "@/features/projects/hooks"; +import type { Project, Repository } from "@/features/projects/hooks"; function localRepoNameCandidate(value: string | null | undefined) { const trimmed = value?.trim().replace(/\.git$/i, "") ?? ""; @@ -26,7 +26,7 @@ function cloneUrlRepoName(cloneUrl: string | undefined) { } } -function localRepoCandidates(project: Project) { +function localRepoCandidates(project: Repository) { return [ localRepoNameCandidate(project.dtag), cloneUrlRepoName(project.cloneUrls[0]), @@ -39,7 +39,9 @@ export function hasLocalCheckout( project: Project, localRepoNames: Set, ) { - return localRepoCandidates(project).some((candidate) => - localRepoNames.has(candidate), + return project.repositories.some((repository) => + localRepoCandidates(repository).some((candidate) => + localRepoNames.has(candidate), + ), ); } diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index fefb75b4f8..59e85cbea0 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -2,6 +2,7 @@ import type { Project, ProjectActivitySummary, } from "@/features/projects/hooks"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -235,7 +236,10 @@ export function projectPeople( ...new Set( [ project.owner, - ...project.contributors, + ...project.repositories.flatMap((repository) => [ + repository.owner, + ...repository.contributors, + ]), ...(summary?.participantPubkeys ?? []), ].map(normalizePubkey), ), @@ -260,7 +264,7 @@ export function normalizeRepositoryUrl(url: string) { } export function getClonePathLabel(project: Project) { - const cloneUrl = project.cloneUrls[0]; + const cloneUrl = selectProjectRepository(project, null)?.cloneUrls[0]; if (!cloneUrl) return "Clone path pending"; try { @@ -272,9 +276,7 @@ export function getClonePathLabel(project: Project) { } function repositoryIdentityKey(project: Project) { - const cloneUrl = project.cloneUrls[0]; - if (cloneUrl) return normalizeRepositoryUrl(cloneUrl); - return (project.name || project.dtag).trim().toLowerCase(); + return project.id; } export function uniqueRepositories(projects: Project[]) { @@ -316,8 +318,12 @@ export function isProjectMine( const normalizedCurrentPubkey = normalizePubkey(currentPubkey); return ( normalizePubkey(project.owner) === normalizedCurrentPubkey || - project.contributors.some( - (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + project.repositories.some( + (repository) => + normalizePubkey(repository.owner) === normalizedCurrentPubkey || + repository.contributors.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ), ) ); } diff --git a/desktop/src/features/projects/projectActivity.d.mts b/desktop/src/features/projects/projectActivity.d.mts index 7277e7bf79..ec09ba9095 100644 --- a/desktop/src/features/projects/projectActivity.d.mts +++ b/desktop/src/features/projects/projectActivity.d.mts @@ -1,7 +1,7 @@ -import type { ProjectActivitySummary, Project } from "./hooks"; +import type { ProjectActivitySummary, Repository } from "./hooks"; import type { RelayEvent } from "@/shared/api/types"; export function summarizeProjectActivityEvents( events: RelayEvent[], - projects: Project[], + projects: Repository[], ): Record; diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 8c43d481ad..1309abb557 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildProjectReadModels, eventToRepository } from "./projectModels.ts"; +import { + buildProjectReadModels, + eventToRepository, + selectProjectRepository, +} from "./projectModels.ts"; const PROJECT_OWNER = "a".repeat(64); const FRONTEND_OWNER = "b".repeat(64); @@ -117,3 +121,30 @@ test("buildProjectReadModels ignores malformed primary membership", () => { assert.equal(projects[0].legacy, true); assert.equal(projects[0].repositories[0].dtag, "frontend"); }); + +test("selectProjectRepository honors a request and falls back to primary", () => { + const frontendAddress = `30617:${FRONTEND_OWNER}:frontend`; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["a", frontendAddress, "", "primary"], + ["a", `30617:${BACKEND_OWNER}:backend`], + ]), + ], + repositoryEvents: [ + repositoryEvent(FRONTEND_OWNER, "frontend"), + repositoryEvent(BACKEND_OWNER, "backend"), + ], + relayOrigin: RELAY_ORIGIN, + }); + + assert.equal( + selectProjectRepository(projects[0], `${BACKEND_OWNER}:backend`)?.dtag, + "backend", + ); + assert.equal( + selectProjectRepository(projects[0], "missing:repository")?.dtag, + "frontend", + ); + assert.equal(selectProjectRepository(projects[0], null)?.dtag, "frontend"); +}); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index 58dbffe455..33df53bf77 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -264,3 +264,26 @@ export function buildProjectReadModels({ (left, right) => right.createdAt - left.createdAt, ); } + +export function selectProjectRepository( + project: Project | null | undefined, + requestedRepositoryId: string | null | undefined, +): Repository | null { + if (!project) return null; + + const requested = requestedRepositoryId + ? project.repositories.find( + (repository) => repository.id === requestedRepositoryId, + ) + : null; + if (requested) return requested; + + return ( + project.repositories.find( + (repository) => + repository.repoAddress === project.primaryRepositoryAddress, + ) ?? + project.repositories[0] ?? + null + ); +} diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index a2f0047703..ed4b2ac61c 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -20,10 +20,17 @@ import { projectPullRequestEventsToPullRequests, } from "./projectPullRequests.mjs"; -type ProjectReference = { +type RepositoryReference = { repoAddress: string; }; +type ProjectReference = { + repositories: RepositoryReference[]; +}; + +type ProjectRepository = + TProject["repositories"][number]; + /** Optional event groups that can fail without discarding root work items. */ export type ProjectWorkItemSection = | "comments" @@ -33,11 +40,19 @@ export type ProjectWorkItemSection = /** Aggregate work items plus any optional event groups that failed to load. */ export type ProjectsWorkItemsResult = { issues: { - items: Array<{ project: TProject; issue: ProjectIssue }>; + items: Array<{ + project: TProject; + repository: ProjectRepository; + issue: ProjectIssue; + }>; failedSections: ProjectWorkItemSection[]; }; pullRequests: { - items: Array<{ project: TProject; pullRequest: ProjectPullRequest }>; + items: Array<{ + project: TProject; + repository: ProjectRepository; + pullRequest: ProjectPullRequest; + }>; failedSections: ProjectWorkItemSection[]; }; }; @@ -59,7 +74,11 @@ export async function fetchProjectsWorkItems( projects: TProject[], ): Promise> { const repoAddresses = [ - ...new Set(projects.map((project) => project.repoAddress)), + ...new Set( + projects.flatMap((project) => + project.repositories.map((repository) => repository.repoAddress), + ), + ), ]; const [rootResult, updateResult, commentResult, statusResult] = await Promise.allSettled([ @@ -109,27 +128,31 @@ export async function fetchProjectsWorkItems( const pullRequests = projects .flatMap((project) => - projectPullRequestEventsToPullRequests( - (rootsByRepo.get(project.repoAddress) ?? []).filter( - (event) => event.kind === KIND_GIT_PULL_REQUEST, - ), - updatesByRepo.get(project.repoAddress) ?? [], - commentsByRepo.get(project.repoAddress) ?? [], - statusesByRepo.get(project.repoAddress) ?? [], - ).map((pullRequest) => ({ project, pullRequest })), + project.repositories.flatMap((repository) => + projectPullRequestEventsToPullRequests( + (rootsByRepo.get(repository.repoAddress) ?? []).filter( + (event) => event.kind === KIND_GIT_PULL_REQUEST, + ), + updatesByRepo.get(repository.repoAddress) ?? [], + commentsByRepo.get(repository.repoAddress) ?? [], + statusesByRepo.get(repository.repoAddress) ?? [], + ).map((pullRequest) => ({ project, pullRequest, repository })), + ), ) .sort( (left, right) => right.pullRequest.updatedAt - left.pullRequest.updatedAt, ); const issues = projects .flatMap((project) => - projectIssueEventsToIssues( - (rootsByRepo.get(project.repoAddress) ?? []).filter( - (event) => event.kind === KIND_GIT_ISSUE, - ), - statusesByRepo.get(project.repoAddress) ?? [], - commentsByRepo.get(project.repoAddress) ?? [], - ).map((issue) => ({ project, issue })), + project.repositories.flatMap((repository) => + projectIssueEventsToIssues( + (rootsByRepo.get(repository.repoAddress) ?? []).filter( + (event) => event.kind === KIND_GIT_ISSUE, + ), + statusesByRepo.get(repository.repoAddress) ?? [], + commentsByRepo.get(repository.repoAddress) ?? [], + ).map((issue) => ({ issue, project, repository })), + ), ) .sort((left, right) => right.issue.updatedAt - left.issue.updatedAt); const sharedFailedSections: ProjectWorkItemSection[] = []; diff --git a/desktop/src/features/projects/pullRequestMutations.ts b/desktop/src/features/projects/pullRequestMutations.ts index 4eae6464bf..160c0a3403 100644 --- a/desktop/src/features/projects/pullRequestMutations.ts +++ b/desktop/src/features/projects/pullRequestMutations.ts @@ -12,7 +12,7 @@ import { KIND_GIT_PULL_REQUEST, } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import type { Project, ProjectPullRequest } from "./hooks"; +import type { ProjectPullRequest, Repository as Project } from "./hooks"; import { nextProjectPullRequestStatusCreatedAt } from "./projectPullRequests.mjs"; import { useProjectPullRequestWriteInvalidation } from "./pullRequestReviews"; diff --git a/desktop/src/features/projects/pullRequestReviews.ts b/desktop/src/features/projects/pullRequestReviews.ts index f038d85f13..39f5ccc319 100644 --- a/desktop/src/features/projects/pullRequestReviews.ts +++ b/desktop/src/features/projects/pullRequestReviews.ts @@ -10,7 +10,7 @@ import { KIND_GIT_STATUS_OPEN, KIND_TEXT_NOTE, } from "@/shared/constants/kinds"; -import type { Project } from "./hooks"; +import type { Repository as Project } from "./hooks"; import { nextProjectPullRequestStatusCreatedAt, type ProjectPullRequest, diff --git a/desktop/src/features/projects/repoSyncHooks.ts b/desktop/src/features/projects/repoSyncHooks.ts index 457ccca175..1444eacf6f 100644 --- a/desktop/src/features/projects/repoSyncHooks.ts +++ b/desktop/src/features/projects/repoSyncHooks.ts @@ -6,7 +6,10 @@ import { pullProjectLocalRepository, pushProjectLocalRepository, } from "@/shared/api/projectGit"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; import { publishProjectPullRequestUpdate } from "./pullRequestMutations"; /** Local-vs-remote git sync status for a project checkout (ahead/behind diff --git a/desktop/src/features/projects/ui/CreateProjectIssueDialog.tsx b/desktop/src/features/projects/ui/CreateProjectIssueDialog.tsx index af28693bb4..134dc8cf60 100644 --- a/desktop/src/features/projects/ui/CreateProjectIssueDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectIssueDialog.tsx @@ -1,8 +1,9 @@ import * as React from "react"; import { toast } from "sonner"; -import type { Project } from "@/features/projects/hooks"; +import type { Project, Repository } from "@/features/projects/hooks"; import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { CreateProjectWorkItemDialog, type CreateProjectWorkItemDialogInput, @@ -16,39 +17,56 @@ export function CreateProjectIssueDialog({ projects, }: { initialProjectId?: string; - onCreated: (project: Project, issueId: string) => void | Promise; + onCreated: ( + project: Project, + repository: Repository, + issueId: string, + ) => void | Promise; onOpenChange: (open: boolean) => void; open: boolean; projects: Project[]; }) { + const repositoryOptions = React.useMemo( + () => + projects.flatMap((project) => + project.repositories.map((repository) => ({ project, repository })), + ), + [projects], + ); const initialProject = projects.find((project) => project.id === initialProjectId) ?? projects[0]; - const [projectId, setProjectId] = React.useState(initialProject?.id ?? ""); - const project = - projects.find((candidate) => candidate.id === projectId) ?? initialProject; - const createMutation = useCreateProjectIssueMutation(project); + const [repositoryId, setRepositoryId] = React.useState( + selectProjectRepository(initialProject, null)?.id ?? "", + ); + const selection = + repositoryOptions.find( + (candidate) => candidate.repository.id === repositoryId, + ) ?? repositoryOptions[0]; + const project = selection?.project; + const repository = selection?.repository; + const createMutation = useCreateProjectIssueMutation(repository); React.useEffect(() => { if (!open) return; const nextProject = projects.find((candidate) => candidate.id === initialProjectId) ?? projects[0]; - setProjectId(nextProject?.id ?? ""); + setRepositoryId(selectProjectRepository(nextProject, null)?.id ?? ""); }, [initialProjectId, open, projects]); async function handleCreate(input: CreateProjectWorkItemDialogInput) { - if (!project) throw new Error("Choose a repository."); + if (!project || !repository) throw new Error("Choose a repository."); const issueId = await createMutation.mutateAsync(input); toast.success("Issue created."); - await onCreated(project, issueId); + await onCreated(project, repository, issueId); } return ( @@ -66,12 +84,17 @@ export function CreateProjectIssueDialog({ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring" data-testid="create-issue-repository" disabled={createMutation.isPending} - onChange={(event) => setProjectId(event.target.value)} - value={project?.id ?? ""} + onChange={(event) => setRepositoryId(event.target.value)} + value={repository?.id ?? ""} > - {projects.map((candidate) => ( - ))} diff --git a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx index d64d132aed..11ba74d8d8 100644 --- a/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx +++ b/desktop/src/features/projects/ui/CreatePullRequestDialog.tsx @@ -3,9 +3,11 @@ import { toast } from "sonner"; import { type Project, + type Repository, useProjectPullRequestsQuery, useRepoStateQuery, } from "@/features/projects/hooks"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { useCreateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations"; import { useProjectRepoSyncStatusQuery } from "@/features/projects/repoSyncHooks"; @@ -25,61 +27,79 @@ export function CreatePullRequestDialog({ reposDir, }: { initialProjectId?: string; - onCreated: (project: Project, pullRequestId: string) => void | Promise; + onCreated: ( + project: Project, + repository: Repository, + pullRequestId: string, + ) => void | Promise; onOpenChange: (open: boolean) => void; open: boolean; projects: Project[]; reposDir?: string | null; }) { + const repositoryOptions = React.useMemo( + () => + projects.flatMap((project) => + project.repositories.map((repository) => ({ project, repository })), + ), + [projects], + ); const initialProject = projects.find((project) => project.id === initialProjectId) ?? projects[0]; - const [projectId, setProjectId] = React.useState(initialProject?.id ?? ""); - const project = - projects.find((candidate) => candidate.id === projectId) ?? initialProject; - const repoStateQuery = useRepoStateQuery(project); - const pullRequestsQuery = useProjectPullRequestsQuery(project); + const initialRepository = selectProjectRepository(initialProject, null); + const [repositoryId, setRepositoryId] = React.useState( + initialRepository?.id ?? "", + ); + const selection = + repositoryOptions.find( + (candidate) => candidate.repository.id === repositoryId, + ) ?? repositoryOptions[0]; + const project = selection?.project; + const repository = selection?.repository; + const repoStateQuery = useRepoStateQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); const initialSyncQuery = useProjectRepoSyncStatusQuery( - project, + repository, reposDir, - project?.defaultBranch, + repository?.defaultBranch, ); const branchOptions = React.useMemo(() => { const names = [ - project?.defaultBranch, + repository?.defaultBranch, ...(repoStateQuery.data?.branches.map((branch) => branch.name) ?? []), initialSyncQuery.data?.localBranch, ].filter((name): name is string => Boolean(name)); return [...new Set(names)]; }, [ initialSyncQuery.data?.localBranch, - project?.defaultBranch, + repository?.defaultBranch, repoStateQuery.data?.branches, ]); const [targetBranch, setTargetBranch] = React.useState( - project?.defaultBranch ?? "", + repository?.defaultBranch ?? "", ); const [sourceBranch, setSourceBranch] = React.useState(""); const sourceSyncQuery = useProjectRepoSyncStatusQuery( - project, + repository, reposDir, sourceBranch || null, targetBranch || null, ); - const createMutation = useCreateProjectPullRequestMutation(project); + const createMutation = useCreateProjectPullRequestMutation(repository); React.useEffect(() => { if (!open) return; const nextProject = projects.find((candidate) => candidate.id === initialProjectId) ?? projects[0]; - setProjectId(nextProject?.id ?? ""); + setRepositoryId(selectProjectRepository(nextProject, null)?.id ?? ""); }, [initialProjectId, open, projects]); React.useEffect(() => { - if (!project) return; - setTargetBranch(project.defaultBranch); + if (!repository) return; + setTargetBranch(repository.defaultBranch); setSourceBranch(""); - }, [project]); + }, [repository]); React.useEffect(() => { if ( @@ -104,9 +124,9 @@ export function CreatePullRequestDialog({ (pullRequest) => (pullRequest.status === "Open" || pullRequest.status === "Draft") && pullRequest.branchName === sourceBranch && - (pullRequest.targetBranch ?? project?.defaultBranch) === targetBranch, + (pullRequest.targetBranch ?? repository?.defaultBranch) === targetBranch, ); - const selectionError = !project + const selectionError = !repository ? "Choose a repository." : !targetBranch ? "Choose a base branch." @@ -120,12 +140,12 @@ export function CreatePullRequestDialog({ ? "The compare branch must be pushed before opening a pull request." : null; const description = - project && sourceBranch && targetBranch - ? `${project.name}: ${sourceBranch} → ${targetBranch}${sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}` + repository && sourceBranch && targetBranch + ? `${repository.name}: ${sourceBranch} → ${targetBranch}${sourceCommit ? ` at ${sourceCommit.slice(0, 7)}` : ""}` : "Choose a repository and branches to compare."; async function handleCreate(input: CreatePullRequestDialogInput) { - if (!project || !sourceCommit || selectionError) { + if (!project || !repository || !sourceCommit || selectionError) { throw new Error( selectionError ?? "Pull request branches are incomplete.", ); @@ -139,7 +159,7 @@ export function CreatePullRequestDialog({ reviewers: [], }); toast.success("Pull request created."); - await onCreated(project, pullRequestId); + await onCreated(project, repository, pullRequestId); } return ( @@ -165,12 +185,17 @@ export function CreatePullRequestDialog({ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm font-normal outline-hidden focus:ring-1 focus:ring-ring" data-testid="create-pull-request-repository" disabled={createMutation.isPending} - onChange={(event) => setProjectId(event.target.value)} - value={project?.id ?? ""} + onChange={(event) => setRepositoryId(event.target.value)} + value={repository?.id ?? ""} > - {projects.map((candidate) => ( - ))} diff --git a/desktop/src/features/projects/ui/MergePullRequestButton.tsx b/desktop/src/features/projects/ui/MergePullRequestButton.tsx index 133c41d043..7b5e67adec 100644 --- a/desktop/src/features/projects/ui/MergePullRequestButton.tsx +++ b/desktop/src/features/projects/ui/MergePullRequestButton.tsx @@ -2,7 +2,10 @@ import { AlertTriangle, Copy, GitMerge, SquareTerminal } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; import { projectPullRequestConflictCommands } from "@/features/projects/projectPullRequestConflictRecovery"; import { useMergeProjectPullRequestMutation, diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 52308f7a05..74042df71c 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -434,6 +434,11 @@ export function ProjectGridCard({ {project.name} + {project.repositories.length > 1 ? ( + + {project.repositories.length} repos + + ) : null}
@@ -506,6 +511,11 @@ export function ProjectListRow({ {project.name} + {project.repositories.length > 1 ? ( + + {project.repositories.length} repos + + ) : null}

diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx new file mode 100644 index 0000000000..ddd0106d9b --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -0,0 +1,123 @@ +import { ChevronRight, FolderGit2, MessageSquare } from "lucide-react"; +import type * as React from "react"; + +import type { Project } from "@/features/projects/hooks"; +import { channelChrome, topChromeInset } from "@/shared/layout/chromeLayout"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +export type ProjectDetailWorkItemCrumb = { + category: string; + title: string; + clear: () => void; +}; + +export function ProjectDetailChrome({ + activeTabCrumb, + activeWorkItemCrumb, + chromeRef, + onGoChannel, + onGoProjectHome, + onGoProjects, + project, +}: { + activeTabCrumb: string | null; + activeWorkItemCrumb: ProjectDetailWorkItemCrumb | null; + chromeRef: React.Ref; + onGoChannel: (channelId: string) => void; + onGoProjectHome: () => void; + onGoProjects: () => void; + project: Project; +}) { + return ( +

+
+ + {project.projectChannelId ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 587dd0d11b..fef3240f77 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -1,10 +1,4 @@ -import { - ArrowLeft, - ChevronRight, - ExternalLink, - FolderGit2, - MessageSquare, -} from "lucide-react"; +import { ArrowLeft, ExternalLink, FolderGit2 } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -12,6 +6,7 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { type Project, + type Repository, useProjectQuery, useProjectIssuesQuery, useProjectLocalRepoDiffQuery, @@ -47,13 +42,8 @@ import { import { useIdentityQuery } from "@/shared/api/hooks"; import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; -import { - channelChrome, - channelContentTopPaddingMeasurement, - topChromeInset, -} from "@/shared/layout/chromeLayout"; +import { channelContentTopPaddingMeasurement } 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"; @@ -70,7 +60,9 @@ import { resolveProjectDefaultBranch, } from "@/features/projects/lib/projectBranches"; import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; +import { ProjectRepositoryPicker } from "./ProjectRepositoryPicker"; import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; import { projectTerminalLabel, @@ -78,6 +70,7 @@ import { } from "./useOpenProjectTerminal"; import type { CreateIssueDialogInput } from "./CreateIssueDialog"; import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs"; +import { ProjectDetailChrome } from "./ProjectDetailChrome"; import { PROJECT_TAB_CRUMB_LABELS, projectPeople, @@ -90,6 +83,7 @@ type ProjectDetailScreenProps = { projectId: string; pullRequestId?: string; issueId?: string; + repositoryId?: string; }; const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ @@ -97,9 +91,15 @@ const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ "profileTab", "profileView", ] as const; +const PROJECT_REPOSITORY_SEARCH_KEYS = [ + "repositoryId", + "issueId", + "pullRequestId", + "commitHash", +] as const; export function ProjectDetailScreen(props: ProjectDetailScreenProps) { - const { commitHash, projectId, pullRequestId, issueId } = props; + const { commitHash, projectId, pullRequestId, issueId, repositoryId } = props; const { goChannel, goProject, goProjects } = useAppNavigation(); const { activeCommunity } = useCommunities(); const mainInsetRef = useMainInsetRef(); @@ -111,16 +111,20 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const projectQuery = useProjectQuery(projectId); const projectsQuery = useProjectsQuery(); const project = projectQuery.data; - const repoStateQuery = useRepoStateQuery(project); - const pullRequestsQuery = useProjectPullRequestsQuery(project); - const defaultBranch = project - ? resolveProjectDefaultBranch(project.defaultBranch, repoStateQuery.data) + const repository = selectProjectRepository(project, repositoryId); + const { applyPatch: applyRepositorySearch } = useHistorySearchState( + PROJECT_REPOSITORY_SEARCH_KEYS, + ); + const repoStateQuery = useRepoStateQuery(repository); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); + const defaultBranch = repository + ? resolveProjectDefaultBranch(repository.defaultBranch, repoStateQuery.data) : null; const { branchOptions, forgetBranch, managedBranches, rememberBranch } = useOptimisticProjectBranches({ defaultBranch, observedBranches: repoStateQuery.data?.branches ?? [], - projectId, + projectId: repository?.id ?? projectId, referencedBranches: pullRequestsQuery.data?.map( (pullRequest) => pullRequest.branchName ?? null, @@ -130,7 +134,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { useProjectRepositoryRefSelection({ branchOptions, defaultBranch, - projectAvailable: Boolean(project), + projectAvailable: Boolean(repository), projectPending: projectQuery.isPending, tags: repoStateQuery.data?.tags ?? [], }); @@ -183,10 +187,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }, [], ); - const issuesQuery = useProjectIssuesQuery(project); + const issuesQuery = useProjectIssuesQuery(repository); const selectedBranchPullRequest = React.useMemo(() => { const projectRepositories = new Set( - (project?.cloneUrls ?? []).map(normalizeRepositoryUrl), + (repository?.cloneUrls ?? []).map(normalizeRepositoryUrl), ); const matches = pullRequestsQuery.data?.filter( @@ -197,7 +201,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ), ) ?? []; return matches.length === 1 ? matches[0] : null; - }, [activeBranch, project?.cloneUrls, pullRequestsQuery.data]); + }, [activeBranch, pullRequestsQuery.data, repository?.cloneUrls]); const openBranchPullRequest = selectedBranchPullRequest?.status === "Open" || selectedBranchPullRequest?.status === "Draft" @@ -210,58 +214,58 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { "remote", ); const repoSnapshotQuery = useProjectRepoSnapshotQuery( - project, + repository, activeBranch, selectedTag ? null : selectedBranchPullRequest, activeTag, ); const repoDiffQuery = useProjectRepoDiffQuery( - project, + repository, activeBranch, activeRepoPullRequest, repoSource === "remote", ); const localRepoDiffQuery = useProjectLocalRepoDiffQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, activeRepoPullRequest, repoSource === "local" && Boolean(activeRepoPullRequest), ); const commitDiffQuery = useProjectCommitDiffQuery( - project, + repository, selectedCommitHash, repoSource, activeCommunity?.reposDir, ); const localRepoSnapshotQuery = useProjectLocalRepoSnapshotQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const repoSyncStatusQuery = useProjectRepoSyncStatusQuery( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const pushLocalRepoMutation = usePushProjectLocalRepositoryMutation( - project, + repository, activeCommunity?.reposDir, activeBranch, openBranchPullRequest, ); const pullLocalRepoMutation = usePullProjectLocalRepositoryMutation( - project, + repository, activeCommunity?.reposDir, activeBranch, ); const cloneRepoMutation = useCloneProjectRepositoryMutation( - project, + repository, activeCommunity?.reposDir, ); - const createIssueMutation = useCreateProjectIssueMutation(project); + const createIssueMutation = useCreateProjectIssueMutation(repository); const updatePullRequestMutation = useUpdateProjectPullRequestMutation( - project, + repository, openBranchPullRequest, ); const hasLocalCheckout = Boolean( @@ -321,7 +325,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { defaultBranch, deleteBranchReason, forgetBranch, - project, + project: repository, refetchRepoState: repoStateQuery.refetch, rememberBranch, selectBranch: handleBranchChange, @@ -377,7 +381,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { : "Local missing", remoteLabel: repoSnapshotQuery.isLoading ? "Remote checking" : "Remote", onCloneLocal: - !selectedTag && project?.cloneUrls[0] + !selectedTag && repository?.cloneUrls[0] ? () => { void handleCloneRepo(); } @@ -421,7 +425,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }; const projectPending = projectQuery.isPending; React.useEffect(() => { - if (!project) { + if (!repository) { // While the project query is still loading, keep the URL-seeded // pullRequestId/issueId selections — clearing here would discard them // before the detail view ever gets a chance to open. @@ -430,7 +434,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { setSelectedIssueId(null); setSelectedCommitHash(null); } - }, [project, projectPending]); + }, [projectPending, repository]); React.useEffect(() => { setRepoSource((currentSource) => { if (selectedTag) return "remote"; @@ -446,7 +450,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { }); }, [hasLocalCheckout, hasRemoteSnapshot, selectedTag]); const peoplePubkeys = React.useMemo(() => { - if (!project) return []; + if (!repository) return []; // Include PR authors/updaters so commit rows can resolve avatars for // publishers who are not listed as project contributors. const pullRequestPubkeys = (pullRequestsQuery.data ?? []).flatMap( @@ -465,12 +469,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ]); return [ ...new Set([ - ...projectPeople(project), + ...projectPeople(repository), ...pullRequestPubkeys, ...issuePubkeys, ]), ]; - }, [issuesQuery.data, project, pullRequestsQuery.data]); + }, [issuesQuery.data, pullRequestsQuery.data, repository]); const profilesQuery = useUsersBatchQuery(peoplePubkeys, { enabled: peoplePubkeys.length > 0, }); @@ -572,15 +576,32 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { } }, [cloneRepoMutation]); const handlePullRequestCreated = React.useCallback( - async (createdProject: Project, pullRequestId: string) => { + async ( + createdProject: Project, + createdRepository: Repository, + pullRequestId: string, + ) => { if (createdProject.id !== projectId) { - await goProject(createdProject.id, { pullRequestId }); + await goProject(createdProject.id, { + pullRequestId, + repositoryId: createdRepository.id, + }); return; } - await pullRequestsQuery.refetch(); + if (createdRepository.id === repository?.id) { + await pullRequestsQuery.refetch(); + } else { + applyRepositorySearch({ repositoryId: createdRepository.id }); + } setSelectedPullRequestId(pullRequestId); }, - [goProject, projectId, pullRequestsQuery], + [ + applyRepositorySearch, + goProject, + projectId, + pullRequestsQuery, + repository?.id, + ], ); const handleCreateIssue = React.useCallback( async ({ body, title }: CreateIssueDialogInput) => { @@ -640,12 +661,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ]); const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback(() => { - if (!project) return Promise.resolve(); - return openTerminal(project, { + if (!repository) return Promise.resolve(); + return openTerminal(repository, { branch: activeBranch, hasLocalCheckout, }); - }, [activeBranch, hasLocalCheckout, openTerminal, project]); + }, [activeBranch, hasLocalCheckout, openTerminal, repository]); const handleOpenMergeRecoveryTerminal = React.useCallback( async (input: { expectedCommit: string; @@ -653,18 +674,18 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { sourceCloneUrl: string; targetBranch: string; }) => { - const targetCloneUrl = project?.cloneUrls[0]; - if (!project || !targetCloneUrl) { + const targetCloneUrl = repository?.cloneUrls[0]; + if (!repository || !targetCloneUrl) { throw new Error("No project selected."); } return openProjectMergeRecoveryTerminal({ ...input, - projectDtag: project.dtag, + projectDtag: repository.dtag, reposDir: activeCommunity?.reposDir, targetCloneUrl, }); }, - [activeCommunity?.reposDir, project], + [activeCommunity?.reposDir, repository], ); if (projectQuery.isLoading) { @@ -717,10 +738,23 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ); } + if (!repository) { + return ( +
+ +

{project.name}

+

+ This project does not have any available repositories yet. +

+
+ ); + } const repoContributors = repoSnapshotQuery.data?.contributors ?? []; const safeWebUrl = - project.webUrl && isSafeUrl(project.webUrl) ? project.webUrl : null; + repository.webUrl && isSafeUrl(repository.webUrl) + ? repository.webUrl + : null; const selectedPullRequest = pullRequestsQuery.data?.find((item) => item.id === selectedPullRequestId) ?? null; @@ -770,6 +804,19 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { // instead of whatever tab the work item left behind. setTabsResetKey((key) => key + 1); }; + const handleRepositoryChange = (nextRepositoryId: string) => { + applyRepositorySearch({ + repositoryId: nextRepositoryId, + issueId: null, + pullRequestId: null, + commitHash: null, + }); + setSelectedPullRequestId(null); + setSelectedIssueId(null); + setSelectedCommitHash(null); + setRepoSource("remote"); + setTabsResetKey((key) => key + 1); + }; return ( @@ -781,101 +828,19 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { />
-
-
- - {project.projectChannelId ? ( - - ) : null} -
-
+ { + void goChannel(channelId); + }} + onGoProjectHome={handleGoToProjectHome} + onGoProjects={() => { + void goProjects(); + }} + project={project} + />
@@ -905,11 +870,16 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ) : null}
+
void; + project: Project; + repository: Repository; +}) { + if (project.repositories.length < 2) return null; + + return ( + + + + + + {project.repositories.map((candidate) => ( + onChange(candidate.id)} + > + {candidate.name} + {candidate.id === repository.id ? ( + + ) : null} + + ))} + + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 8fdfba194b..188083761f 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -15,6 +15,7 @@ import type { ProjectRepoContributor, ProjectRepoDiff, ProjectRepoSnapshot, + Repository, } from "@/features/projects/hooks"; import { commitAuthorPubkeysFromPullRequests, @@ -51,7 +52,11 @@ import { PROJECT_PANEL_ACTION_BUTTON_CLASS } from "./projectPanelStyles"; type CreatePullRequestAction = { projects: Project[]; reposDir?: string | null; - onCreated: (project: Project, pullRequestId: string) => void | Promise; + onCreated: ( + project: Project, + repository: Repository, + pullRequestId: string, + ) => void | Promise; }; type CreateIssueAction = { @@ -146,7 +151,7 @@ export function WorkspaceTabs({ localSnapshot: ProjectLocalRepoSnapshot | null | undefined; localSnapshotError: unknown; localSnapshotLoading: boolean; - project: Project; + project: Repository; repoDiff: ProjectRepoDiff | null | undefined; repoDiffError: unknown; repoDiffLoading: boolean; diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index ba0c15b1c7..04656e23ce 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -10,6 +10,7 @@ import type { ProjectPullRequest, ProjectPullRequestListItem, ProjectRepoSnapshot, + Repository, } from "@/features/projects/hooks"; import { formatExactTimestamp, @@ -37,9 +38,15 @@ type ActivityTarget = | { type: "pull-request"; project: Project; + repository: Repository; pullRequest: ProjectPullRequest; } - | { type: "issue"; project: Project; issue: ProjectIssue }; + | { + type: "issue"; + project: Project; + repository: Repository; + issue: ProjectIssue; + }; type ProjectActivityItem = { id: string; @@ -59,10 +66,15 @@ type ProjectsActivityFeedProps = { isLoading: boolean; issues: ProjectIssueListItem[]; onOpenCommit: (project: Project, commitHash: string) => void; - onOpenIssue: (project: Project, issue: ProjectIssue) => void; + onOpenIssue: ( + project: Project, + repository: Repository, + issue: ProjectIssue, + ) => void; onOpenProject: (project: Project) => void; onOpenPullRequest: ( project: Project, + repository: Repository, pullRequest: ProjectPullRequest, ) => void; profiles?: UserProfileLookup; @@ -129,10 +141,15 @@ function buildActivityItems({ }); } - for (const { project, pullRequest } of pullRequests) { - const target = { type: "pull-request", project, pullRequest } as const; + for (const { project, pullRequest, repository } of pullRequests) { + const target = { + type: "pull-request", + project, + pullRequest, + repository, + } as const; items.push({ - id: `pr:${pullRequest.id}`, + id: `pr:${repository.id}:${pullRequest.id}`, kind: "pull-request", createdAt: pullRequest.createdAt, actorPubkey: pullRequest.author, @@ -145,7 +162,7 @@ function buildActivityItems({ }); for (const update of pullRequest.updates) { items.push({ - id: `pr-update:${update.id}`, + id: `pr-update:${repository.id}:${update.id}`, kind: "commit", createdAt: update.createdAt, actorPubkey: update.author, @@ -172,7 +189,7 @@ function buildActivityItems({ ? "review-request" : "comment"; items.push({ - id: `pr-comment:${comment.id}`, + id: `pr-comment:${repository.id}:${comment.id}`, kind, createdAt: comment.createdAt, actorPubkey: comment.author, @@ -198,10 +215,10 @@ function buildActivityItems({ } } - for (const { project, issue } of issues) { - const target = { type: "issue", project, issue } as const; + for (const { project, issue, repository } of issues) { + const target = { type: "issue", project, issue, repository } as const; items.push({ - id: `issue:${issue.id}`, + id: `issue:${repository.id}:${issue.id}`, kind: "issue", createdAt: issue.createdAt, actorPubkey: issue.author, @@ -214,7 +231,7 @@ function buildActivityItems({ }); for (const comment of issue.comments) { items.push({ - id: `issue-comment:${comment.id}`, + id: `issue-comment:${repository.id}:${comment.id}`, kind: "comment", createdAt: comment.createdAt, actorPubkey: comment.author, @@ -421,10 +438,15 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) { } else if (item.target.type === "pull-request") { props.onOpenPullRequest( item.target.project, + item.target.repository, item.target.pullRequest, ); } else { - props.onOpenIssue(item.target.project, item.target.issue); + props.onOpenIssue( + item.target.project, + item.target.repository, + item.target.issue, + ); } }} onOpenProject={() => props.onOpenProject(item.target.project)} diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 29705425f6..52245da577 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -81,10 +81,19 @@ const REPO_CONTEXT_MARKER = "Workspace repositories:"; * with the first message of a conversation. */ function repoContextBlock(projects: readonly Project[]) { if (projects.length === 0) return ""; - const listed = projects + const repositories = projects.flatMap((project) => + project.repositories.map((repository) => ({ + label: + project.repositories.length > 1 + ? `${project.name} / ${repository.name}` + : project.name, + repoAddress: repository.repoAddress, + })), + ); + const listed = repositories .slice(0, MAX_CONTEXT_REPOS) - .map((project) => `- ${project.name} (${project.repoAddress})`); - const remaining = projects.length - listed.length; + .map((repository) => `- ${repository.label} (${repository.repoAddress})`); + const remaining = repositories.length - listed.length; return ["", "---", REPO_CONTEXT_MARKER, ...listed] .concat(remaining > 0 ? [`…and ${remaining} more`] : []) .join("\n"); diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index 916508a96e..20c00b3a31 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -4,6 +4,7 @@ import type { Project, ProjectIssue, ProjectIssueListItem, + Repository, } from "@/features/projects/hooks"; import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems"; import { @@ -33,7 +34,11 @@ type ProjectsIssuesListProps = { failedSections: ProjectWorkItemSection[]; isLoading: boolean; isRetrying: boolean; - onOpen: (project: Project, issue: ProjectIssue) => void; + onOpen: ( + project: Project, + repository: Repository, + issue: ProjectIssue, + ) => void; onRetry: () => void; profiles?: UserProfileLookup; issues: ProjectIssueListItem[]; @@ -282,11 +287,13 @@ export function ProjectsIssuesList({
{loadNotice}
- {issues.map(({ project, issue }) => ( + {issues.map(({ project, issue, repository }) => ( + onOpen(selectedProject, repository, selectedIssue) + } profiles={profiles} project={project} /> @@ -300,11 +307,13 @@ export function ProjectsIssuesList({
{loadNotice}
- {issues.map(({ project, issue }) => ( + {issues.map(({ project, issue, repository }) => ( + onOpen(selectedProject, repository, selectedIssue) + } profiles={profiles} project={project} /> diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 3640af904e..0bdca1751d 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -27,7 +27,7 @@ function overviewStats( ) { return projects.reduce( (stats, project) => { - const summary = summaries?.[project.repoAddress]; + const summary = summaries?.[project.id]; return { issues: stats.issues + (summary?.issueCount ?? 0), prs: stats.prs + (summary?.prCount ?? 0), @@ -82,7 +82,10 @@ export function ProjectsOverviewPanel({
count + project.repositories.length, + 0, + )} icon={FolderGit2} label="Repositories" onClick={() => onSelectSection("repositories")} diff --git a/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx b/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx index 9f77cf102c..284c53365f 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx @@ -27,8 +27,11 @@ function overviewPeople( projects.flatMap((project) => [ project.owner, - ...project.contributors, - ...(summaries?.[project.repoAddress]?.participantPubkeys ?? []), + ...project.repositories.flatMap((repository) => [ + repository.owner, + ...repository.contributors, + ]), + ...(summaries?.[project.id]?.participantPubkeys ?? []), ].map(normalizePubkey), ), ), @@ -41,7 +44,7 @@ function overviewActivityByDay( ) { const merged: Record = {}; for (const project of projects) { - const byDay = summaries?.[project.repoAddress]?.activityByDay; + const byDay = summaries?.[project.id]?.activityByDay; if (!byDay) continue; for (const [day, count] of Object.entries(byDay)) { merged[day] = (merged[day] ?? 0) + count; diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index 5934df316a..3e13cd09e2 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -4,6 +4,7 @@ import type { Project, ProjectPullRequest, ProjectPullRequestListItem, + Repository, } from "@/features/projects/hooks"; import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems"; import { @@ -53,7 +54,11 @@ type ProjectsPullRequestsListProps = { failedSections: ProjectWorkItemSection[]; isLoading: boolean; isRetrying: boolean; - onOpen: (project: Project, pullRequest: ProjectPullRequest) => void; + onOpen: ( + project: Project, + repository: Repository, + pullRequest: ProjectPullRequest, + ) => void; onRetry: () => void; profiles?: UserProfileLookup; pullRequests: ProjectPullRequestListItem[]; @@ -306,10 +311,12 @@ export function ProjectsPullRequestsList({
{loadNotice}
- {pullRequests.map(({ project, pullRequest }) => ( + {pullRequests.map(({ project, pullRequest, repository }) => ( + onOpen(selectedProject, repository, selectedPullRequest) + } profiles={profiles} project={project} pullRequest={pullRequest} @@ -324,10 +331,12 @@ export function ProjectsPullRequestsList({
{loadNotice}
- {pullRequests.map(({ project, pullRequest }) => ( + {pullRequests.map(({ project, pullRequest, repository }) => ( + onOpen(selectedProject, repository, selectedPullRequest) + } profiles={profiles} project={project} pullRequest={pullRequest} diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 2f22bfe0c1..b5f89c2763 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -7,6 +7,7 @@ import { type Project, type ProjectIssue, type ProjectPullRequest, + type Repository, useDeleteProjectMutation, useProjectActivitySummariesQuery, useProjectLocalRepositoriesQuery, @@ -14,6 +15,7 @@ import { useProjectsWorkItemsQuery, } from "@/features/projects/hooks"; import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; +import { selectProjectRepository } from "@/features/projects/projectModels"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed"; import { @@ -191,10 +193,7 @@ export function ProjectsView() { ...new Set( [ ...projects.flatMap((project) => - projectPeople( - project, - activitySummariesQuery.data?.[project.repoAddress], - ), + projectPeople(project, activitySummariesQuery.data?.[project.id]), ), ...(projectsWorkItemsQuery.data?.pullRequests.items.flatMap( ({ pullRequest }) => [ @@ -293,7 +292,7 @@ export function ProjectsView() { const sortedProjects = projects .filter((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; const people = projectPeople(project, summary); if (repositoryScope === "mine") return isProjectMine(project, currentPubkey); @@ -306,8 +305,8 @@ export function ProjectsView() { return true; }) .sort((left, right) => { - const leftSummary = activitySummariesQuery.data?.[left.repoAddress]; - const rightSummary = activitySummariesQuery.data?.[right.repoAddress]; + const leftSummary = activitySummariesQuery.data?.[left.id]; + const rightSummary = activitySummariesQuery.data?.[right.id]; if (sort === "name") { return left.name.localeCompare(right.name); } @@ -392,25 +391,38 @@ export function ProjectsView() { ); const handleOpenPullRequest = React.useCallback( - (project: Project, pullRequest: ProjectPullRequest) => { - void goProject(project.id, { pullRequestId: pullRequest.id }); + ( + project: Project, + repository: Repository, + pullRequest: ProjectPullRequest, + ) => { + void goProject(project.id, { + pullRequestId: pullRequest.id, + repositoryId: repository.id, + }); }, [goProject], ); const handleOpenIssue = React.useCallback( - (project: Project, issue: ProjectIssue) => { - void goProject(project.id, { issueId: issue.id }); + (project: Project, repository: Repository, issue: ProjectIssue) => { + void goProject(project.id, { + issueId: issue.id, + repositoryId: repository.id, + }); }, [goProject], ); const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback( - (project: Project) => - openTerminal(project, { + (project: Project) => { + const repository = selectProjectRepository(project, null); + if (!repository) return Promise.resolve(); + return openTerminal(repository, { hasLocalCheckout: hasLocalCheckout(project, localRepoNames), - }), + }); + }, [localRepoNames, openTerminal], ); @@ -462,7 +474,7 @@ export function ProjectsView() { )} > {visibleProjects.map((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; return ( {visibleProjects.map((project) => { - const summary = activitySummariesQuery.data?.[project.repoAddress]; + const summary = activitySummariesQuery.data?.[project.id]; return ( {createPullRequestOpen ? ( { - await goProject(createdProject.id, { pullRequestId }); + onCreated={async ( + createdProject, + createdRepository, + pullRequestId, + ) => { + await goProject(createdProject.id, { + pullRequestId, + repositoryId: createdRepository.id, + }); }} onOpenChange={setCreatePullRequestOpen} open @@ -624,8 +643,11 @@ export function ProjectsView() { /> ) : null} { - await goProject(createdProject.id, { issueId }); + onCreated={async (createdProject, createdRepository, issueId) => { + await goProject(createdProject.id, { + issueId, + repositoryId: createdRepository.id, + }); }} onOpenChange={setCreateIssueOpen} open={createIssueOpen} diff --git a/desktop/src/features/projects/ui/PullRequestReviewCard.tsx b/desktop/src/features/projects/ui/PullRequestReviewCard.tsx index bf2f074a8b..e642cb2763 100644 --- a/desktop/src/features/projects/ui/PullRequestReviewCard.tsx +++ b/desktop/src/features/projects/ui/PullRequestReviewCard.tsx @@ -3,7 +3,10 @@ import * as React from "react"; import { toast } from "sonner"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; import { nextProjectPullRequestReviewCreatedAt, projectPullRequestReviewSummary, diff --git a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx index 63c5be94b1..9923a2bcab 100644 --- a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx +++ b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx @@ -3,7 +3,10 @@ import * as React from "react"; import { toast } from "sonner"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; -import type { Project, ProjectPullRequest } from "@/features/projects/hooks"; +import type { + ProjectPullRequest, + Repository as Project, +} from "@/features/projects/hooks"; import { useRequestProjectPullRequestReviewMutation } from "@/features/projects/pullRequestReviews"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; diff --git a/desktop/src/features/projects/ui/projectDetailHelpers.ts b/desktop/src/features/projects/ui/projectDetailHelpers.ts index 3552e0db19..c018c426d1 100644 --- a/desktop/src/features/projects/ui/projectDetailHelpers.ts +++ b/desktop/src/features/projects/ui/projectDetailHelpers.ts @@ -1,4 +1,7 @@ -import type { Project, ProjectRepoSnapshot } from "@/features/projects/hooks"; +import type { + ProjectRepoSnapshot, + Repository as Project, +} from "@/features/projects/hooks"; import { normalizePubkey } from "@/shared/lib/pubkey"; export const PROJECT_TAB_CRUMB_LABELS: Record = { diff --git a/desktop/src/features/projects/ui/useOpenProjectTerminal.ts b/desktop/src/features/projects/ui/useOpenProjectTerminal.ts index 64d2415536..cead71377c 100644 --- a/desktop/src/features/projects/ui/useOpenProjectTerminal.ts +++ b/desktop/src/features/projects/ui/useOpenProjectTerminal.ts @@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; -import type { Project } from "@/features/projects/hooks"; +import type { Repository } from "@/features/projects/hooks"; import { openProjectTerminal } from "@/shared/api/projectGit"; export function projectTerminalLabel(hasLocalCheckout: boolean) { @@ -19,7 +19,7 @@ export function useOpenProjectTerminal(reposDir?: string | null) { return React.useCallback( async ( - project: Project, + project: Repository, options: { branch?: string | null; hasLocalCheckout: boolean }, ) => { const toastId = options.hasLocalCheckout diff --git a/desktop/src/features/projects/useCreateProject.ts b/desktop/src/features/projects/useCreateProject.ts index fce3e0fd8e..f5a04bc9b4 100644 --- a/desktop/src/features/projects/useCreateProject.ts +++ b/desktop/src/features/projects/useCreateProject.ts @@ -1,11 +1,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - eventToProject, fetchProjects, type Project, projectsQueryKey, } from "@/features/projects/hooks"; +import { buildProjectReadModels } from "@/features/projects/projectModels"; import { relayClient } from "@/shared/api/relayClient"; import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; import { signRelayEvent } from "@/shared/api/tauri"; @@ -78,7 +78,15 @@ async function createProject(input: CreateProjectInput): Promise { "Failed to create project.", ); - return eventToProject(event, getCachedRelayOrigin()); + const [project] = buildProjectReadModels({ + projectEvents: [], + repositoryEvents: [event], + relayOrigin: getCachedRelayOrigin(), + }); + if (!project) { + throw new Error("The repository was created but could not be read."); + } + return project; } /** Mutation that creates a project and inserts it into the projects cache. */ diff --git a/desktop/src/features/projects/useProjectCommitDiff.ts b/desktop/src/features/projects/useProjectCommitDiff.ts index ba3ab5fc80..1151cdffe2 100644 --- a/desktop/src/features/projects/useProjectCommitDiff.ts +++ b/desktop/src/features/projects/useProjectCommitDiff.ts @@ -5,7 +5,7 @@ import { getProjectRepoDiff, } from "@/shared/api/projectGit"; import type { ProjectRepoDiff } from "@/shared/api/types"; -import type { Project } from "./hooks"; +import type { Repository as Project } from "./hooks"; async function fetchProjectCommitDiff( project: Project, diff --git a/desktop/src/features/projects/useProjectsRepoSnapshots.ts b/desktop/src/features/projects/useProjectsRepoSnapshots.ts index 256c90a1e3..e8b10bc53d 100644 --- a/desktop/src/features/projects/useProjectsRepoSnapshots.ts +++ b/desktop/src/features/projects/useProjectsRepoSnapshots.ts @@ -7,6 +7,7 @@ import { } from "@/shared/api/projectGit"; import type { ProjectRepoSnapshot } from "@/shared/api/types"; import type { Project } from "./hooks"; +import { selectProjectRepository } from "./projectModels"; // Remote snapshots are backed by a blobless `git clone` per repository, so the // overview scan is deliberately throttled and cached for a long time. @@ -27,25 +28,27 @@ async function fetchProjectSnapshot( project: Project, reposDir: string | null | undefined, ): Promise { + const repository = selectProjectRepository(project, null); + if (!repository) return null; try { const local = await getProjectLocalRepoSnapshot({ reposDir, - projectDtag: project.dtag, - cloneUrl: project.cloneUrls[0] ?? null, - defaultBranch: project.defaultBranch, - baseBranch: project.defaultBranch, + projectDtag: repository.dtag, + cloneUrl: repository.cloneUrls[0] ?? null, + defaultBranch: repository.defaultBranch, + baseBranch: repository.defaultBranch, }); if (snapshotHasData(local?.snapshot)) return local?.snapshot ?? null; } catch { // Best-effort: fall through to the remote snapshot. } - const cloneUrl = project.cloneUrls[0]; + const cloneUrl = repository.cloneUrls[0]; if (!cloneUrl) return null; return getProjectRepoSnapshot({ cloneUrl, - defaultBranch: project.defaultBranch, - baseBranch: project.defaultBranch, + defaultBranch: repository.defaultBranch, + baseBranch: repository.defaultBranch, }); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 02aeeb49d2..a92970e12f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -35,6 +35,7 @@ import { KIND_HUDDLE_STARTED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, @@ -4845,6 +4846,7 @@ const MOCK_PROJECT_SUBJECTS = [ ]; const MOCK_PROJECT_KINDS = new Set([ + KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_GIT_PATCH, @@ -5000,6 +5002,25 @@ function buildMockProjectEvents(): RelayEvent[] { } } + const projectOwner = + window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ ?? MOCK_PROJECT_SEEDS[0].owner; + events.push( + createMockEvent( + KIND_PROJECT_ANNOUNCEMENT, + "Relay, desktop, mobile, and operator tooling for Buzz.", + [ + ["d", "buzz"], + ["name", "buzz"], + ["description", "The complete Buzz community platform."], + ["a", `${KIND_REPO_ANNOUNCEMENT}:${projectOwner}:buzz`, "", "primary"], + ["a", `${KIND_REPO_ANNOUNCEMENT}:${ALICE_PUBKEY}:relay-tools`], + ], + projectOwner, + now, + "project-buzz".padEnd(64, "0"), + ), + ); + return events; } diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 52c886a0d1..876263584b 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; -import { installMockBridge } from "../helpers/bridge"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; const SHOTS = "test-results/project-commit-detail"; const ALIGNMENT_TOLERANCE_PX = 2; @@ -136,6 +136,39 @@ test("top-level project lists align dates and overflow actions", async ({ ).toBe(true); }); +test("multi-repository projects switch the active repository", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-projects-view").click(); + await page.getByRole("button", { name: "Repositories", exact: true }).click(); + await page + .locator( + '[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]', + ) + .first() + .click(); + + const picker = page.getByTestId("project-repository-picker"); + await expect(picker).toContainText("buzz"); + await picker.click(); + await expect( + page.getByTestId("project-repository-relay-tools"), + ).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ + path: `${SHOTS}/04-multi-repository-picker.png`, + }); + + await page.getByTestId("project-repository-relay-tools").click(); + await expect(picker).toContainText("relay-tools"); + await expect(page).toHaveURL( + new RegExp(`repositoryId=${TEST_IDENTITIES.alice.pubkey}%3Arelay-tools`), + ); +}); + test("commit detail opens from the commits feed with a diff", async ({ page, }) => { From eef840f40c049830fcdb61a4f282ce726d40a9ee Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 24 Jul 2026 23:11:30 +0200 Subject: [PATCH 03/19] feat(projects): create projects with initial repositories Publish the project grouping alongside its initial NIP-34 repository and make partial-write retries idempotent. Add protocol validation and end-to-end coverage for accepted, rejected, and lost-acknowledgement paths. --- .../projects/projectCreation.test.mjs | 63 ++++++++ .../src/features/projects/projectCreation.ts | 94 ++++++++++++ .../projects/ui/CreateProjectDialog.tsx | 8 +- .../projects/ui/ProjectsCreateMenu.tsx | 8 +- .../src/features/projects/ui/ProjectsView.tsx | 2 +- .../src/features/projects/useCreateProject.ts | 117 +++++++------- desktop/src/testing/e2eBridge.ts | 34 ++++- .../tests/e2e/project-commit-detail.spec.ts | 144 +++++++++++++++++- 8 files changed, 401 insertions(+), 69 deletions(-) create mode 100644 desktop/src/features/projects/projectCreation.test.mjs create mode 100644 desktop/src/features/projects/projectCreation.ts diff --git a/desktop/src/features/projects/projectCreation.test.mjs b/desktop/src/features/projects/projectCreation.test.mjs new file mode 100644 index 0000000000..597277483c --- /dev/null +++ b/desktop/src/features/projects/projectCreation.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInitialProjectEventTemplates } from "./projectCreation.ts"; + +const OWNER = "a".repeat(64); + +test("buildInitialProjectEventTemplates links the initial repository as primary", () => { + const templates = buildInitialProjectEventTemplates({ + cloneUrl: "https://relay.example/git/owner/sprout.git", + description: "A multi-repository workspace", + name: "Sprout", + ownerPubkey: OWNER, + webUrl: "https://example.com/sprout", + }); + + assert.equal(templates.dtag, "sprout"); + assert.equal(templates.project.kind, 30621); + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["description", "A multi-repository workspace"], + ["a", `30617:${OWNER}:sprout`, "", "primary"], + ]); + assert.deepEqual(templates.repository.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["description", "A multi-repository workspace"], + ["clone", "https://relay.example/git/owner/sprout.git"], + ["web", "https://example.com/sprout"], + ]); +}); + +test("buildInitialProjectEventTemplates rejects names without an identifier", () => { + assert.throws( + () => + buildInitialProjectEventTemplates({ + name: "!!!", + ownerPubkey: OWNER, + }), + /letters or numbers/, + ); +}); + +test("buildInitialProjectEventTemplates enforces the project content byte limit", () => { + assert.doesNotThrow(() => + buildInitialProjectEventTemplates({ + description: "🙂".repeat(256), + name: "Sprout", + ownerPubkey: OWNER, + }), + ); + assert.throws( + () => + buildInitialProjectEventTemplates({ + description: "🙂".repeat(257), + name: "Sprout", + ownerPubkey: OWNER, + }), + /1,024 bytes/, + ); +}); diff --git a/desktop/src/features/projects/projectCreation.ts b/desktop/src/features/projects/projectCreation.ts new file mode 100644 index 0000000000..125de426f5 --- /dev/null +++ b/desktop/src/features/projects/projectCreation.ts @@ -0,0 +1,94 @@ +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; + +export type ProjectEventTemplate = { + kind: number; + content: string; + tags: string[][]; +}; + +export type InitialProjectEventTemplates = { + dtag: string; + project: ProjectEventTemplate; + repository: ProjectEventTemplate; + repositoryAddress: string; +}; + +function projectDtagFromName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function buildInitialProjectEventTemplates({ + cloneUrl, + description, + name, + ownerPubkey, + webUrl, +}: { + cloneUrl?: string; + description?: string; + name: string; + ownerPubkey: string; + webUrl?: string; +}): InitialProjectEventTemplates { + const normalizedName = name.trim(); + if (!normalizedName) { + throw new Error("Project name is required."); + } + const dtag = projectDtagFromName(normalizedName); + if (!dtag) { + throw new Error("Project name must include letters or numbers."); + } + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalizedOwner)) { + throw new Error("Project owner public key is invalid."); + } + + const normalizedDescription = description?.trim() ?? ""; + if (new TextEncoder().encode(normalizedDescription).byteLength > 1_024) { + throw new Error("Project description must not exceed 1,024 bytes."); + } + const repositoryTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ]; + const projectTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ]; + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + projectTags.push(["description", normalizedDescription]); + } + const normalizedCloneUrl = cloneUrl?.trim(); + if (normalizedCloneUrl) { + repositoryTags.push(["clone", normalizedCloneUrl]); + } + const normalizedWebUrl = webUrl?.trim(); + if (normalizedWebUrl) { + repositoryTags.push(["web", normalizedWebUrl]); + } + + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; + projectTags.push(["a", repositoryAddress, "", "primary"]); + + return { + dtag, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: normalizedDescription, + tags: projectTags, + }, + repository: { + kind: KIND_REPO_ANNOUNCEMENT, + content: normalizedDescription, + tags: repositoryTags, + }, + repositoryAddress, + }; +} diff --git a/desktop/src/features/projects/ui/CreateProjectDialog.tsx b/desktop/src/features/projects/ui/CreateProjectDialog.tsx index c5e6c2670c..74a869c325 100644 --- a/desktop/src/features/projects/ui/CreateProjectDialog.tsx +++ b/desktop/src/features/projects/ui/CreateProjectDialog.tsx @@ -22,7 +22,7 @@ type CreateProjectDialogProps = { open: boolean; }; -/** Modal for publishing a new project (NIP-34 repo announcement). */ +/** Modal for publishing a project with its initial NIP-34 repository. */ export function CreateProjectDialog({ isCreating, onCreate, @@ -88,7 +88,7 @@ export function CreateProjectDialog({ className="max-w-lg" contentClassName="pt-3" data-testid="create-project-dialog" - description="Projects are repositories published to this workspace's relay." + description="Projects group one or more repositories published to this workspace's relay." footer={
+ } + footerClassName="border-t-0 pt-0" + headerClassName="pb-2" + title="Add repository" + > +
void handleSubmit(event)} + > +
+ +
+ { + setName(event.target.value); + setErrorMessage(null); + }} + placeholder="mobile-app" + ref={nameInputRef} + spellCheck={false} + value={name} + /> +
+
+
+ +
+ { + setCloneUrl(event.target.value); + setErrorMessage(null); + }} + placeholder="https://relay.example.com/git/mobile-app.git" + spellCheck={false} + value={cloneUrl} + /> +
+
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+ + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 74042df71c..fdc85b3717 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -1,6 +1,6 @@ import { CircleDot, - FolderGit2, + Folders, GitCommit, GitPullRequest, TerminalSquare, @@ -83,7 +83,7 @@ function ProjectUpdatedLabel({ ); } -function ProjectPeopleStack({ +export function ProjectPeopleStack({ pubkeys, profiles, workOwnerPubkey, @@ -100,7 +100,7 @@ function ProjectPeopleStack({ } return ( -
+
{visible.map((pubkey, index) => { const profile = profiles?.[normalizePubkey(pubkey)]; const label = resolveUserLabel({ pubkey, profiles }); @@ -167,7 +167,7 @@ const PROJECT_STAT_ITEMS = [ }, ] as const; -function ProjectStatsRow({ +export function ProjectStatsRow({ summary, fixedColumns = false, }: { @@ -207,7 +207,7 @@ function ProjectStatsRow({ // Segmented commits/PRs/issues distribution — the card's "progress bar". // Hovering thickens the bar and reveals a tooltip with the exact breakdown. -function ProjectActivityBar({ +export function ProjectActivityBar({ summary, }: { summary: ProjectActivitySummary | undefined; @@ -266,7 +266,7 @@ function StatusPill({ status }: { status: string }) { export function EmptyState() { return (
- +

No projects yet

@@ -280,7 +280,7 @@ export function EmptyState() { export function EmptyFilteredState() { return (

- +

No matching projects @@ -302,7 +302,7 @@ function ProjectCardButton({ }) { return ( + + + Repositories + {project.repositories.map((candidate) => ( + onChange(candidate.id)} + > + {candidate.name} + {candidate.repoAddress === project.primaryRepositoryAddress ? ( + + Primary + + ) : null} + {candidate.id === repository.id ? ( + + ) : null} + + ))} + + + )} + {onAdd ? ( + - - - {project.repositories.map((candidate) => ( - onChange(candidate.id)} - > - {candidate.name} - {candidate.id === repository.id ? ( - - ) : null} - - ))} - - + ) : null} +

); } diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 0bdca1751d..4a84ce1fb5 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -1,4 +1,4 @@ -import { CircleDot, FolderGit2, GitPullRequest, Radio } from "lucide-react"; +import { CircleDot, FolderGit2, Folders, GitPullRequest } from "lucide-react"; import type * as React from "react"; import type { @@ -7,14 +7,13 @@ import type { } from "@/features/projects/hooks"; export type ProjectsOverviewSection = + | "projects" | "repositories" | "prs" - | "local" | "issues"; type ProjectsOverviewPanelProps = { children: React.ReactNode; - localRepositoryCount: number; metadata: React.ReactNode; onSelectSection: (section: ProjectsOverviewSection) => void; projects: Project[]; @@ -69,7 +68,6 @@ function StatPill({ export function ProjectsOverviewPanel({ children, - localRepositoryCount, metadata, onSelectSection, projects, @@ -81,6 +79,12 @@ export function ProjectsOverviewPanel({
+ onSelectSection("projects")} + /> count + project.repositories.length, @@ -96,12 +100,6 @@ export function ProjectsOverviewPanel({ label="Pull requests" onClick={() => onSelectSection("prs")} /> - onSelectSection("local")} - /> = [ - { label: "Overview", value: "all" }, + { label: "Activity", value: "all" }, + { label: "Projects", value: "projects" }, { label: "Repositories", value: "repositories" }, { label: "Pull Requests", value: "prs" }, { label: "Issues", value: "issues" }, @@ -82,6 +83,7 @@ export function ProjectsToolbar({ option.value === "all" && "pl-0 after:left-0", filter === option.value && SELECTED_MENU_ITEM_CLASSES, )} + data-testid={`projects-section-${option.value}`} key={option.value} onClick={() => onFilterChange(option.value)} type="button" diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 50e795a295..5a57c99fd3 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -14,6 +14,7 @@ import { useProjectsQuery, useProjectsWorkItemsQuery, } from "@/features/projects/hooks"; +import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; import { selectProjectRepository } from "@/features/projects/projectModels"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; @@ -39,7 +40,14 @@ import { ProjectsToolbar, ProjectsViewModeToggle, } from "@/features/projects/ui/ProjectsToolbar"; -import { hasLocalCheckout } from "@/features/projects/lib/projectLocalRepos"; +import { + hasLocalCheckout, + hasLocalRepositoryCheckout, +} from "@/features/projects/lib/projectLocalRepos"; +import { + RepositoryGridCard, + RepositoryListRow, +} from "@/features/projects/ui/RepositoryCards"; import { getProjectUpdatedAt, isProjectMine, @@ -58,7 +66,6 @@ import { readStoredRepositoryScope, readStoredSort, readStoredViewMode, - uniqueRepositories, writeStoredFilter, writeStoredIssueScope, writeStoredPullRequestScope, @@ -76,6 +83,14 @@ import { Button } from "@/shared/ui/button"; import { PageHeader } from "@/shared/ui/PageHeader"; const MANY_PROJECTS_THRESHOLD = 12; +const PROJECT_SCOPE_OPTIONS: Array<{ + label: string; + value: ProjectsRepositoryScope; +}> = [ + { label: "All", value: "all" }, + { label: "My Projects", value: "mine" }, + { label: "Local", value: "local" }, +]; const REPOSITORY_SCOPE_OPTIONS: Array<{ label: string; value: ProjectsRepositoryScope; @@ -154,7 +169,12 @@ export function ProjectsView() { : storedFilter; }); const activitySummariesQuery = useProjectActivitySummariesQuery( - filter === "prs" || filter === "issues" ? [] : projects, + filter === "prs" || filter === "issues" || filter === "repositories" + ? [] + : projects, + ); + const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( + filter === "repositories" ? projects : [], ); const [repositoryScope, setRepositoryScope] = React.useState(() => readStoredRepositoryScope()); @@ -168,10 +188,7 @@ export function ProjectsView() { ); // One blobless clone per unique repository — only scan while the overview // header (filter === "all") is actually visible. - const snapshotProjects = React.useMemo( - () => (filter === "all" ? uniqueRepositories(projects) : []), - [filter, projects], - ); + const snapshotProjects = filter === "all" ? projects : []; const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery( snapshotProjects, activeCommunity?.reposDir, @@ -274,19 +291,8 @@ export function ProjectsView() { [localRepositoriesQuery.data], ); - // Count projects with a checkout on this machine — matches what the - // "Local" filter actually lists, not every directory in the repos folder. - const localProjectCount = React.useMemo( - () => - projects.filter((project) => hasLocalCheckout(project, localRepoNames)) - .length, - [localRepoNames, projects], - ); - const visibleProjects = React.useMemo(() => { - // The PRs and Issues filters render dedicated lists - // (visiblePullRequests / visibleIssues), not project cards. - if (filter === "prs" || filter === "issues") { + if (filter !== "projects" && filter !== "agents" && filter !== "users") { return []; } @@ -319,9 +325,7 @@ export function ProjectsView() { ); }); - return filter === "repositories" - ? uniqueRepositories(sortedProjects) - : sortedProjects; + return sortedProjects; }, [ activitySummariesQuery.data, currentPubkey, @@ -333,6 +337,62 @@ export function ProjectsView() { sort, ]); + const visibleRepositories = React.useMemo(() => { + if (filter !== "repositories") return []; + const repositories = [ + ...new Map( + projects + .flatMap((project) => + project.repositories.map((repository) => ({ + project, + repository, + })), + ) + .map((item) => [item.repository.repoAddress, item]), + ).values(), + ]; + return repositories + .filter(({ repository }) => { + if (repositoryScope === "mine") { + if (!currentPubkey) return false; + const normalizedCurrentPubkey = normalizePubkey(currentPubkey); + return ( + normalizePubkey(repository.owner) === normalizedCurrentPubkey || + repository.contributors.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ) + ); + } + if (repositoryScope === "local") { + return hasLocalRepositoryCheckout(repository, localRepoNames); + } + return true; + }) + .sort((left, right) => { + if (sort === "name") { + return left.repository.name.localeCompare(right.repository.name); + } + if (sort === "created") { + return right.repository.createdAt - left.repository.createdAt; + } + const leftUpdatedAt = + repositoryActivitySummariesQuery.data?.[left.repository.repoAddress] + ?.updatedAt ?? left.repository.createdAt; + const rightUpdatedAt = + repositoryActivitySummariesQuery.data?.[right.repository.repoAddress] + ?.updatedAt ?? right.repository.createdAt; + return rightUpdatedAt - leftUpdatedAt; + }); + }, [ + currentPubkey, + filter, + localRepoNames, + projects, + repositoryActivitySummariesQuery.data, + repositoryScope, + sort, + ]); + const visiblePullRequests = React.useMemo(() => { const pullRequests = projectsWorkItemsQuery.data?.pullRequests.items ?? []; const scopedPullRequests = @@ -383,6 +443,13 @@ export function ProjectsView() { [goProject], ); + const handleOpenRepository = React.useCallback( + (project: Project, repository: Repository) => { + void goProject(project.id, { repositoryId: repository.id }); + }, + [goProject], + ); + const handleOpenCommit = React.useCallback( (project: Project, commitHash: string) => { void goProject(project.id, { commitHash }); @@ -425,6 +492,16 @@ export function ProjectsView() { }, [localRepoNames, openTerminal], ); + const handleOpenRepositoryTerminal = React.useCallback( + (repository: Repository) => + openTerminal(repository, { + hasLocalCheckout: hasLocalRepositoryCheckout( + repository, + localRepoNames, + ), + }), + [localRepoNames, openTerminal], + ); const handleDeleteProject = React.useCallback( async (project: Project) => { @@ -463,7 +540,7 @@ export function ProjectsView() { return ; } - const repositoryItems = + const projectItems = visibleProjects.length === 0 ? ( ) : viewMode === "grid" ? ( @@ -515,6 +592,45 @@ export function ProjectsView() {
); + const repositoryItems = + visibleRepositories.length === 0 ? ( + + ) : viewMode === "grid" ? ( +
+ {visibleRepositories.map(({ project, repository }) => ( + + ))} +
+ ) : ( +
+ {visibleRepositories.map(({ project, repository }) => ( + + ))} +
+ ); + const listControls = (