desktop: tab-aware project share links

Copying a project link while on the Pull Request, Issues, Files, Commits,
or Contributors tab now appends &tab=<tab> to the buzz://project|repo
link, and opening such a link lands on that tab instead of the readme
overview. The tab parameter is validated against a fixed allowlist in the
TypeScript parser, the route search schema, and the Tauri deep-link
handler; event links (pr/issue) accept no tab. Chrome repository actions
move to ProjectDetailChromeActions to keep the screen under the size
ratchet.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-11 07:36:28 -04:00
parent 764d6183a1
commit a2e9def5a5
14 changed files with 324 additions and 54 deletions
+6
View File
@@ -8,6 +8,12 @@
//!
//! Callers are expected to validate inputs first (`validate_hex64`,
//! `validate_repo_id`); the identifier charsets need no URL encoding.
//!
//! Coordinate links additionally accept an optional `&tab=<tab>` parameter
//! (`files|commits|issues|prs|contributors`) selecting a workspace tab on
//! the receiving side. The CLI builders emit the canonical no-tab form
//! (overview); the parameter exists for the desktop's tab-aware copy-link
//! button.
/// Whether a d-tag can be expressed in a `buzz://` link.
///
+21 -3
View File
@@ -188,10 +188,15 @@ fn is_linkable_dtag(value: &str) -> bool {
/// navigating. Validating here too keeps a malformed link from raising and
/// focusing the window for a navigation that would then be declined.
///
/// Workspace tabs addressable by `buzz://repo|project` links — mirrors
/// `ENTITY_LINK_TABS` in `entityLink.ts`.
const ENTITY_LINK_TABS: [&str; 5] = ["files", "commits", "issues", "prs", "contributors"];
/// 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.
/// links and the optional `tab` for coordinate 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) {
@@ -202,12 +207,14 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> {
}
let needs_event_id = host == "pr" || host == "issue";
let (mut owner, mut dtag, mut id) = (None, None, None);
let allows_tab = host == "repo" || host == "project";
let (mut owner, mut dtag, mut id, mut tab) = (None, 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,
"tab" if allows_tab => &mut tab,
_ => return None,
};
if slot.is_some() {
@@ -225,6 +232,11 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> {
if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) {
return None;
}
if let Some(tab) = tab {
if !ENTITY_LINK_TABS.contains(&tab.as_str()) {
return None;
}
}
Some(())
}
@@ -481,6 +493,8 @@ mod tests {
for raw in [
format!("buzz://repo?owner={OWNER}&d=buzz-world"),
format!("buzz://project?owner={OWNER}&d=buzz-world"),
format!("buzz://repo?owner={OWNER}&d=buzz-world&tab=prs"),
format!("buzz://project?owner={OWNER}&d=buzz-world&tab=issues"),
format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world"),
format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world"),
] {
@@ -506,6 +520,10 @@ mod tests {
// 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"),
// Unknown tab value, duplicate tab, and tab on an event link.
format!("buzz://repo?owner={OWNER}&d=buzz-world&tab=overview"),
format!("buzz://repo?owner={OWNER}&d=buzz-world&tab=prs&tab=prs"),
format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world&tab=prs"),
format!("buzz://repo/extra?owner={OWNER}&d=buzz-world"),
format!("buzz://repo?owner={OWNER}&d=buzz-world#top"),
// Not an entity host.
@@ -110,6 +110,8 @@ export function useAppNavigation() {
pullRequestId?: string;
issueId?: string;
repositoryId?: string;
/** Workspace tab requested by a share link (link vocabulary). */
tab?: string;
},
) =>
commitNavigation(
@@ -129,6 +131,7 @@ export function useAppNavigation() {
...(behavior?.repositoryId
? { repositoryId: behavior.repositoryId }
: {}),
...(behavior?.tab ? { tab: behavior.tab } : {}),
},
},
behavior,
@@ -2,6 +2,7 @@ import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { usePreviewFeatureWarning } from "@/shared/features";
import { isEntityLinkTab } from "@/shared/lib/entityLink";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const ProjectDetailScreen = React.lazy(async () => {
@@ -21,13 +22,14 @@ export const Route = createFileRoute("/projects/$projectId")({
issueId: typeof search.issueId === "string" ? search.issueId : undefined,
repositoryId:
typeof search.repositoryId === "string" ? search.repositoryId : undefined,
tab: isEntityLinkTab(search.tab) ? search.tab : undefined,
}),
});
function ProjectDetailRouteComponent() {
usePreviewFeatureWarning("projects");
const { projectId } = Route.useParams();
const { commitHash, pullRequestId, issueId, repositoryId } =
const { commitHash, pullRequestId, issueId, repositoryId, tab } =
Route.useSearch();
return (
@@ -38,6 +40,7 @@ function ProjectDetailRouteComponent() {
projectId={projectId}
pullRequestId={pullRequestId}
repositoryId={repositoryId}
tab={tab}
/>
</React.Suspense>
);
@@ -7,6 +7,8 @@ import {
projectShareLink,
pullRequestShareLink,
repositoryShareLink,
shareTabForWorkspaceTab,
workspaceTabForShareTab,
} from "./projectShareLinks.ts";
const OWNER = "a".repeat(64);
@@ -49,6 +51,32 @@ test("projectShareLink links explicit projects by their 30621 coordinate", () =>
);
});
test("projectShareLink carries the active workspace tab for both link kinds", () => {
assert.equal(
projectShareLink({ projectAddress: PROJECT_ADDRESS }, "prs"),
`buzz://project?owner=${OWNER}&d=pollinator&tab=prs`,
);
// Legacy projects share as buzz://repo and keep the tab too.
assert.equal(
projectShareLink({ projectAddress: REPO_ADDRESS }, "issues"),
`buzz://repo?owner=${OWNER}&d=flappy-bee&tab=issues`,
);
});
test("workspace tab ids map onto link tabs and back", () => {
assert.equal(shareTabForWorkspaceTab("prs"), "prs");
assert.equal(shareTabForWorkspaceTab("issues"), "issues");
assert.equal(shareTabForWorkspaceTab("files"), "files");
assert.equal(shareTabForWorkspaceTab("contributors"), "contributors");
// "activity" is the workspace's name for the commit list.
assert.equal(shareTabForWorkspaceTab("activity"), "commits");
assert.equal(workspaceTabForShareTab("commits"), "activity");
assert.equal(workspaceTabForShareTab("prs"), "prs");
// Overview and PR-detail sub-tabs have no link spelling.
assert.equal(shareTabForWorkspaceTab("overview"), undefined);
assert.equal(shareTabForWorkspaceTab("pr-conversation"), undefined);
});
test("projectShareLink links legacy projects as their backing repository", () => {
assert.equal(
projectShareLink({ projectAddress: REPO_ADDRESS }),
@@ -18,6 +18,7 @@ import {
buildProjectLink,
buildPullRequestLink,
buildRepoLink,
type EntityLinkTab,
isLinkableCoordinate,
} from "@/shared/lib/entityLink";
@@ -61,22 +62,51 @@ function repositoryCoordinate(
return coordinate?.kind === KIND_REPO_ANNOUNCEMENT ? coordinate : null;
}
/**
* Map a workspace tab id (`WorkspaceTabs` vocabulary) onto the link format's
* tab value. The overview tab is the link's default and PR-detail sub-tabs
* have their own `buzz://pr` links, so both map to `undefined` (no tab).
*/
export function shareTabForWorkspaceTab(
workspaceTab: string,
): EntityLinkTab | undefined {
switch (workspaceTab) {
case "files":
case "issues":
case "prs":
case "contributors":
return workspaceTab;
case "activity":
return "commits";
default:
return undefined;
}
}
/** Inverse of `shareTabForWorkspaceTab`, for the receiving side. */
export function workspaceTabForShareTab(tab: EntityLinkTab): string {
return tab === "commits" ? "activity" : tab;
}
/**
* 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 {
export function projectShareLink(
project: Project,
tab?: EntityLinkTab,
): 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 buildProjectLink({ ...coordinate, tab });
}
return coordinate.kind === KIND_REPO_ANNOUNCEMENT
? buildRepoLink(coordinate)
? buildRepoLink({ ...coordinate, tab })
: null;
}
@@ -5,6 +5,7 @@ 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 type { EntityLinkTab } from "@/shared/lib/entityLink";
import { Button } from "@/shared/ui/button";
import { ShareLinkButton } from "./ShareLinkButton";
@@ -23,6 +24,7 @@ export function ProjectDetailChrome({
onGoProjectHome,
onGoProjects,
project,
shareTab,
}: {
/** Repository-scoped controls, rendered left of the project-wide ones. */
actions?: React.ReactNode;
@@ -33,6 +35,11 @@ export function ProjectDetailChrome({
onGoProjectHome: () => void;
onGoProjects: () => void;
project: Project;
/**
* Workspace tab the copied link should open (`undefined` = overview), so
* sharing from the PR or issue list lands recipients on that same list.
*/
shareTab?: EntityLinkTab;
}) {
return (
<div
@@ -115,7 +122,7 @@ export function ProjectDetailChrome({
{actions}
<ShareLinkButton
label="Copy project link"
link={projectShareLink(project)}
link={projectShareLink(project, shareTab)}
testId="project-detail-copy-link"
/>
{project.projectChannelId ? (
@@ -0,0 +1,53 @@
import { ExternalLink } from "lucide-react";
import type { Project, Repository } from "@/features/projects/hooks";
import { Button } from "@/shared/ui/button";
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
/**
* Repository-scoped controls for the project detail chrome: the repository
* picker (with access management) plus, when the repository has a browsable
* web page, an external-link button. Extracted from `ProjectDetailScreen` to
* keep the screen under the file-size ratchet.
*/
export function ProjectDetailChromeActions({
identityPubkey,
onRepositoryChange,
project,
projects,
repository,
webUrl,
}: {
identityPubkey?: string;
onRepositoryChange: (repositoryId: string) => void;
project: Project;
projects: Project[];
repository: Repository;
/** Browsable repository web page, already gated by the caller. */
webUrl: string | null;
}) {
return (
<>
<ProjectRepositoryManagement
identityPubkey={identityPubkey}
onChange={onRepositoryChange}
project={project}
projects={projects}
repository={repository}
/>
{webUrl ? (
<Button
asChild
aria-label="Open project web page"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
size="icon"
variant="ghost"
>
<a href={webUrl} rel="noopener noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
) : null}
</>
);
}
@@ -1,4 +1,4 @@
import { ArrowLeft, ExternalLink, FolderGit2 } from "lucide-react";
import { ArrowLeft, FolderGit2 } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
@@ -60,8 +60,13 @@ import {
resolveProjectDefaultBranch,
} from "@/features/projects/lib/projectBranches";
import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers";
import {
shareTabForWorkspaceTab,
workspaceTabForShareTab,
} from "@/features/projects/lib/projectShareLinks";
import { selectProjectRepository } from "@/features/projects/projectModels";
import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds";
import type { EntityLinkTab } from "@/shared/lib/entityLink";
import { useProjectRepoPresentation } from "@/features/projects/useProjectRepoHost";
import { WorkspaceTabs } from "./ProjectWorkspaceTabs";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
@@ -73,7 +78,7 @@ import {
import type { CreateIssueDialogInput } from "./CreateIssueDialog";
import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs";
import { ProjectDetailChrome } from "./ProjectDetailChrome";
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
import { ProjectDetailChromeActions } from "./ProjectDetailChromeActions";
import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories";
import {
PROJECT_TAB_CRUMB_LABELS,
@@ -88,6 +93,8 @@ type ProjectDetailScreenProps = {
pullRequestId?: string;
issueId?: string;
repositoryId?: string;
/** Workspace tab requested by a share link (link vocabulary). */
tab?: EntityLinkTab;
};
const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [
@@ -103,7 +110,8 @@ const PROJECT_REPOSITORY_SEARCH_KEYS = [
] as const;
export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const { commitHash, projectId, pullRequestId, issueId, repositoryId } = props;
const { commitHash, projectId, pullRequestId, issueId, repositoryId, tab } =
props;
const { goChannel, goProject, goProjects } = useAppNavigation();
const { activeCommunity } = useCommunities();
const mainInsetRef = useMainInsetRef();
@@ -179,6 +187,13 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
// Bumped when breadcrumb navigation should land on the project Overview
// tab; remounts WorkspaceTabs, which owns the selected-tab state.
const [tabsResetKey, setTabsResetKey] = React.useState(0);
// Tab requested by a share link (`?tab=`), mirrored into local state like
// the work-item ids above so breadcrumb/repository resets can drop it —
// WorkspaceTabs remounts must not re-apply a stale link tab.
const [requestedTab, setRequestedTab] = React.useState<
EntityLinkTab | undefined
>(tab);
React.useEffect(() => setRequestedTab(tab), [tab]);
// Mirror of the WorkspaceTabs selection so the breadcrumb can name the
// active sub-tab. The Overview (readme) tab is "home" and gets no crumb.
const [activeTab, setActiveTab] = React.useState("overview");
@@ -814,6 +829,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
setSelectedPullRequestId(null);
setSelectedIssueId(null);
setSelectedCommitHash(null);
setRequestedTab(undefined);
// Remount the workspace tabs so the project page opens on Overview
// instead of whatever tab the work item left behind.
setTabsResetKey((key) => key + 1);
@@ -828,6 +844,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
setSelectedPullRequestId(null);
setSelectedIssueId(null);
setSelectedCommitHash(null);
setRequestedTab(undefined);
setRepoSource("remote");
setTabsResetKey((key) => key + 1);
};
@@ -844,34 +861,20 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ProjectDetailChrome
actions={
<>
<ProjectRepositoryManagement
identityPubkey={identityQuery.data?.pubkey}
onChange={handleRepositoryChange}
project={project}
projects={projectsQuery.data ?? []}
repository={repository}
/>
{repoRemote.webUrl &&
(repoRemote.host.kind !== "external" ||
repoSource === "local") ? (
<Button
asChild
aria-label="Open project web page"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
size="icon"
variant="ghost"
>
<a
href={repoRemote.webUrl}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
) : null}
</>
<ProjectDetailChromeActions
identityPubkey={identityQuery.data?.pubkey}
onRepositoryChange={handleRepositoryChange}
project={project}
projects={projectsQuery.data ?? []}
repository={repository}
webUrl={
repoRemote.webUrl &&
(repoRemote.host.kind !== "external" ||
repoSource === "local")
? repoRemote.webUrl
: null
}
/>
}
activeTabCrumb={activeTabCrumb}
activeWorkItemCrumb={activeWorkItemCrumb}
@@ -884,12 +887,22 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
void goProjects();
}}
project={project}
shareTab={
activeWorkItemCrumb
? undefined
: shareTabForWorkspaceTab(activeTab)
}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4">
<div className="w-full space-y-3 pt-[calc(var(--buzz-channel-content-top-padding,5.75rem)_+_1px)]">
<WorkspaceTabs
key={`${project.id}:${repository.id}:${tabsResetKey}`}
initialTab={
requestedTab
? workspaceTabForShareTab(requestedTab)
: undefined
}
commitDiff={commitDiffQuery.data}
commitDiffError={commitDiffQuery.error}
commitDiffLoading={commitDiffQuery.isLoading}
@@ -126,6 +126,7 @@ export function WorkspaceTabs({
createIssueAction,
createPullRequestAction,
updatePullRequestAction,
initialTab,
localSnapshot,
localSnapshotError,
localSnapshotLoading,
@@ -164,6 +165,8 @@ export function WorkspaceTabs({
createIssueAction: CreateIssueAction;
createPullRequestAction?: CreatePullRequestAction;
updatePullRequestAction?: UpdatePullRequestAction;
/** Tab to open on mount (workspace vocabulary), e.g. from a share link. */
initialTab?: string;
localSnapshot: ProjectLocalRepoSnapshot | null | undefined;
localSnapshotError: unknown;
localSnapshotLoading: boolean;
@@ -252,7 +255,14 @@ export function WorkspaceTabs({
[pullRequests, selectedCommitHash],
);
const isPullRequestSelected = Boolean(selectedPullRequest);
const [selectedTab, setSelectedTab] = React.useState("overview");
const [selectedTab, setSelectedTab] = React.useState(
initialTab ?? "overview",
);
// Follow later share-link navigations to the same project (the search
// param changes without a remount).
React.useEffect(() => {
if (initialTab) setSelectedTab(initialTab);
}, [initialTab]);
const [pullRequestCommentTarget, setPullRequestCommentTarget] =
React.useState<{
anchor: ProjectPullRequestCommentAnchor;
@@ -131,6 +131,49 @@ test("entityLinkProjectRouteId routes project links to the 30621 coordinate", ()
);
});
test("coordinate links carry an optional workspace tab", () => {
const link = buildProjectLink({
owner: OWNER,
dtag: "buzz-world",
tab: "prs",
});
assert.equal(link, `buzz://project?owner=${OWNER}&d=buzz-world&tab=prs`);
assert.deepEqual(parseEntityLink(link), {
ok: true,
value: { type: "project", owner: OWNER, dtag: "buzz-world", tab: "prs" },
});
const repoLink = buildRepoLink({
owner: OWNER,
dtag: "buzz-world",
tab: "issues",
});
assert.deepEqual(parseEntityLink(repoLink), {
ok: true,
value: { type: "repo", owner: OWNER, dtag: "buzz-world", tab: "issues" },
});
// The default overview has no tab spelling; unknown values are rejected
// rather than silently dropped, and event links accept no tab at all.
assert.throws(() =>
buildRepoLink({ owner: OWNER, dtag: "buzz-world", tab: "overview" }),
);
assert.deepEqual(
parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world&tab=overview`),
{ ok: false, reason: "invalid-tab" },
);
assert.deepEqual(
parseEntityLink(`buzz://repo?owner=${OWNER}&d=buzz-world&tab=`),
{ ok: false, reason: "invalid-tab" },
);
assert.deepEqual(
parseEntityLink(
`buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world&tab=prs`,
),
{ ok: false, reason: "unknown-param" },
);
});
test("isLinkableCoordinate gates coordinates the link format cannot express", () => {
assert.equal(isLinkableCoordinate(OWNER, "buzz-world"), true);
assert.equal(isLinkableCoordinate(OWNER, "a".repeat(64)), true);
+58 -12
View File
@@ -3,25 +3,49 @@
* `features/messages/lib/messageLink.ts` for `buzz://message`.
*
* Formats:
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>
* buzz://project?owner=<owner-pubkey>&d=<project-dtag>
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>[&tab=<tab>]
* buzz://project?owner=<owner-pubkey>&d=<project-dtag>[&tab=<tab>]
* 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>`) 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).
* (`30621:<owner>:<d>`); `id` is the kind 1618 / 1621 event id. The
* optional `tab` on the coordinate links selects a workspace tab (the
* pull-request list, issue list, ) instead of the default readme
* overview. 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).
*/
const ENTITY_LINK_SCHEME = "buzz:";
/**
* Workspace tabs addressable by a coordinate link. The default overview
* (readme) tab has no spelling canonical links omit `tab` entirely.
*/
export const ENTITY_LINK_TABS = [
"files",
"commits",
"issues",
"prs",
"contributors",
] as const;
export type EntityLinkTab = (typeof ENTITY_LINK_TABS)[number];
export function isEntityLinkTab(value: unknown): value is EntityLinkTab {
return (
typeof value === "string" &&
(ENTITY_LINK_TABS as readonly string[]).includes(value)
);
}
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: "project"; owner: string; dtag: string };
| { type: "repo"; owner: string; dtag: string; tab?: EntityLinkTab }
| { type: "project"; owner: string; dtag: string; tab?: EntityLinkTab };
export type EntityLinkParseResult =
| { ok: true; value: ParsedEntityLink }
@@ -59,19 +83,32 @@ function checkEventId(id: string): void {
}
}
function tabSuffix(tab: EntityLinkTab | undefined): string {
if (tab === undefined) return "";
if (!isEntityLinkTab(tab)) {
throw new Error("entityLink: unknown workspace tab");
}
return `&tab=${tab}`;
}
/** Build a `buzz://repo` link for a repository announcement (kind 30617). */
export function buildRepoLink(input: { owner: string; dtag: string }): string {
export function buildRepoLink(input: {
owner: string;
dtag: string;
tab?: EntityLinkTab;
}): string {
checkCoordinate(input.owner, input.dtag);
return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}`;
return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}${tabSuffix(input.tab)}`;
}
/** Build a `buzz://project` link for a project announcement (kind 30621). */
export function buildProjectLink(input: {
owner: string;
dtag: string;
tab?: EntityLinkTab;
}): string {
checkCoordinate(input.owner, input.dtag);
return `buzz://project?owner=${input.owner.toLowerCase()}&d=${input.dtag}`;
return `buzz://project?owner=${input.owner.toLowerCase()}&d=${input.dtag}${tabSuffix(input.tab)}`;
}
/** Build a `buzz://pr` link for a pull request event (kind 1618). */
@@ -160,7 +197,7 @@ export function parseEntityLink(url: string): EntityLinkParseResult {
}
// Validate known params and reject unknown ones, and enforce single-instance.
const KNOWN_COORDINATE_PARAMS = new Set(["owner", "d"]);
const KNOWN_COORDINATE_PARAMS = new Set(["owner", "d", "tab"]);
const KNOWN_EVENT_PARAMS = new Set(["id", "owner", "d"]);
const knownParams = isCoordinateHost
? KNOWN_COORDINATE_PARAMS
@@ -188,9 +225,18 @@ export function parseEntityLink(url: string): EntityLinkParseResult {
}
if (host === "repo" || host === "project") {
const tab = parsed.searchParams.get("tab");
if (tab !== null && !isEntityLinkTab(tab)) {
return { ok: false, reason: "invalid-tab" };
}
return {
ok: true,
value: { type: host, owner: owner.toLowerCase(), dtag },
value: {
type: host,
owner: owner.toLowerCase(),
dtag,
...(tab !== null && isEntityLinkTab(tab) ? { tab } : {}),
},
};
}
@@ -24,6 +24,9 @@ export function useOpenEntityLink(): (link: ParsedEntityLink) => void {
void goProject(entityLinkProjectRouteId(link), {
...(link.type === "pr" ? { pullRequestId: link.id } : {}),
...(link.type === "issue" ? { issueId: link.id } : {}),
...((link.type === "repo" || link.type === "project") && link.tab
? { tab: link.tab }
: {}),
});
},
[goProject],
+9 -2
View File
@@ -78,8 +78,8 @@ free.
Extend the existing `buzz://` scheme, mirroring `buzz://message`:
```
buzz://repo?owner=<pubkey-hex>&d=<repo-dtag>
buzz://project?owner=<pubkey-hex>&d=<project-dtag>
buzz://repo?owner=<pubkey-hex>&d=<repo-dtag>[&tab=<tab>]
buzz://project?owner=<pubkey-hex>&d=<project-dtag>[&tab=<tab>]
buzz://pr?id=<event-id-hex>&owner=<pubkey-hex>&d=<repo-dtag>
buzz://issue?id=<event-id-hex>&owner=<pubkey-hex>&d=<repo-dtag>
```
@@ -89,6 +89,13 @@ buzz://issue?id=<event-id-hex>&owner=<pubkey-hex>&d=<repo-dtag>
- `d` is the addressable `d`-tag. For `repo`/`project` links the
(`owner`, `d`) pair is the full `30617:<owner>:<d>` /
`30621:<owner>:<d>` coordinate.
- `tab` (coordinate links only, optional) selects a workspace tab instead
of the default readme overview: `files`, `commits`, `issues`, `prs`, or
`contributors`. The overview has no spelling (canonical links omit the
parameter), unknown values are rejected, and event links accept no `tab`.
The desktop's copy-link button emits it automatically when a non-overview
tab is active, so "link to the PR list" is just the project link copied
from the Pull Request tab.
- For `pr`/`issue` links, `id` identifies the kind `1618` / `1621` event;
`owner` + `d` are the routing coordinate that lets the client navigate
(and render a fallback card) without an event lookup. **v1 decision:** the