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