diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs index 0056f5773..70f3c4606 100644 --- a/crates/buzz-cli/src/links.rs +++ b/crates/buzz-cli/src/links.rs @@ -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=` 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. /// diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 0dbade64e..cf381ab95 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -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. diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 4c7382a30..56f3e49ad 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -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, diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 495442874..ecc27ac23 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -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} /> ); diff --git a/desktop/src/features/projects/lib/projectShareLinks.test.mjs b/desktop/src/features/projects/lib/projectShareLinks.test.mjs index a0424166b..23325c578 100644 --- a/desktop/src/features/projects/lib/projectShareLinks.test.mjs +++ b/desktop/src/features/projects/lib/projectShareLinks.test.mjs @@ -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 }), diff --git a/desktop/src/features/projects/lib/projectShareLinks.ts b/desktop/src/features/projects/lib/projectShareLinks.ts index da7cc60e5..ab3713baf 100644 --- a/desktop/src/features/projects/lib/projectShareLinks.ts +++ b/desktop/src/features/projects/lib/projectShareLinks.ts @@ -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; } diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index 86ed06de3..7b5963ab8 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -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 (
{project.projectChannelId ? ( diff --git a/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx b/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx new file mode 100644 index 000000000..b12a562bb --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx @@ -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 ( + <> + + {webUrl ? ( + + ) : null} + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index addabcd55..232792477 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -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) {
- - {repoRemote.webUrl && - (repoRemote.host.kind !== "external" || - repoSource === "local") ? ( - - ) : null} - + } activeTabCrumb={activeTabCrumb} activeWorkItemCrumb={activeWorkItemCrumb} @@ -884,12 +887,22 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { void goProjects(); }} project={project} + shareTab={ + activeWorkItemCrumb + ? undefined + : shareTabForWorkspaceTab(activeTab) + } />
{ + if (initialTab) setSelectedTab(initialTab); + }, [initialTab]); const [pullRequestCommentTarget, setPullRequestCommentTarget] = React.useState<{ anchor: ProjectPullRequestCommentAnchor; diff --git a/desktop/src/shared/lib/entityLink.test.mjs b/desktop/src/shared/lib/entityLink.test.mjs index d6f0e5d71..da64c6e6e 100644 --- a/desktop/src/shared/lib/entityLink.test.mjs +++ b/desktop/src/shared/lib/entityLink.test.mjs @@ -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); diff --git a/desktop/src/shared/lib/entityLink.ts b/desktop/src/shared/lib/entityLink.ts index 15174c29f..080f03796 100644 --- a/desktop/src/shared/lib/entityLink.ts +++ b/desktop/src/shared/lib/entityLink.ts @@ -3,25 +3,49 @@ * `features/messages/lib/messageLink.ts` for `buzz://message`. * * Formats: - * buzz://repo?owner=&d= - * buzz://project?owner=&d= + * buzz://repo?owner=&d=[&tab=] + * buzz://project?owner=&d=[&tab=] * buzz://pr?id=&owner=&d= * buzz://issue?id=&owner=&d= * * `owner` + `d` identify the NIP-34 repository coordinate * (`30617::`) or the NIP-MP project coordinate - * (`30621::`); `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::`); `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 } : {}), + }, }; } diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index b215110b8..5bbfcf5bf 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -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], diff --git a/docs/buzz-entity-links.md b/docs/buzz-entity-links.md index 68c226de5..bc1a61e4c 100644 --- a/docs/buzz-entity-links.md +++ b/docs/buzz-entity-links.md @@ -78,8 +78,8 @@ free. Extend the existing `buzz://` scheme, mirroring `buzz://message`: ``` -buzz://repo?owner=&d= -buzz://project?owner=&d= +buzz://repo?owner=&d=[&tab=] +buzz://project?owner=&d=[&tab=] buzz://pr?id=&owner=&d= buzz://issue?id=&owner=&d= ``` @@ -89,6 +89,13 @@ buzz://issue?id=&owner=&d= - `d` is the addressable `d`-tag. For `repo`/`project` links the (`owner`, `d`) pair is the full `30617::` / `30621::` 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