import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";import { useNavigate } from "@tanstack/react-router";import { type MouseEvent, useCallback } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";import { canonicalRepositoryKey, sourceControlRepositorySelector,} from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink";import { stackedThreadToast, toastManager } from "../components/ui/toast";import { useRightPanelStore } from "../rightPanelStore";import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs } from "../state/entities";import { usePrimaryEnvironmentId } from "../state/environments"; export { parseChangeRequestUrl, type ChangeRequestLink, gitHubPullRequestBrowserUrl, pullRequestCandidateUrlFromReferenceAutolink, matchesLinkedPullRequestUrl, changeRequestRepositoryUrl,} from "@t3tools/shared/changeRequestUrl"; /** * Returns a click handler that opens a pull request URL in the system browser. * * Stops event propagation/default so activating the link does not also trigger * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a * toast when the local API is unavailable or the open fails. *//** * The project a link belongs to, or nothing. Matched the way the server matches: the repository * identity is the full path below the host where one was recorded — which is what nested GitLab * groups and Azure project paths need — and the host is the first segment of the canonical * remote, so github.com and an Enterprise install stay apart. */export function findProjectForChangeRequest( projects: ReadonlyArray<EnvironmentProject>, link: ChangeRequestLink,): EnvironmentProject | undefined { return projects.find((project) => { const identity = project.repositoryIdentity; if (!identity) return false; const kind = identity.provider as SourceControlProviderKind | undefined; if (kind === undefined) return false; if (kind === "azure-devops") { return ( canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()) ); } const repository = identity.displayName ?? (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); return ( repository !== null && repository.toLowerCase() === link.repository.toLowerCase() && pullRequestHostOf(identity, kind) === link.host.toLowerCase() ); });} /** * Any project checked out from the link's host. Thread links are host-level, so a pull request * from a repository nobody has checked out is still linkable as long as one project on that * host can lend the server its credentials. The link's own project, when it exists, comes first. */export function findProjectOnChangeRequestHost( projects: ReadonlyArray<EnvironmentProject>, link: ChangeRequestLink,): EnvironmentProject | undefined { const own = findProjectForChangeRequest(projects, link); if (own !== undefined) return own; // Azure CLI reads use the checkout's organization and project, not host-wide credentials. if ( canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith( "dev.azure.com/", ) ) return undefined; return projects.find((project) => { const identity = project.repositoryIdentity; const kind = identity?.provider as SourceControlProviderKind | undefined; return ( identity != null && kind !== undefined && kind !== "azure-devops" && pullRequestHostOf(identity, kind) === link.host.toLowerCase() ); });} /** * Opens a change request link on the page, and says whether it did. Anything else — another * organisation's repository, a host nothing here is checked out from, a link that merely looks * like one — is left alone for the caller to handle as the ordinary link it is. * * Resolving the project here rather than on the page is what makes recognising a URL safe: a * lookalike hostname matches no project and stays a link, and the page is handed the project * rather than a host to narrow its whole list by. * * Given a thread, the link opens beside it in the right panel instead of taking the whole app to * the pull requests page: a reader following a link the agent wrote is reading the thread, and * should still be reading it afterwards. Any change request opens there, not only the thread's * own, since the panel is told which one to show. */export function shouldOpenPullRequestExternally( event: Pick<MouseEvent<HTMLElement>, "metaKey" | "ctrlKey">,): boolean { return event.metaKey || event.ctrlKey;} export function useOpenChangeRequestLink( threadRef?: ScopedThreadRef, panelRef?: ScopedThreadRef,): ( event: Pick< MouseEvent<HTMLElement>, "preventDefault" | "stopPropagation" | "metaKey" | "ctrlKey" >, targetUrl: string, targetThreadRef?: ScopedThreadRef, targetEnvironmentId?: EnvironmentId,) => boolean { const navigate = useNavigate(); const allProjects = useProjects(); const serverConfigs = useServerConfigs(); const primaryEnvironmentId = usePrimaryEnvironmentId(); return useCallback( (event, targetUrl, targetThreadRef, targetEnvironmentId) => { if (shouldOpenPullRequestExternally(event)) return false; const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; const parsed = parseChangeRequestUrl(targetUrl); if (parsed === null) return false; const reads = (environmentId: string) => serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests === true; // Beside a thread the panel reads on that thread's environment, so a project from another // one could not be read there whatever its remote says: two environments can hold the same // repository, and handing the panel the wrong one's id opens a surface that never loads. // // The page has no such tie — it lists every server at once — so the link is resolved // against all of them, the primary first where two hold the same repository. const projects = resolvedThreadRef ? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId) : targetEnvironmentId ? allProjects.filter((project) => project.environmentId === targetEnvironmentId) : allProjects .filter((project) => reads(project.environmentId)) .toSorted( (left, right) => Number(right.environmentId === primaryEnvironmentId) - Number(left.environmentId === primaryEnvironmentId), ); const exactProject = findProjectForChangeRequest(projects, parsed); const project = exactProject ?? (resolvedPanelRef ? findProjectOnChangeRequestHost( projects.filter( (candidate) => serverConfigs.get(candidate.environmentId)?.environment.capabilities .threadPullRequests === true, ), parsed, ) : undefined); if (project === undefined || !reads(project.environmentId)) return false; const repository = serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === true ? parsed.repository : (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository); event.preventDefault(); event.stopPropagation(); if (resolvedPanelRef) { useRightPanelStore.getState().openPullRequest(resolvedPanelRef, { // The standalone PR panel has a synthetic ref; each tab keeps its real environment. ...(resolvedPanelRef.environmentId === project.environmentId ? {} : { environmentId: project.environmentId }), projectId: project.id, ...(serverConfigs.get(project.environmentId)?.environment.capabilities .threadPullRequests === true ? { host: parsed.host } : {}), repository, url: targetUrl, number: parsed.number, }); if (!resolvedThreadRef) { void navigate({ to: "/pull-requests", search: (previous) => ({ ...previous, involvement: previous.involvement ?? "all", state: previous.state ?? "all", repository, number: parsed.number, selectedHost: parsed.host, selectedProjectId: project.id, selectedEnvironmentId: project.environmentId, }), replace: true, }); } return true; } void navigate({ to: "/pull-requests", search: { involvement: "all", // Every state, so the pull request being opened is also in the list behind it whether // it is open, merged or closed. state: "all", repository, number: parsed.number, selectedHost: parsed.host, selectedProjectId: project.id, // Named so the page opens the right one of two servers holding this project. selectedEnvironmentId: project.environmentId, }, }); return true; }, [allProjects, navigate, panelRef, primaryEnvironmentId, serverConfigs, threadRef], );} export function useOpenPrLink(threadRef?: ScopedThreadRef) { const openChangeRequest = useOpenChangeRequestLink(threadRef); const openLink = useOpenLink(threadRef); return useCallback( (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => { event.stopPropagation(); const openInBrowser = shouldOpenPullRequestExternally(event); const isAnchor = event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.href.length > 0; // A real link already knows how to cmd/ctrl+click. Leave its default // action alone so the browser (or Electron's window-open handler) opens // the host. Buttons have no href, so they still go through openExternal. if (openInBrowser && isAnchor) return false; event.preventDefault(); if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true; // No project to show it in, so it is an ordinary link and follows the // "Open links in" setting; the modifier still forces the system browser. void openLink(prUrl, { event, threadRef: targetThreadRef }).catch((error: unknown) => { console.error(error); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open pull request link", description: error instanceof Error ? error.message : "An error occurred.", }), ); }); return false; }, [openChangeRequest, openLink], );}
import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import { useNavigate } from "@tanstack/react-router";
import { type MouseEvent, useCallback } from "react";
import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl";
import {
canonicalRepositoryKey,
sourceControlRepositorySelector,
} from "@t3tools/shared/sourceControl";
import { useOpenLink } from "../browser/useOpenLink";
import { stackedThreadToast, toastManager } from "../components/ui/toast";
import { useRightPanelStore } from "../rightPanelStore";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { useProjects, useServerConfigs } from "../state/entities";
import { usePrimaryEnvironmentId } from "../state/environments";
export {
parseChangeRequestUrl,
type ChangeRequestLink,
gitHubPullRequestBrowserUrl,
pullRequestCandidateUrlFromReferenceAutolink,
matchesLinkedPullRequestUrl,
changeRequestRepositoryUrl,
} from "@t3tools/shared/changeRequestUrl";
/**
* Returns a click handler that opens a pull request URL in the system browser.
*
* Stops event propagation/default so activating the link does not also trigger
* an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a
* toast when the local API is unavailable or the open fails.
*/
/**
* The project a link belongs to, or nothing. Matched the way the server matches: the repository
* identity is the full path below the host where one was recorded — which is what nested GitLab
* groups and Azure project paths need — and the host is the first segment of the canonical
* remote, so github.com and an Enterprise install stay apart.
*/
export function findProjectForChangeRequest(
projects: ReadonlyArray<EnvironmentProject>,
link: ChangeRequestLink,
): EnvironmentProject | undefined {
return projects.find((project) => {
const identity = project.repositoryIdentity;
if (!identity) return false;
const kind = identity.provider as SourceControlProviderKind | undefined;
if (kind === undefined) return false;
if (kind === "azure-devops") {
return (
canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===
canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase())
);
}
const repository =
identity.displayName ??
(identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
return (
repository !== null &&
repository.toLowerCase() === link.repository.toLowerCase() &&
pullRequestHostOf(identity, kind) === link.host.toLowerCase()
);
});
}
/**
* Any project checked out from the link's host. Thread links are host-level, so a pull request
* from a repository nobody has checked out is still linkable as long as one project on that
* host can lend the server its credentials. The link's own project, when it exists, comes first.
*/
export function findProjectOnChangeRequestHost(
projects: ReadonlyArray<EnvironmentProject>,
link: ChangeRequestLink,
): EnvironmentProject | undefined {
const own = findProjectForChangeRequest(projects, link);
if (own !== undefined) return own;
// Azure CLI reads use the checkout's organization and project, not host-wide credentials.
if (
canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith(
"dev.azure.com/",
)
)
return undefined;
return projects.find((project) => {
const identity = project.repositoryIdentity;
const kind = identity?.provider as SourceControlProviderKind | undefined;
return (
identity != null &&
kind !== undefined &&
kind !== "azure-devops" &&
pullRequestHostOf(identity, kind) === link.host.toLowerCase()
);
});
}
/**
* Opens a change request link on the page, and says whether it did. Anything else — another
* organisation's repository, a host nothing here is checked out from, a link that merely looks
* like one — is left alone for the caller to handle as the ordinary link it is.
*
* Resolving the project here rather than on the page is what makes recognising a URL safe: a
* lookalike hostname matches no project and stays a link, and the page is handed the project
* rather than a host to narrow its whole list by.
*
* Given a thread, the link opens beside it in the right panel instead of taking the whole app to
* the pull requests page: a reader following a link the agent wrote is reading the thread, and
* should still be reading it afterwards. Any change request opens there, not only the thread's
* own, since the panel is told which one to show.
*/
export function shouldOpenPullRequestExternally(
event: Pick<MouseEvent<HTMLElement>, "metaKey" | "ctrlKey">,
): boolean {
return event.metaKey || event.ctrlKey;
}
export function useOpenChangeRequestLink(
threadRef?: ScopedThreadRef,
panelRef?: ScopedThreadRef,
): (
event: Pick<
MouseEvent<HTMLElement>,
"preventDefault" | "stopPropagation" | "metaKey" | "ctrlKey"
>,
targetUrl: string,
targetThreadRef?: ScopedThreadRef,
targetEnvironmentId?: EnvironmentId,
) => boolean {
const navigate = useNavigate();
const allProjects = useProjects();
const serverConfigs = useServerConfigs();
const primaryEnvironmentId = usePrimaryEnvironmentId();
return useCallback(
(event, targetUrl, targetThreadRef, targetEnvironmentId) => {
if (shouldOpenPullRequestExternally(event)) return false;
const resolvedThreadRef = targetThreadRef ?? threadRef;
const resolvedPanelRef = panelRef ?? resolvedThreadRef;
const parsed = parseChangeRequestUrl(targetUrl);
if (parsed === null) return false;
const reads = (environmentId: string) =>
serverConfigs.get(environmentId as EnvironmentId)?.environment.capabilities.pullRequests ===
true;
// Beside a thread the panel reads on that thread's environment, so a project from another
// one could not be read there whatever its remote says: two environments can hold the same
// repository, and handing the panel the wrong one's id opens a surface that never loads.
//
// The page has no such tie — it lists every server at once — so the link is resolved
// against all of them, the primary first where two hold the same repository.
const projects = resolvedThreadRef
? allProjects.filter((project) => project.environmentId === resolvedThreadRef.environmentId)
: targetEnvironmentId
? allProjects.filter((project) => project.environmentId === targetEnvironmentId)
: allProjects
.filter((project) => reads(project.environmentId))
.toSorted(
(left, right) =>
Number(right.environmentId === primaryEnvironmentId) -
Number(left.environmentId === primaryEnvironmentId),
);
const exactProject = findProjectForChangeRequest(projects, parsed);
const project =
exactProject ??
(resolvedPanelRef
? findProjectOnChangeRequestHost(
projects.filter(
(candidate) =>
serverConfigs.get(candidate.environmentId)?.environment.capabilities
.threadPullRequests === true,
),
parsed,
)
: undefined);
if (project === undefined || !reads(project.environmentId)) return false;
const repository =
serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests ===
true
? parsed.repository
: (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository);
event.preventDefault();
event.stopPropagation();
if (resolvedPanelRef) {
useRightPanelStore.getState().openPullRequest(resolvedPanelRef, {
// The standalone PR panel has a synthetic ref; each tab keeps its real environment.
...(resolvedPanelRef.environmentId === project.environmentId
? {}
: { environmentId: project.environmentId }),
projectId: project.id,
...(serverConfigs.get(project.environmentId)?.environment.capabilities
.threadPullRequests === true
? { host: parsed.host }
: {}),
repository,
url: targetUrl,
number: parsed.number,
});
if (!resolvedThreadRef) {
void navigate({
to: "/pull-requests",
search: (previous) => ({
...previous,
involvement: previous.involvement ?? "all",
state: previous.state ?? "all",
repository,
number: parsed.number,
selectedHost: parsed.host,
selectedProjectId: project.id,
selectedEnvironmentId: project.environmentId,
}),
replace: true,
});
}
return true;
}
void navigate({
to: "/pull-requests",
search: {
involvement: "all",
// Every state, so the pull request being opened is also in the list behind it whether
// it is open, merged or closed.
state: "all",
repository,
number: parsed.number,
selectedHost: parsed.host,
selectedProjectId: project.id,
// Named so the page opens the right one of two servers holding this project.
selectedEnvironmentId: project.environmentId,
},
});
return true;
},
[allProjects, navigate, panelRef, primaryEnvironmentId, serverConfigs, threadRef],
);
}
export function useOpenPrLink(threadRef?: ScopedThreadRef) {
const openChangeRequest = useOpenChangeRequestLink(threadRef);
const openLink = useOpenLink(threadRef);
return useCallback(
(event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => {
event.stopPropagation();
const openInBrowser = shouldOpenPullRequestExternally(event);
const isAnchor =
event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.href.length > 0;
// A real link already knows how to cmd/ctrl+click. Leave its default
// action alone so the browser (or Electron's window-open handler) opens
// the host. Buttons have no href, so they still go through openExternal.
if (openInBrowser && isAnchor) return false;
event.preventDefault();
if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true;
// No project to show it in, so it is an ordinary link and follows the
// "Open links in" setting; the modifier still forces the system browser.
void openLink(prUrl, { event, threadRef: targetThreadRef }).catch((error: unknown) => {
console.error(error);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Unable to open pull request link",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
});
return false;
},
[openChangeRequest, openLink],
);
}