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

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`

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");
}
}
}