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