mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00: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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user