From 1bc2105f3edb9ada77587b7250c47b7d7b2bebbd Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 10 Aug 2026 08:01:16 +0200 Subject: [PATCH] feat(desktop): share Projects entities by copying a direct link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects, repositories, issues, and pull requests had no shareable reference — the only way to point someone at one was to describe it. Each now offers "Copy link" in its row menu (plus a copy button in the project, issue, and pull request detail headers), yielding a `buzz://` deep link that renders as a preview card in chat and opens the entity in-app when clicked or opened from the OS. Completes the entity-link format for this: adds the `buzz://project` scheme on both sides (TS builders/parser and the buzz-cli mirror, with `projects create` emitting `link`), resolves repository and project card titles from their announcement events, and registers the four entity hosts with the Tauri deep-link handler. Coordinates whose d-tag falls outside the link charset simply have no share affordance, rather than producing a link the recipient cannot parse. Signed-off-by: Thomas Petersen --- crates/buzz-acp/src/base_prompt.md | 2 +- crates/buzz-cli/src/commands/projects.rs | 42 ++++-- crates/buzz-cli/src/links.rs | 44 ++++++ desktop/src-tauri/src/deep_link.rs | 129 +++++++++++++++++- desktop/src/app/AppShell.tsx | 6 +- .../projects/lib/projectShareLinks.test.mjs | 111 +++++++++++++++ .../projects/lib/projectShareLinks.ts | 108 +++++++++++++++ .../projects/ui/CopyShareLinkMenuItem.tsx | 37 +++++ .../src/features/projects/ui/ProjectCards.tsx | 6 + .../projects/ui/ProjectDetailChrome.tsx | 31 +++-- .../projects/ui/ProjectIssuesPanel.tsx | 39 ++++-- .../projects/ui/ProjectPullRequestsPanel.tsx | 21 ++- .../projects/ui/ProjectsIssuesList.tsx | 6 + .../projects/ui/ProjectsPullRequestsList.tsx | 6 + .../features/projects/ui/RepositoryCards.tsx | 6 + .../features/projects/ui/ShareLinkButton.tsx | 68 +++++++++ desktop/src/shared/deep-link.ts | 13 ++ desktop/src/shared/lib/entityLink.test.mjs | 34 +++++ desktop/src/shared/lib/entityLink.ts | 63 +++++++-- desktop/src/shared/lib/linkPreview.test.mjs | 25 ++++ desktop/src/shared/lib/linkPreview.ts | 24 +++- .../src/shared/lib/useResolvedLinkPreviews.ts | 82 ++++++++--- desktop/src/shared/useAppDeepLinks.ts | 14 ++ desktop/src/shared/useEntityDeepLinks.ts | 33 +++++ docs/buzz-entity-links.md | 51 ++++--- 25 files changed, 897 insertions(+), 104 deletions(-) create mode 100644 desktop/src/features/projects/lib/projectShareLinks.test.mjs create mode 100644 desktop/src/features/projects/lib/projectShareLinks.ts create mode 100644 desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx create mode 100644 desktop/src/features/projects/ui/ShareLinkButton.tsx create mode 100644 desktop/src/shared/useAppDeepLinks.ts create mode 100644 desktop/src/shared/useEntityDeepLinks.ts diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 1d85221f1..12d103b42 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -31,7 +31,7 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. -`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. +`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. ## Conversational Agent Creation diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index e6798dbfc..32056bc69 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -108,13 +108,29 @@ fn make_tag(parts: &[&str]) -> Result { // ── Submit helper ───────────────────────────────────────────────────────────── -async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { +/// Submit a project event and print the relay's write response. +/// +/// `link_slug` carries the project's d-tag on creates whose slug fits the +/// `buzz://` link charset; the response then also carries a `link` field, +/// which renders as a rich preview card in Buzz Desktop when included in a +/// chat message — agents announce projects with it (see base_prompt.md). +async fn submit_project( + client: &BuzzClient, + builder: EventBuilder, + link_slug: Option<&str>, +) -> Result<(), CliError> { let event = client.sign_event(builder)?; + let owner = event.pubkey.to_hex(); let raw = client.submit_event(event).await?; - println!( - "{}", - parse_write_response(&raw, "project changed concurrently; retry")? - ); + let response = parse_write_response(&raw, "project changed concurrently; retry")?; + match link_slug { + Some(slug) => crate::client::print_create_response( + &response, + "link", + &crate::links::project_link(&owner, slug), + ), + None => println!("{response}"), + } Ok(()) } @@ -207,7 +223,15 @@ pub async fn cmd_create( // ── Build via Layer B (enforces all writer policy) ──────────────────── let builder = build_project(slug, name, description, &members, channel, visibility) .map_err(|e| CliError::Usage(e.to_string()))?; - submit_project(client, builder).await + + // Slugs wider than the link charset stay linkless rather than emitting a + // `link` no client can parse. + submit_project( + client, + builder, + crate::links::is_linkable_dtag(slug).then_some(slug), + ) + .await } /// `buzz projects get` @@ -320,7 +344,7 @@ pub async fn cmd_add_repo( } let builder = rebuild_project(&head.content, tags, next_ts)?; - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects remove-repo` @@ -383,7 +407,7 @@ pub async fn cmd_remove_repo( // Single rebuild validates the full envelope and strips any remaining auth. let builder = rebuild_project(&head.content, tags, next_ts)?; - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects update` @@ -484,7 +508,7 @@ pub async fn cmd_update( let builder = build_project_with_tags(&head.content, tags) .map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))? .custom_created_at(next_ts); - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects delete` diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs index 043bdc48b..0056f5773 100644 --- a/crates/buzz-cli/src/links.rs +++ b/crates/buzz-cli/src/links.rs @@ -9,11 +9,34 @@ //! Callers are expected to validate inputs first (`validate_hex64`, //! `validate_repo_id`); the identifier charsets need no URL encoding. +/// Whether a d-tag can be expressed in a `buzz://` link. +/// +/// Project slugs accept up to 1024 bytes of arbitrary UTF-8, but the link +/// format is restricted to `[a-zA-Z0-9._-]{1,64}` (no leading dot, no `..`) +/// so links need no escaping and stay safe to paste. Callers must check +/// before building a link and omit the field when it returns false, rather +/// than emitting a link no client can parse. Mirrors `isValidDtag` in +/// `desktop/src/shared/lib/entityLink.ts`. +pub fn is_linkable_dtag(dtag: &str) -> bool { + !dtag.is_empty() + && dtag.len() <= 64 + && !dtag.starts_with('.') + && !dtag.contains("..") + && dtag + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + /// Build a `buzz://repo` link for a repository announcement (kind 30617). pub fn repo_link(owner: &str, repo_id: &str) -> String { format!("buzz://repo?owner={owner}&d={repo_id}") } +/// Build a `buzz://project` link for a project announcement (kind 30621). +pub fn project_link(owner: &str, project_id: &str) -> String { + format!("buzz://project?owner={owner}&d={project_id}") +} + /// Build a `buzz://pr` link for a pull request event (kind 1618). pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String { format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}") @@ -47,5 +70,26 @@ mod tests { repo_link(OWNER, "buzz-world"), format!("buzz://repo?owner={OWNER}&d=buzz-world") ); + assert_eq!( + project_link(OWNER, "buzz-world"), + format!("buzz://project?owner={OWNER}&d=buzz-world") + ); + } + + #[test] + fn linkable_dtag_matches_the_desktop_charset() { + for ok in ["buzz-world", "a", "a.b_c-d", &"a".repeat(64)] { + assert!(is_linkable_dtag(ok), "{ok:?} should be linkable"); + } + for bad in [ + "", + ".hidden", + "a..b", + "has space", + "sl/ash", + &"a".repeat(65), + ] { + assert!(!is_linkable_dtag(bad), "{bad:?} should not be linkable"); + } } } diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc3..0dbade64e 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -163,6 +163,71 @@ fn parse_join_deep_link(url: &Url) -> Option { })) } +/// Hosts of the `buzz://` git-entity links built by +/// `desktop/src/shared/lib/entityLink.ts` and `crates/buzz-cli/src/links.rs`. +const ENTITY_LINK_HOSTS: [&str; 4] = ["repo", "project", "pr", "issue"]; + +fn is_hex64(value: &str) -> bool { + value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Mirrors `isValidDtag` in `entityLink.ts` — the link format addresses a +/// narrower d-tag charset than Nostr allows. +fn is_linkable_dtag(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + && !value.starts_with('.') + && !value.contains("..") +} + +/// Validate a `buzz://repo|project|pr|issue?…` link and return it verbatim +/// for the frontend, which re-parses it with `parseEntityLink` before +/// navigating. Validating here too keeps a malformed link from raising and +/// focusing the window for a navigation that would then be declined. +/// +/// The canonical-form rules match `parseEntityLink`: no path segments, no +/// fragment, and no parameters beyond `owner`/`d` (plus `id` for event +/// links), so a future extension of the format is declined by old builds +/// rather than silently misread. +fn parse_entity_deep_link(url: &Url) -> Option<()> { + let host = url.host_str()?; + if !ENTITY_LINK_HOSTS.contains(&host) { + return None; + } + if !matches!(url.path(), "" | "/") || url.fragment().is_some() { + return None; + } + + let needs_event_id = host == "pr" || host == "issue"; + let (mut owner, mut dtag, mut id) = (None, None, None); + for (key, value) in url.query_pairs() { + let slot = match key.as_ref() { + "owner" => &mut owner, + "d" => &mut dtag, + "id" if needs_event_id => &mut id, + _ => return None, + }; + if slot.is_some() { + return None; + } + *slot = Some(value.into_owned()); + } + + if !owner.is_some_and(|owner| is_hex64(&owner)) { + return None; + } + if !dtag.is_some_and(|dtag| is_linkable_dtag(&dtag)) { + return None; + } + if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) { + return None; + } + Some(()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct AddCommunityDeepLinkPayload { @@ -295,6 +360,7 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result` — emits `deep-link-connect` to the frontend +/// - `buzz://repo|project|pr|issue?…` — emits `deep-link-entity` to the frontend pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, @@ -366,6 +432,19 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { activate_main_window(app); let _ = app.emit("deep-link-message", payload); } + Some("repo" | "project" | "pr" | "issue") => { + // `buzz://repo|project?owner=&d=` and + // `buzz://pr|issue?id=&owner=&d=` — the + // share links copied from the Projects UI. The frontend owns + // routing (`useEntityDeepLinks`), so the validated URL is + // forwarded unchanged. + if parse_entity_deep_link(&url).is_none() { + eprintln!("buzz-desktop: malformed entity deep link: {url_str}"); + return; + } + activate_main_window(app); + let _ = app.emit("deep-link-entity", url_str.to_owned()); + } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { Ok(payload) => { activate_main_window(app); @@ -389,10 +468,56 @@ mod tests { use url::Url; use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + parse_add_community_deep_link, parse_entity_deep_link, parse_join_deep_link, + parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, + PendingCommunityDeepLinks, }; + const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; + const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + + #[test] + fn parse_entity_deep_link_accepts_every_share_link_shape() { + for raw in [ + format!("buzz://repo?owner={OWNER}&d=buzz-world"), + format!("buzz://project?owner={OWNER}&d=buzz-world"), + format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world"), + format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world"), + ] { + assert!( + parse_entity_deep_link(&Url::parse(&raw).unwrap()).is_some(), + "{raw}" + ); + } + } + + #[test] + fn parse_entity_deep_link_rejects_malformed_and_non_canonical_links() { + for raw in [ + // Missing or malformed identifiers. + format!("buzz://repo?owner={OWNER}"), + "buzz://repo?owner=nope&d=buzz-world".to_owned(), + format!("buzz://repo?owner={OWNER}&d=.hidden"), + format!("buzz://repo?owner={OWNER}&d=has%20space"), + format!("buzz://pr?owner={OWNER}&d=buzz-world"), + format!("buzz://pr?id=short&owner={OWNER}&d=buzz-world"), + // Coordinate links take no event id. + format!("buzz://repo?id={EVENT_ID}&owner={OWNER}&d=buzz-world"), + // Non-canonical: unknown param, duplicate param, path, fragment. + format!("buzz://repo?owner={OWNER}&d=buzz-world&relay=wss%3A%2F%2Fx.example"), + format!("buzz://repo?owner={OWNER}&owner={OWNER}&d=buzz-world"), + format!("buzz://repo/extra?owner={OWNER}&d=buzz-world"), + format!("buzz://repo?owner={OWNER}&d=buzz-world#top"), + // Not an entity host. + format!("buzz://message?owner={OWNER}&d=buzz-world"), + ] { + assert!( + parse_entity_deep_link(&Url::parse(&raw).unwrap()).is_none(), + "{raw}" + ); + } + } + fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { PendingCommunityDeepLink { id: id.to_owned(), diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e4233a8fa..29a9b4bea 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -90,7 +90,7 @@ import { joinChannel } from "@/shared/api/tauri"; import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; -import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; +import { useAppDeepLinks } from "@/shared/useAppDeepLinks"; import { SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -634,8 +634,8 @@ export function AppShell() { unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links only from the main window; the companion is dedicated to its active Huddle route. - useMessageDeepLinks(!isHuddleRoom); + // Dispatch `buzz://` deep links only from the main window; the companion is dedicated to its active Huddle route. + useAppDeepLinks(!isHuddleRoom); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], diff --git a/desktop/src/features/projects/lib/projectShareLinks.test.mjs b/desktop/src/features/projects/lib/projectShareLinks.test.mjs new file mode 100644 index 000000000..a0424166b --- /dev/null +++ b/desktop/src/features/projects/lib/projectShareLinks.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + issueShareLink, + parseAddressableCoordinate, + projectShareLink, + pullRequestShareLink, + repositoryShareLink, +} from "./projectShareLinks.ts"; + +const OWNER = "a".repeat(64); +const EVENT_ID = "b".repeat(64); +const REPO_ADDRESS = `30617:${OWNER}:flappy-bee`; +const PROJECT_ADDRESS = `30621:${OWNER}:pollinator`; + +test("parseAddressableCoordinate splits only the two structural separators", () => { + assert.deepEqual(parseAddressableCoordinate(`30617:${OWNER}:a:b`), { + kind: 30617, + owner: OWNER, + dtag: "a:b", + }); + assert.deepEqual( + parseAddressableCoordinate(`30617:${OWNER.toUpperCase()}:repo`)?.owner, + OWNER, + ); +}); + +test("parseAddressableCoordinate rejects malformed coordinates", () => { + for (const address of [ + null, + undefined, + "", + OWNER, + `30617:${OWNER}`, + `30617:not-a-pubkey:repo`, + `30617:${OWNER}:`, + `:${OWNER}:repo`, + `notakind:${OWNER}:repo`, + ]) { + assert.equal(parseAddressableCoordinate(address), null, String(address)); + } +}); + +test("projectShareLink links explicit projects by their 30621 coordinate", () => { + assert.equal( + projectShareLink({ projectAddress: PROJECT_ADDRESS }), + `buzz://project?owner=${OWNER}&d=pollinator`, + ); +}); + +test("projectShareLink links legacy projects as their backing repository", () => { + assert.equal( + projectShareLink({ projectAddress: REPO_ADDRESS }), + `buzz://repo?owner=${OWNER}&d=flappy-bee`, + ); +}); + +test("projectShareLink declines coordinates the link format cannot express", () => { + for (const dtag of [ + "has space", + "..", + ".hidden", + "x".repeat(65), + "emoji🐝", + ]) { + assert.equal( + projectShareLink({ projectAddress: `30621:${OWNER}:${dtag}` }), + null, + dtag, + ); + } + // Some other addressable kind is not a project or repository. + assert.equal(projectShareLink({ projectAddress: `30000:${OWNER}:x` }), null); +}); + +test("repositoryShareLink links the repository coordinate", () => { + assert.equal( + repositoryShareLink({ repoAddress: REPO_ADDRESS }), + `buzz://repo?owner=${OWNER}&d=flappy-bee`, + ); + assert.equal( + repositoryShareLink({ repoAddress: PROJECT_ADDRESS }), + null, + "a project coordinate is not a repository", + ); +}); + +test("issue and pull request links carry the event id and repo coordinate", () => { + assert.equal( + issueShareLink({ id: EVENT_ID, repoAddress: REPO_ADDRESS }), + `buzz://issue?id=${EVENT_ID}&owner=${OWNER}&d=flappy-bee`, + ); + assert.equal( + pullRequestShareLink({ id: EVENT_ID, repoAddress: REPO_ADDRESS }), + `buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=flappy-bee`, + ); +}); + +test("issue and pull request links require a repo coordinate and hex id", () => { + assert.equal(issueShareLink({ id: EVENT_ID, repoAddress: null }), null); + assert.equal(pullRequestShareLink({ id: EVENT_ID, repoAddress: null }), null); + assert.equal( + issueShareLink({ id: "short", repoAddress: REPO_ADDRESS }), + null, + ); + assert.equal( + pullRequestShareLink({ id: "short", repoAddress: REPO_ADDRESS }), + null, + ); +}); diff --git a/desktop/src/features/projects/lib/projectShareLinks.ts b/desktop/src/features/projects/lib/projectShareLinks.ts new file mode 100644 index 000000000..da7cc60e5 --- /dev/null +++ b/desktop/src/features/projects/lib/projectShareLinks.ts @@ -0,0 +1,108 @@ +/** + * Share links for the Projects read models. + * + * Every builder returns `null` instead of throwing when the entity cannot be + * addressed by a `buzz://` link — addressable d-tags accept a wider charset + * (and 1024 bytes) than the link format's `[a-zA-Z0-9._-]{1,64}`, and issues + * and pull requests loaded outside a repository have no coordinate at all. + * Callers hide the share affordance on `null` rather than copying a link that + * would not parse on the receiving side. + */ + +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { + buildIssueLink, + buildProjectLink, + buildPullRequestLink, + buildRepoLink, + isLinkableCoordinate, +} from "@/shared/lib/entityLink"; + +import type { ProjectIssue } from "../projectIssues.mjs"; +import type { Project, Repository } from "../projectModels"; +import type { ProjectPullRequest } from "../projectPullRequests.mjs"; + +type Coordinate = { kind: number; owner: string; dtag: string }; + +const HEX64_RE = /^[a-fA-F0-9]{64}$/; + +/** + * Split an addressable coordinate (`::`). Only the first two + * separators are structural — d-tags may themselves contain colons, so the + * remainder is taken verbatim. + */ +export function parseAddressableCoordinate( + address: string | null | undefined, +): Coordinate | null { + if (!address) return null; + + const kindEnd = address.indexOf(":"); + if (kindEnd < 1) return null; + const ownerEnd = address.indexOf(":", kindEnd + 1); + if (ownerEnd < 0) return null; + + const kind = Number(address.slice(0, kindEnd)); + const owner = address.slice(kindEnd + 1, ownerEnd); + const dtag = address.slice(ownerEnd + 1); + if (!Number.isInteger(kind) || !HEX64_RE.test(owner) || dtag.length === 0) { + return null; + } + + return { kind, owner: owner.toLowerCase(), dtag }; +} + +function repositoryCoordinate( + repoAddress: string | null | undefined, +): Coordinate | null { + const coordinate = parseAddressableCoordinate(repoAddress); + return coordinate?.kind === KIND_REPO_ANNOUNCEMENT ? coordinate : null; +} + +/** + * Link to a project. Legacy (implicit) projects are backed by a repository + * announcement rather than a kind:30621 event, so they share as `buzz://repo` + * — which resolves to the same project route on the receiving side. + */ +export function projectShareLink(project: Project): string | null { + const coordinate = parseAddressableCoordinate(project.projectAddress); + if (!coordinate || !isLinkableCoordinate(coordinate.owner, coordinate.dtag)) { + return null; + } + + if (coordinate.kind === KIND_PROJECT_ANNOUNCEMENT) { + return buildProjectLink(coordinate); + } + return coordinate.kind === KIND_REPO_ANNOUNCEMENT + ? buildRepoLink(coordinate) + : null; +} + +export function repositoryShareLink(repository: Repository): string | null { + const coordinate = repositoryCoordinate(repository.repoAddress); + return coordinate && isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildRepoLink(coordinate) + : null; +} + +export function issueShareLink(issue: ProjectIssue): string | null { + const coordinate = repositoryCoordinate(issue.repoAddress); + return coordinate && + HEX64_RE.test(issue.id) && + isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildIssueLink({ ...coordinate, id: issue.id }) + : null; +} + +export function pullRequestShareLink( + pullRequest: ProjectPullRequest, +): string | null { + const coordinate = repositoryCoordinate(pullRequest.repoAddress); + return coordinate && + HEX64_RE.test(pullRequest.id) && + isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildPullRequestLink({ ...coordinate, id: pullRequest.id }) + : null; +} diff --git a/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx b/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx new file mode 100644 index 000000000..53d564dba --- /dev/null +++ b/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx @@ -0,0 +1,37 @@ +import { Link2 } from "lucide-react"; + +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; + +/** + * "Copy link" row for the Projects action menus. Renders nothing when the + * entity has no shareable coordinate (see `lib/projectShareLinks`) so we never + * offer a link that would fail to parse for the recipient. + */ +export function CopyShareLinkMenuItem({ + label = "Copy link", + link, + successMessage = "Link copied to clipboard", + testId, +}: { + label?: string; + link: string | null; + successMessage?: string; + testId?: string; +}) { + if (!link) return null; + + return ( + { + event.preventDefault(); + event.stopPropagation(); + copyTextToClipboard(link, successMessage); + }} + > + + {label} + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 09535fdfc..c46a800d3 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -24,6 +24,7 @@ import { relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; +import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal"; import { PROJECT_LIST_ROW_CLASS, @@ -50,6 +51,7 @@ import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; function ProjectUpdatedLabel({ @@ -395,6 +397,10 @@ function ProjectActionsMenu({ return ( + { event.preventDefault(); diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index ddd0106d9..d065b9e14 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -2,9 +2,11 @@ import { ChevronRight, FolderGit2, MessageSquare } from "lucide-react"; import type * as React from "react"; import type { Project } from "@/features/projects/hooks"; +import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; import { channelChrome, topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { ShareLinkButton } from "./ShareLinkButton"; export type ProjectDetailWorkItemCrumb = { category: string; @@ -106,17 +108,24 @@ export function ProjectDetailChrome({ )} - {project.projectChannelId ? ( - - ) : null} +
+ + {project.projectChannelId ? ( + + ) : null} +
); diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 6f34248dd..b00aa798e 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -13,6 +13,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { issueShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ChannelMember } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -27,6 +28,7 @@ import { ProjectOriginReference } from "./ProjectOriginReference"; import { OverviewRailSection } from "./ProjectOverviewPanel"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; +import { ShareLinkButton } from "./ShareLinkButton"; export function issueStatusClassName(status: ProjectIssue["status"]) { if (status === "Done") return "text-purple-400"; @@ -194,21 +196,28 @@ export function ProjectIssueDetail({ >
-
-

- - Issue from {authorLabel} - -

-

- {issue.title}{" "} - - #{issue.id.slice(0, 8)} - -

+
+
+

+ + Issue from {authorLabel} + +

+

+ {issue.title}{" "} + + #{issue.id.slice(0, 8)} + +

+
+
{issue.content ? ( diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index 54e3689e4..2aac1d989 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -26,6 +26,7 @@ import { useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; import { projectPullRequestCommentTimelineKind } from "@/features/projects/projectPullRequests.mjs"; +import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks"; import { formatExactTimestamp, relativeTime, @@ -51,6 +52,7 @@ import { import { ProjectRichContent } from "./ProjectRichContent"; import { PullRequestReviewersRow } from "./PullRequestReviewersRow"; import { PullRequestReviewCard } from "./PullRequestReviewCard"; +import { ShareLinkButton } from "./ShareLinkButton"; function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) { return profiles?.[normalizePubkey(pubkey)] ?? null; @@ -333,12 +335,19 @@ export function PullRequestDetailHeader({ return (
-

- {pullRequest.title}{" "} - - #{pullRequest.id.slice(0, 8)} - -

+
+

+ {pullRequest.title}{" "} + + #{pullRequest.id.slice(0, 8)} + +

+ +

diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index f3f5f5aa4..891642412 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -6,6 +6,7 @@ import type { ProjectIssueListItem, Repository, } from "@/features/projects/hooks"; +import { issueShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems"; import { @@ -16,6 +17,7 @@ import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; +import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity"; import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; @@ -233,6 +235,10 @@ function IssueListRow({ {nextStepLabel(issue.status)} +

diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index 7ebfcf685..43c3f1322 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -6,6 +6,7 @@ import type { ProjectPullRequestListItem, Repository, } from "@/features/projects/hooks"; +import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems"; import { cn } from "@/shared/lib/cn"; @@ -16,6 +17,7 @@ import { import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; +import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity"; import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; @@ -226,6 +228,10 @@ function PullRequestListRow({ {nextStepLabel(pullRequest.status)}
+
diff --git a/desktop/src/features/projects/ui/RepositoryCards.tsx b/desktop/src/features/projects/ui/RepositoryCards.tsx index 575e4f355..72af069ec 100644 --- a/desktop/src/features/projects/ui/RepositoryCards.tsx +++ b/desktop/src/features/projects/ui/RepositoryCards.tsx @@ -13,6 +13,7 @@ import { projectRepoHostForRepository, repositoryDisplayPath, } from "@/features/projects/lib/projectRepoHost"; +import { repositoryShareLink } from "@/features/projects/lib/projectShareLinks"; import { formatExactTimestamp, relativeTime, @@ -36,6 +37,7 @@ import { ProjectPeopleStack, ProjectStatsRow, } from "./ProjectCards"; +import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; import { GitHubMark } from "./GitHubMark"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { projectTerminalLabel } from "./useOpenProjectTerminal"; @@ -180,6 +182,10 @@ function RepositoryActionsMenu({ }: Pick) { return ( + { event.preventDefault(); diff --git a/desktop/src/features/projects/ui/ShareLinkButton.tsx b/desktop/src/features/projects/ui/ShareLinkButton.tsx new file mode 100644 index 000000000..753cfb97f --- /dev/null +++ b/desktop/src/features/projects/ui/ShareLinkButton.tsx @@ -0,0 +1,68 @@ +import { Check, Link2 } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +/** + * Copies a `buzz://` share link for the surrounding entity. Renders nothing + * when `link` is null — see `lib/projectShareLinks` for when an entity has no + * shareable coordinate. + */ +export function ShareLinkButton({ + className, + label = "Copy link", + link, + testId, +}: { + className?: string; + label?: string; + link: string | null; + testId?: string; +}) { + const [copied, setCopied] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + + React.useEffect( + () => () => { + if (timeoutRef.current !== null) clearTimeout(timeoutRef.current); + }, + [], + ); + + const handleCopy = React.useCallback(() => { + if (!link) return; + void writeTextToClipboard(link).then(() => { + setCopied(true); + if (timeoutRef.current !== null) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), 2_000); + }); + }, [link]); + + if (!link) return null; + + return ( + + + + + {copied ? "Link copied" : label} + + ); +} diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c62a8bec3..2a1801ec5 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -165,6 +165,19 @@ export function listenForMessageDeepLinks( }); } +/** + * Register a listener for `deep-link-entity` events — the `buzz://` share + * links for projects, repositories, issues, and pull requests. The payload is + * the raw URL; callers parse it with `parseEntityLink` before navigating. + */ +export function listenForEntityDeepLinks( + onOpen: (href: string) => void, +): Promise { + return listen("deep-link-entity", (event) => { + onOpen(event.payload); + }); +} + export function listenForNostrBindDeepLinks( onOpen: (payload: NostrBindDeepLinkPayload) => void, ): Promise { diff --git a/desktop/src/shared/lib/entityLink.test.mjs b/desktop/src/shared/lib/entityLink.test.mjs index 729006f47..d6f0e5d71 100644 --- a/desktop/src/shared/lib/entityLink.test.mjs +++ b/desktop/src/shared/lib/entityLink.test.mjs @@ -3,10 +3,12 @@ import test from "node:test"; import { buildIssueLink, + buildProjectLink, buildPullRequestLink, buildRepoLink, entityLinkProjectRouteId, isEntityLink, + isLinkableCoordinate, parseEntityLink, } from "./entityLink.ts"; @@ -30,6 +32,10 @@ test("builders emit the canonical cross-language link format", () => { buildRepoLink({ owner: OWNER, dtag: "buzz-world" }), `buzz://repo?owner=${OWNER}&d=buzz-world`, ); + assert.equal( + buildProjectLink({ owner: OWNER, dtag: "buzz-world" }), + `buzz://project?owner=${OWNER}&d=buzz-world`, + ); }); test("builders reject invalid identifiers", () => { @@ -59,6 +65,12 @@ test("parseEntityLink round-trips built links", () => { ok: true, value: { type: "repo", owner: OWNER, dtag: "buzz-world" }, }); + + const projectLink = buildProjectLink({ owner: OWNER, dtag: "buzz-world" }); + assert.deepEqual(parseEntityLink(projectLink), { + ok: true, + value: { type: "project", owner: OWNER, dtag: "buzz-world" }, + }); }); test("parseEntityLink lowercase-normalizes hex identifiers", () => { @@ -91,6 +103,7 @@ test("isEntityLink matches entity hosts and excludes message links", () => { assert.equal(isEntityLink(`buzz://pr?id=${EVENT_ID}`), true); assert.equal(isEntityLink(`buzz://issue?id=${EVENT_ID}`), true); assert.equal(isEntityLink(`buzz://repo?owner=${OWNER}`), true); + assert.equal(isEntityLink(`buzz://project?owner=${OWNER}`), true); assert.equal(isEntityLink("buzz://message?channel=x&id=y"), false); assert.equal(isEntityLink("https://github.com/block/buzz"), false); assert.equal(isEntityLink(null), false); @@ -107,6 +120,27 @@ test("entityLinkProjectRouteId emits the canonical 30617 coordinate route id", ( ); }); +test("entityLinkProjectRouteId routes project links to the 30621 coordinate", () => { + const parsed = parseEntityLink( + buildProjectLink({ owner: OWNER, dtag: "buzz-world" }), + ); + assert.ok(parsed.ok); + assert.equal( + entityLinkProjectRouteId(parsed.value), + `30621:${OWNER}:buzz-world`, + ); +}); + +test("isLinkableCoordinate gates coordinates the link format cannot express", () => { + assert.equal(isLinkableCoordinate(OWNER, "buzz-world"), true); + assert.equal(isLinkableCoordinate(OWNER, "a".repeat(64)), true); + // Addressable d-tags allow far more than the link charset does. + assert.equal(isLinkableCoordinate(OWNER, "a".repeat(65)), false); + assert.equal(isLinkableCoordinate(OWNER, "has space"), false); + assert.equal(isLinkableCoordinate(OWNER, ".hidden"), false); + assert.equal(isLinkableCoordinate("not-a-pubkey", "buzz-world"), false); +}); + test("parseEntityLink rejects noncanonical extras", () => { // Unexpected path segments — reserved for future versioning. assert.deepEqual( diff --git a/desktop/src/shared/lib/entityLink.ts b/desktop/src/shared/lib/entityLink.ts index 4ab8a78fb..15174c29f 100644 --- a/desktop/src/shared/lib/entityLink.ts +++ b/desktop/src/shared/lib/entityLink.ts @@ -4,11 +4,13 @@ * * Formats: * buzz://repo?owner=&d= + * buzz://project?owner=&d= * buzz://pr?id=&owner=&d= * buzz://issue?id=&owner=&d= * * `owner` + `d` identify the NIP-34 repository coordinate - * (`30617::`); `id` is the kind 1618 / 1621 event id. The CLI + * (`30617::`) or the NIP-MP project coordinate + * (`30621::`); `id` is the kind 1618 / 1621 event id. The CLI * builder in `crates/buzz-cli/src/links.rs` emits the same format — the two * must stay compatible (see the golden-format tests on both sides). */ @@ -18,7 +20,8 @@ const ENTITY_LINK_SCHEME = "buzz:"; export type ParsedEntityLink = | { type: "pr"; id: string; owner: string; dtag: string } | { type: "issue"; id: string; owner: string; dtag: string } - | { type: "repo"; owner: string; dtag: string }; + | { type: "repo"; owner: string; dtag: string } + | { type: "project"; owner: string; dtag: string }; export type EntityLinkParseResult = | { ok: true; value: ParsedEntityLink } @@ -36,10 +39,20 @@ function checkCoordinate(owner: string, dtag: string): void { throw new Error("entityLink: owner must be a 64-char hex pubkey"); } if (!isValidDtag(dtag)) { - throw new Error("entityLink: invalid repository d-tag"); + throw new Error("entityLink: invalid addressable d-tag"); } } +/** + * True when a coordinate can be expressed as an entity link. Addressable + * d-tags accept a wider charset than the link format does, so callers that + * build links from read models must check first and hide the share + * affordance rather than surface a builder throw. + */ +export function isLinkableCoordinate(owner: string, dtag: string): boolean { + return HEX64_RE.test(owner) && isValidDtag(dtag); +} + function checkEventId(id: string): void { if (!HEX64_RE.test(id)) { throw new Error("entityLink: id must be a 64-char hex event id"); @@ -52,6 +65,15 @@ export function buildRepoLink(input: { owner: string; dtag: string }): string { return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}`; } +/** Build a `buzz://project` link for a project announcement (kind 30621). */ +export function buildProjectLink(input: { + owner: string; + dtag: string; +}): string { + checkCoordinate(input.owner, input.dtag); + return `buzz://project?owner=${input.owner.toLowerCase()}&d=${input.dtag}`; +} + /** Build a `buzz://pr` link for a pull request event (kind 1618). */ export function buildPullRequestLink(input: { id: string; @@ -84,7 +106,8 @@ export function isEntityLink(href: string | undefined | null): boolean { return ( href.startsWith("buzz://pr?") || href.startsWith("buzz://issue?") || - href.startsWith("buzz://repo?") + href.startsWith("buzz://repo?") || + href.startsWith("buzz://project?") ); } @@ -116,9 +139,15 @@ export function parseEntityLink(url: string): EntityLinkParseResult { } const host = parsed.hostname; - if (host !== "pr" && host !== "issue" && host !== "repo") { + if ( + host !== "pr" && + host !== "issue" && + host !== "repo" && + host !== "project" + ) { return { ok: false, reason: "wrong-host" }; } + const isCoordinateHost = host === "repo" || host === "project"; // Require empty/root path — path segments are reserved for future versioning. if (parsed.pathname !== "" && parsed.pathname !== "/") { @@ -131,9 +160,11 @@ export function parseEntityLink(url: string): EntityLinkParseResult { } // Validate known params and reject unknown ones, and enforce single-instance. - const KNOWN_REPO_PARAMS = new Set(["owner", "d"]); + const KNOWN_COORDINATE_PARAMS = new Set(["owner", "d"]); const KNOWN_EVENT_PARAMS = new Set(["id", "owner", "d"]); - const knownParams = host === "repo" ? KNOWN_REPO_PARAMS : KNOWN_EVENT_PARAMS; + const knownParams = isCoordinateHost + ? KNOWN_COORDINATE_PARAMS + : KNOWN_EVENT_PARAMS; for (const key of parsed.searchParams.keys()) { if (!knownParams.has(key)) { @@ -156,10 +187,10 @@ export function parseEntityLink(url: string): EntityLinkParseResult { return { ok: false, reason: "invalid-dtag" }; } - if (host === "repo") { + if (host === "repo" || host === "project") { return { ok: true, - value: { type: "repo", owner: owner.toLowerCase(), dtag }, + value: { type: host, owner: owner.toLowerCase(), dtag }, }; } @@ -180,15 +211,19 @@ export function parseEntityLink(url: string): EntityLinkParseResult { } /** - * Canonical NIP-34 repository coordinate (`30617::`) used as the - * route id for `/projects/$projectId`. Duncan's #4671 branch resolves 30617 - * coordinates regardless of which explicit project contains the repository, so - * entity links remain stable when a repo's container project changes. + * Canonical addressable coordinate used as the route id for + * `/projects/$projectId`: `30621::` for project links, and + * `30617::` for repository-scoped links (repo, PR, issue). + * + * `projectMatchesRouteId` resolves a 30617 coordinate regardless of which + * explicit project contains the repository, so entity links remain stable + * when a repo's container project changes. * * Do NOT use the legacy `:` form — it only matched implicit * project cards and breaks for repos claimed by an explicit project with a * different d-tag. */ export function entityLinkProjectRouteId(link: ParsedEntityLink): string { - return `30617:${link.owner}:${link.dtag}`; + const kind = link.type === "project" ? 30621 : 30617; + return `${kind}:${link.owner}:${link.dtag}`; } diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index 4bf3245db..31c465496 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -178,17 +178,42 @@ test("parseSupportedLinkPreview parses buzz:// PR and issue deep links", () => { ); }); +test("parseSupportedLinkPreview parses buzz:// project deep links", () => { + assert.deepEqual( + parseSupportedLinkPreview( + `buzz://project?owner=${BUZZ_OWNER}&d=buzz-world`, + ), + { + kind: "buzz-project", + href: `buzz://project?owner=${BUZZ_OWNER}&d=buzz-world`, + provider: "Buzz", + title: "buzz-world", + typeLabel: "project", + }, + ); +}); + test("parseSupportedLinkPreview rejects malformed buzz:// entity links", () => { for (const href of [ `buzz://pr?owner=${BUZZ_OWNER}&d=buzz-world`, `buzz://pr?id=short&owner=${BUZZ_OWNER}&d=buzz-world`, `buzz://issue?id=${BUZZ_EVENT_ID}&owner=nope&d=buzz-world`, `buzz://repo?owner=${BUZZ_OWNER}&d=.hidden`, + `buzz://project?owner=${BUZZ_OWNER}&d=.hidden`, ]) { assert.equal(parseSupportedLinkPreview(href), null, href); } }); +test("extractSupportedLinkPreviews picks up buzz:// project links in prose", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + `tracking here: buzz://project?owner=${BUZZ_OWNER}&d=buzz-world`, + ).map((preview) => [preview.kind, preview.typeLabel, preview.title]), + [["buzz-project", "project", "buzz-world"]], + ); +}); + test("extractSupportedLinkPreviews picks up buzz:// links in prose", () => { assert.deepEqual( extractSupportedLinkPreviews( diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 58d8739ef..ad25a464f 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -1,5 +1,6 @@ import { buildIssueLink, + buildProjectLink, buildPullRequestLink, buildRepoLink, isEntityLink, @@ -11,6 +12,7 @@ export type SupportedLinkPreviewKind = | "buzz-pull-request" | "buzz-issue" | "buzz-repository" + | "buzz-project" | "github-pull-request" | "github-issue" | "github-repository" @@ -34,6 +36,7 @@ export type SupportedLinkPreview = { | "PR" | "issue" | "repo" + | "project" | "file" | "folder" | "document" @@ -46,9 +49,9 @@ export type SupportedLinkPreview = { // their distinctive path shape (`/git/<64-hex-pubkey>/`) rather than by // hostname, and require an explicit scheme. Generic previews remain HTTPS-only. const SUPPORTED_URL_RE = - /(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; + /(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo|project)\?[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; const MARKDOWN_SUPPORTED_LINK_RE = - /!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; + /!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo|project)\?[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; const MAX_PREVIEWS = 8; type HiddenRange = { @@ -287,14 +290,14 @@ function createPreview( * markdown-label override it must not overwrite. */ export function buzzEntityFallbackTitle(link: ParsedEntityLink): string { - if (link.type === "repo") return link.dtag; + if (link.type === "repo" || link.type === "project") return link.dtag; return `${link.dtag} #${link.id.slice(0, 8)}`; } /** - * Map a `buzz://pr|issue|repo` deep link onto a preview card. The href is - * rebuilt through the canonical builders so equivalent links (case or query - * order variants) dedupe to a single card. + * Map a `buzz://pr|issue|repo|project` deep link onto a preview card. The + * href is rebuilt through the canonical builders so equivalent links (case + * or query order variants) dedupe to a single card. */ function parseBuzzEntityPreview(href: string): SupportedLinkPreview | null { const parsed = parseEntityLink(href); @@ -320,6 +323,15 @@ function parseBuzzEntityPreview(href: string): SupportedLinkPreview | null { typeLabel: "issue", }; } + if (link.type === "project") { + return { + kind: "buzz-project", + href: buildProjectLink(link), + provider: "Buzz", + title, + typeLabel: "project", + }; + } return { kind: "buzz-repository", href: buildRepoLink(link), diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 136f8f4b1..722c54428 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -5,9 +5,11 @@ import { relayClient } from "@/shared/api/relayClient"; import { KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST, + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; -import { parseEntityLink } from "./entityLink"; +import { isEntityLink, parseEntityLink } from "./entityLink"; import { buzzEntityFallbackTitle, type SupportedLinkPreview, @@ -200,17 +202,70 @@ function fetchLinkPreviewMetadata( const metadataLoader = createMetadataLoader({ fetcher: fetchLinkPreviewMetadata, }); +function buzzEntityMetadata( + title: string | null | undefined, + description?: string | null, +): LinkPreviewMetadata | null { + return title + ? { + title, + siteName: "Buzz", + description: description || null, + imageDataUrl: null, + imageDomain: null, + } + : null; +} + +/** + * Resolve the display name of an addressable coordinate (`kind:owner:d`) from + * its announcement event. Returns null when the relay has no such event, which + * drops the card rather than advertising a coordinate nobody can open. + */ +async function loadCoordinateMetadata( + kind: number, + owner: string, + dtag: string, +): Promise { + const events = await relayClient.fetchEvents({ + kinds: [kind], + authors: [owner], + "#d": [dtag], + limit: 1, + }); + const event = events[0]; + if (!event) return null; + + const tag = (name: string) => + event.tags.find((entry) => entry[0] === name)?.[1] || null; + return buzzEntityMetadata(tag("name") ?? dtag, tag("description")); +} + const entityTitleLoader = createMetadataLoader({ fetcher: async (href) => { const parsed = parseEntityLink(href); - if (!parsed.ok || parsed.value.type === "repo") return null; + if (!parsed.ok) return null; - const { id, owner, dtag } = parsed.value; + const link = parsed.value; + if (link.type === "repo") { + return loadCoordinateMetadata( + KIND_REPO_ANNOUNCEMENT, + link.owner, + link.dtag, + ); + } + if (link.type === "project") { + return loadCoordinateMetadata( + KIND_PROJECT_ANNOUNCEMENT, + link.owner, + link.dtag, + ); + } + + const { id, owner, dtag } = link; const expectedCoordinate = `30617:${owner}:${dtag}`; const events = await relayClient.fetchEvents({ - kinds: [ - parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE, - ], + kinds: [link.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE], ids: [id], limit: 1, }); @@ -224,16 +279,7 @@ const entityTitleLoader = createMetadataLoader({ } const subject = event.tags.find((tag) => tag[0] === "subject")?.[1]; - const title = subject || event.content.split("\n")[0] || null; - return title - ? { - title, - siteName: "Buzz", - description: null, - imageDataUrl: null, - imageDomain: null, - } - : null; + return buzzEntityMetadata(subject || event.content.split("\n")[0]); }, }); @@ -260,9 +306,7 @@ type ResolvedMetadataByHref = Record< /** Only auto-generated titles may be replaced; explicit markdown labels win. */ export function shouldResolveTitle(preview: SupportedLinkPreview): boolean { - if (preview.kind !== "buzz-pull-request" && preview.kind !== "buzz-issue") { - return true; - } + if (!isEntityLink(preview.href)) return true; const parsed = parseEntityLink(preview.href); return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value); } diff --git a/desktop/src/shared/useAppDeepLinks.ts b/desktop/src/shared/useAppDeepLinks.ts new file mode 100644 index 000000000..0ac6f1536 --- /dev/null +++ b/desktop/src/shared/useAppDeepLinks.ts @@ -0,0 +1,14 @@ +import { useEntityDeepLinks } from "@/shared/useEntityDeepLinks"; +import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; + +/** + * Subscribe to every deep link that routes inside the app shell — + * `buzz://message` plus the `buzz://repo|project|pr|issue` share links. + * + * Both need the router, so they mount together in `AppShell`; bundling them + * here keeps the shell to one line per concern. + */ +export function useAppDeepLinks(enabled = true) { + useMessageDeepLinks(enabled); + useEntityDeepLinks(enabled); +} diff --git a/desktop/src/shared/useEntityDeepLinks.ts b/desktop/src/shared/useEntityDeepLinks.ts new file mode 100644 index 000000000..4e71ef283 --- /dev/null +++ b/desktop/src/shared/useEntityDeepLinks.ts @@ -0,0 +1,33 @@ +import * as React from "react"; + +import { listenForEntityDeepLinks } from "@/shared/deep-link"; +import { parseEntityLink } from "@/shared/lib/entityLink"; +import { useOpenEntityLink } from "@/shared/ui/markdown/entityLinks"; + +/** + * Subscribe to `buzz://repo|project|pr|issue` deep links emitted by the Tauri + * backend and route them through the same handler that opens entity links + * clicked inside a message, so an OS-opened share link and an in-app one land + * on the same view. + * + * Mirrors `useMessageDeepLinks`: a hook rather than inline shell code so it + * can be tested without the whole shell. + */ +export function useEntityDeepLinks(enabled = true) { + const openEntityLink = useOpenEntityLink(); + + React.useEffect(() => { + if (!enabled) return; + + let cancelled = false; + const unlistenPromise = listenForEntityDeepLinks((href) => { + if (cancelled) return; + const parsed = parseEntityLink(href); + if (parsed.ok) openEntityLink(parsed.value); + }); + return () => { + cancelled = true; + void unlistenPromise.then((fn) => fn()); + }; + }, [enabled, openEntityLink]); +} diff --git a/docs/buzz-entity-links.md b/docs/buzz-entity-links.md index df3203789..68c226de5 100644 --- a/docs/buzz-entity-links.md +++ b/docs/buzz-entity-links.md @@ -5,16 +5,23 @@ Status: **partially implemented**. Done on this branch: - Slice 0 — HTTPS relay git clone URLs (`{relay-origin}/git//`) render as Buzz repository preview cards in chat (`desktop/src/shared/lib/linkPreview.ts`). -- Slice 1 — `buzz://pr|issue|repo` deep links: `entityLink.ts` - builders/parser, preview cards with relay title enrichment, in-timeline - click navigation to `/projects/$projectId`. +- Slice 1 — `buzz://pr|issue|repo|project` deep links: `entityLink.ts` + builders/parser, preview cards with relay title enrichment (repo and + project titles resolve from their announcement events), in-timeline click + navigation to `/projects/$projectId`. +- Slice 2 — OS-level deep links: the `repo`/`project`/`pr`/`issue` hosts in + `desktop/src-tauri/src/deep_link.rs` emit `deep-link-entity`, and + `useEntityDeepLinks` routes them through the same handler as in-timeline + clicks. - Slice 3 (create-command part) — `crates/buzz-cli/src/links.rs`, `link` - output field on `pr open` / `issues create` / `repos create`, base prompt - guidance, cross-language golden-format tests. + output field on `pr open` / `issues create` / `repos create` / + `projects create`, base prompt guidance, cross-language golden-format tests. +- Sharing from the UI — `lib/projectShareLinks.ts` maps the Projects read + models onto links, surfaced as "Copy link" in the project, repository, + issue, and pull request row menus and as a copy button in the project, + issue, and pull request detail headers. -Still unimplemented: OS-level deep links (slice 2), `link` on get commands, -the `buzz://project` scheme (waiting on NIP-MP landing), and the follow-ups -in slice 4. +Still unimplemented: `link` on get commands and the follow-ups in slice 4. ## Problem @@ -154,11 +161,15 @@ a nice-to-have and explicitly deferred to a follow-up. the projects feature so `linkPreview.ts` — also `shared/lib` — can import it without a feature→shared boundary violation): -- `buildRepoLink`, `buildPullRequestLink`, `buildIssueLink` - (`buildProjectLink` deferred with the `project` scheme) +- `buildRepoLink`, `buildProjectLink`, `buildPullRequestLink`, + `buildIssueLink` - `parseEntityLink(url): EntityLinkParseResult` (discriminated union, same shape as `parseMessageLink`) - `isEntityLink(href)` cheap pre-check for the markdown renderer +- `isLinkableCoordinate(owner, dtag)` — addressable d-tags allow a wider + charset (and 1024 bytes) than the link format's + `[a-zA-Z0-9._-]{1,64}`, so callers that build links from read models check + first and hide the share affordance instead of surfacing a builder throw Detection: extend `extractSupportedLinkPreviews` in `linkPreview.ts` with a `buzz://` pattern (new `SupportedLinkPreviewKind` members @@ -184,10 +195,14 @@ unresolved routes at runtime: If resolution fails (entity not visible in this community), show the same kind of toast fallback used for unresolvable message links. -**OS-level**: register `repo` / `project` / `pr` / `issue` hosts in -`desktop/src-tauri/src/deep_link.rs` and dispatch to a new listener hook -(sibling to `useMessageDeepLinks.ts`). This makes links pasted outside Buzz -(e.g. in a terminal or another app) open the desktop app correctly. +**OS-level** *(implemented)*: the `repo` / `project` / `pr` / `issue` hosts +in `desktop/src-tauri/src/deep_link.rs` validate the link's canonical form +(so a malformed link does not raise and focus the window for a navigation +that would then be declined), then emit `deep-link-entity` with the URL +verbatim. `useEntityDeepLinks` — sibling to `useMessageDeepLinks.ts`, mounted +in `AppShell` for the main window only — re-parses it with `parseEntityLink` +and reuses `useOpenEntityLink`, so a link opened from the OS lands on the +same view as one clicked in a message. ## CLI (`buzz-cli`) @@ -241,12 +256,12 @@ No persona changes needed — the base prompt applies to all managed agents. enrichment (with `resetLinkPreviewTitleCache()` wired into `resetCommunityState()`). Unit tests (`entityLink.test.mjs`, extended `linkPreview.test.mjs`). -2. **OS deep links** — `deep_link.rs` + listener hook + `deep-link.ts` - parity tests. +2. **OS deep links** *(done, this branch)* — `deep_link.rs` + + `useEntityDeepLinks` + `deep-link.ts` parity tests. 3. **CLI + agent prompt** *(create commands done, this branch)* — `links.rs` helper, `link` output field on `pr open` / `issues create` / - `repos create`, base prompt paragraph, cross-language golden-format test. - Still open: `link` on the get commands. + `repos create` / `projects create`, base prompt paragraph, cross-language + golden-format test. Still open: `link` on the get commands. 4. **Follow-ups (separate)** — status chips on PR/issue cards, mobile pill/card rendering, web PR/issue routes + HTTPS link recognition, cross-community `relay=` parameter.