feat(desktop): share Projects entities by copying a direct link

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 <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-10 08:01:16 +02:00
parent 5bf78671f4
commit 1bc2105f3e
25 changed files with 897 additions and 104 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message
When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` 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
+33 -9
View File
@@ -108,13 +108,29 @@ fn make_tag(parts: &[&str]) -> Result<Tag, CliError> {
// ── 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`
+44
View File
@@ -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");
}
}
}
+127 -2
View File
@@ -163,6 +163,71 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
}))
}
/// 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<NostrBindDeepLinkPayload, Str
///
/// Currently supports:
/// - `buzz://connect?relay=<ws(s)://...>` — 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=<pubkey>&d=<dtag>` and
// `buzz://pr|issue?id=<eventId>&owner=<pubkey>&d=<dtag>` — 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(),
+3 -3
View File
@@ -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),
[],
@@ -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,
);
});
@@ -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 (`<kind>:<owner>:<d>`). 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;
}
@@ -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 (
<DropdownMenuItem
data-testid={testId}
onSelect={(event) => {
event.preventDefault();
event.stopPropagation();
copyTextToClipboard(link, successMessage);
}}
>
<Link2 className="h-4 w-4" />
{label}
</DropdownMenuItem>
);
}
@@ -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 (
<AlertDialog onOpenChange={setConfirmOpen} open={confirmOpen}>
<ProjectListRowMenu label={`More options for ${project.name}`}>
<CopyShareLinkMenuItem
link={projectShareLink(project)}
testId={`project-copy-link-${project.dtag}`}
/>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
@@ -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({
</span>
)}
</nav>
{project.projectChannelId ? (
<Button
className="h-8 shrink-0 gap-1.5"
onClick={() => onGoChannel(project.projectChannelId as string)}
size="sm"
variant="outline"
>
<MessageSquare className="h-4 w-4" />
Open Discussion
</Button>
) : null}
<div className="flex shrink-0 items-center gap-1.5">
<ShareLinkButton
label="Copy project link"
link={projectShareLink(project)}
testId="project-detail-copy-link"
/>
{project.projectChannelId ? (
<Button
className="h-8 shrink-0 gap-1.5"
onClick={() => onGoChannel(project.projectChannelId as string)}
size="sm"
variant="outline"
>
<MessageSquare className="h-4 w-4" />
Open Discussion
</Button>
) : null}
</div>
</div>
</div>
);
@@ -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({
>
<div className="min-w-0 divide-y divide-border/50">
<header className="space-y-3 p-4">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-muted-foreground">
<CircleDot className="h-3.5 w-3.5" />
Issue from {authorLabel}
<ProjectOriginReference
agentName={issue.originAgentName}
channelId={issue.channelId}
/>
</p>
<h3 className="mt-1 line-clamp-2 text-base font-semibold text-foreground">
{issue.title}{" "}
<span className="font-normal text-muted-foreground">
#{issue.id.slice(0, 8)}
</span>
</h3>
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-xs font-medium text-muted-foreground">
<CircleDot className="h-3.5 w-3.5" />
Issue from {authorLabel}
<ProjectOriginReference
agentName={issue.originAgentName}
channelId={issue.channelId}
/>
</p>
<h3 className="mt-1 line-clamp-2 text-base font-semibold text-foreground">
{issue.title}{" "}
<span className="font-normal text-muted-foreground">
#{issue.id.slice(0, 8)}
</span>
</h3>
</div>
<ShareLinkButton
label="Copy issue link"
link={issueShareLink(issue)}
testId="project-issue-copy-link"
/>
</div>
{issue.content ? (
<ProjectRichContent content={issue.content} tags={issue.tags} />
@@ -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 (
<header className="min-w-0 space-y-1 p-4 pb-4">
<h3 className="line-clamp-2 min-w-0 text-xl font-semibold text-foreground">
{pullRequest.title}{" "}
<span className="font-normal text-muted-foreground">
#{pullRequest.id.slice(0, 8)}
</span>
</h3>
<div className="flex min-w-0 items-start gap-2">
<h3 className="line-clamp-2 min-w-0 flex-1 text-xl font-semibold text-foreground">
{pullRequest.title}{" "}
<span className="font-normal text-muted-foreground">
#{pullRequest.id.slice(0, 8)}
</span>
</h3>
<ShareLinkButton
label="Copy pull request link"
link={pullRequestShareLink(pullRequest)}
testId="project-pull-request-copy-link"
/>
</div>
<p className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
<GitPullRequest className="h-3.5 w-3.5" />
<span className="flex min-w-0 items-center gap-1">
@@ -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({
<Eye className="h-4 w-4" />
{nextStepLabel(issue.status)}
</DropdownMenuItem>
<CopyShareLinkMenuItem
link={issueShareLink(issue)}
testId={`projects-issue-copy-link-${issue.id}`}
/>
</ProjectListRowMenu>
</div>
</div>
@@ -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({
<GitPullRequest className="h-4 w-4" />
{nextStepLabel(pullRequest.status)}
</DropdownMenuItem>
<CopyShareLinkMenuItem
link={pullRequestShareLink(pullRequest)}
testId={`projects-pull-request-copy-link-${pullRequest.id}`}
/>
</ProjectListRowMenu>
</div>
</div>
@@ -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<RepositoryItemProps, "hasLocal" | "onOpenTerminal" | "repository">) {
return (
<ProjectListRowMenu label={`More options for ${repository.name}`}>
<CopyShareLinkMenuItem
link={repositoryShareLink(repository)}
testId={`repository-copy-link-${repository.dtag}`}
/>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
@@ -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<ReturnType<typeof setTimeout> | 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 (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={label}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
data-testid={testId}
onClick={handleCopy}
type="button"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent>{copied ? "Link copied" : label}</TooltipContent>
</Tooltip>
);
}
+13
View File
@@ -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<UnlistenFn> {
return listen<string>("deep-link-entity", (event) => {
onOpen(event.payload);
});
}
export function listenForNostrBindDeepLinks(
onOpen: (payload: NostrBindDeepLinkPayload) => void,
): Promise<UnlistenFn> {
@@ -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(
+49 -14
View File
@@ -4,11 +4,13 @@
*
* Formats:
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>
* buzz://project?owner=<owner-pubkey>&d=<project-dtag>
* buzz://pr?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
* buzz://issue?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
*
* `owner` + `d` identify the NIP-34 repository coordinate
* (`30617:<owner>:<d>`); `id` is the kind 1618 / 1621 event id. The CLI
* (`30617:<owner>:<d>`) or the NIP-MP project coordinate
* (`30621:<owner>:<d>`); `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:<owner>:<dtag>`) 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:<owner>:<dtag>` for project links, and
* `30617:<owner>:<dtag>` 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 `<owner>:<dtag>` 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}`;
}
@@ -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(
+18 -6
View File
@@ -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>/<repo>`) 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),
@@ -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<LinkPreviewMetadata | null> {
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);
}
+14
View File
@@ -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);
}
+33
View File
@@ -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]);
}
+33 -18
View File
@@ -5,16 +5,23 @@ Status: **partially implemented**. Done on this branch:
- Slice 0 — HTTPS relay git clone URLs (`{relay-origin}/git/<pubkey>/<repo>`)
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.