Link #123 issue and pull request references in chat (#8636)

* feat(app): link #123 issue and pull request references in Markdown

Agents cite issues and pull requests by number; reading one meant looking
up the URL by hand. Markdown in a workspace now links forge shorthand
against the workspace's origin remote: `#N`, GitLab and Gitea `!N`, and
cross-repository `owner/repo#N` on the same host.

Each forge's URL grammar declares which sigil maps to which path, so the
syntax stays per forge: GitHub has no `!`, and its `#` targets /issues/,
which GitHub redirects to the pull request. Recognition goes through the
Markdown engine's text link regex so code spans, fences and authored links
stay plain without a second Markdown parse while streaming. The reference
alternative sits before path recognition so `group/project#12` is not read
as a file path.

* fix(app): keep http:// for self-hosted forge web links

A self-hosted forge cloned over http:// serves its web UI over http on
the same origin. Forcing https made reference, blob and tree links fail
against an HTTP-only port. Cloud hosts and SSH remotes stay on https.
This commit is contained in:
Mohamed Boudra
2026-11-03 13:00:24 +07:00
committed by GitHub
parent f4eeb8a538
commit 85be2121b8
17 changed files with 401 additions and 30 deletions
@@ -38,12 +38,18 @@ export interface MarkdownImageContext {
export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
) {
const occurrenceKey = useId();
const { commandNames, forgeReferencePattern } = links;
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot, commandNames],
() =>
prepareMarkdownSource(text, context.workspaceRoot, {
supportsSvg: isWeb,
commandNames,
forgeReferencePattern,
}),
[text, context.workspaceRoot, commandNames, forgeReferencePattern],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
@@ -3,6 +3,7 @@ import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/util
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { createForgeShorthand } from "@/git/forge-shorthand";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
@@ -443,6 +444,47 @@ it("lets the engine own code boundaries while classifying candidate values", asy
]);
});
it("recognizes forge references in prose but not in code, links or paths", async () => {
const shorthand = createForgeShorthand({
forge: "gitlab",
remoteUrl: "git@gitlab.com:g/p.git",
})!;
const source = [
"Fixed in #12 and !13, see g/sub/q#14 and https://example.com/a#15.",
"`#16` and [#17](https://example.com) and src/app/index.ts and src/a/b/c#18",
"```\n#19\n```",
].join("\n\n");
const prepared = prepareMarkdownSource(source, "/repo", {
forgeReferencePattern: shorthand.pattern,
});
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const recognized: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
recognized.push(String(node.attributes.url));
}
node.children?.forEach(collect);
}
collect(tree);
expect(recognized).toEqual(["#12", "!13", "g/sub/q#14", "src/app/index.ts", "src/a/b/c#18"]);
expect(recognized.map((url) => shorthand.resolve(url))).toEqual([
"https://gitlab.com/g/p/-/issues/12",
"https://gitlab.com/g/p/-/merge_requests/13",
"https://gitlab.com/g/sub/q/-/issues/14",
null,
"https://gitlab.com/src/a/b/c/-/issues/18",
]);
// A rejected native regex silently falls back to no recognition at all.
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
isDisabled: false,
isDefault: false,
});
});
describe("bare URL sentence punctuation", () => {
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
"leaves %s outside the link and preserves the rendered sentence",
@@ -7,6 +7,8 @@ import {
} from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { openExternalUrl } from "@/utils/open-external-url";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
@@ -89,6 +91,7 @@ const MarkdownPart = memo(function MarkdownPart({
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const forgeShorthand = useForgeShorthand();
const client =
imageContext.client ??
(environment.client?.readFile
@@ -102,7 +105,7 @@ const MarkdownPart = memo(function MarkdownPart({
...imageContext,
client,
},
commandHints?.names,
{ commandNames: commandHints?.names, forgeReferencePattern: forgeShorthand?.pattern },
);
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
@@ -111,6 +114,11 @@ const MarkdownPart = memo(function MarkdownPart({
commandHints.insert(command);
return;
}
const forgeUrl = sourceType === "text" ? forgeShorthand?.resolve(url) : null;
if (forgeUrl) {
void openExternalUrl(forgeUrl);
return;
}
const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
@@ -7,6 +7,7 @@ import type {
} from "react-native-enriched-markdown";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useFileLink } from "@/assistant-file-links";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
@@ -31,7 +32,13 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
const forgeShorthand = useForgeShorthand();
// A forge reference like `#12` carries its web URL so hover, copy and new-tab opens work.
const forgeUrl = link.sourceType === "text" ? forgeShorthand?.resolve(link.url) : null;
const { tooltipPath, onHoverIn } = useFileLink(
forgeUrl ? markdownLinkSource(forgeUrl) : markdownLinkSource(link.url, link.sourceType),
);
const tooltip = forgeUrl ?? tooltipPath;
const [hovered, setHovered] = useState(false);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
@@ -46,6 +53,7 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const anchor = (
<a
{...anchorProps}
href={forgeUrl ?? anchorProps.href}
style={anchorStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -58,10 +66,10 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
<TooltipTrigger asChild onHoverIn={onHoverIn}>
<InlineLinkTrigger>{anchor}</InlineLinkTrigger>
</TooltipTrigger>
{tooltipPath ? (
{tooltip ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{tooltipPath}
{tooltip}
</Text>
</TooltipContent>
) : null}
+26 -6
View File
@@ -24,10 +24,12 @@ const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// Bare prose paths need a known extension, a line suffix, or at least two separators.
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
function createFilePattern(path: string): RegExp {
function createTextPattern(path: string, forgeReferencePattern?: string): RegExp {
// Forge references come before paths so `group/project#12` is not read as a path.
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
@@ -38,8 +40,20 @@ const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const FILE_PATTERN = createFilePattern(PROSE_PATH);
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
const textPatternsByForgeReference = new Map<string, RegExp>();
function textPattern(forgeReferencePattern?: string): RegExp {
if (!forgeReferencePattern) return TEXT_PATTERN;
let pattern = textPatternsByForgeReference.get(forgeReferencePattern);
if (!pattern) {
pattern = createTextPattern(PROSE_PATH, forgeReferencePattern);
textPatternsByForgeReference.set(forgeReferencePattern, pattern);
}
return pattern;
}
function linkMetadata(inlineCodePaths: ReadonlySet<string>, forgeReferencePattern?: string) {
// The engine accepts regexes on both platforms. Classify complete code spans
// first, then pass literal matches rather than maintaining a second path grammar.
const alternatives = [...inlineCodePaths].map((path) =>
@@ -47,7 +61,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
);
return {
linkRecognition: {
text: FILE_PATTERN,
text: textPattern(forgeReferencePattern),
inlineCode: new RegExp(
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
),
@@ -120,7 +134,12 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource(
source: string,
workspaceRoot?: string,
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
options: {
supportsSvg?: boolean;
commandNames?: ReadonlySet<string>;
/** Regex source for forge references such as `#12`; see `@/git/forge-shorthand`. */
forgeReferencePattern?: string;
} = {},
) {
source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = [];
@@ -132,6 +151,7 @@ export function prepareMarkdownSource(
}));
const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
options.forgeReferencePattern,
);
const edits: { start: number; end: number; text: string }[] = [];
if (
+9 -2
View File
@@ -41,8 +41,15 @@ export interface ForgeReferencePath {
kind: "change_request" | "issue";
/** Path between the repository path and the numeric local id. */
infix: string;
/**
* Shorthand marker the forge links in prose, e.g. GitHub `#12`, GitLab `!12`.
* Absent when the forge has no shorthand for this kind.
*/
sigil?: ForgeReferenceSigil;
}
export type ForgeReferenceSigil = "#" | "!";
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
@@ -54,8 +61,8 @@ export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
blobInfix: "/src/branch/",
lineAnchor: GITHUB_LINE_ANCHOR,
referencePaths: [
{ kind: "change_request", infix: "/pulls/" },
{ kind: "issue", infix: "/issues/" },
{ kind: "change_request", infix: "/pulls/", sigil: "!" },
{ kind: "issue", infix: "/issues/", sigil: "#" },
],
};
@@ -0,0 +1,35 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
export { createForgeShorthand, type ForgeShorthand } from "./shorthand";
const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
/** Makes `#12`-style references in descendant Markdown open on the workspace forge. */
export function ForgeShorthandProvider({
forge,
remoteUrl,
children,
}: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
children: ReactNode;
}) {
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
}
export function useForgeShorthand(): ForgeShorthand | null {
return useContext(ForgeShorthandContext);
}
/** Carries the shorthand environment across a portal. */
export function useForgeShorthandBridge() {
const value = useContext(ForgeShorthandContext);
return useCallback(
(children: ReactNode) => (
<ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>
),
[value],
);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createForgeShorthand } from "./shorthand";
function matches(shorthand: { pattern: string }, text: string): string[] {
return [...text.matchAll(new RegExp(shorthand.pattern, "g"))].map((match) => match[0]);
}
describe("createForgeShorthand", () => {
const github = createForgeShorthand({
forge: "github",
remoteUrl: "git@github.com:getpaseo/paseo.git",
})!;
const gitlab = createForgeShorthand({
forge: "gitlab",
remoteUrl: "https://gitlab.com/g/p.git",
})!;
it("resolves same-repo and cross-repo references on the remote's host", () => {
expect(github.resolve("#8613")).toBe("https://github.com/getpaseo/paseo/issues/8613");
expect(github.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
expect(gitlab.resolve("!7")).toBe("https://gitlab.com/g/p/-/merge_requests/7");
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
});
it("only recognizes the sigils the forge declares", () => {
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
expect(github.resolve("!2")).toBeNull();
expect(matches(gitlab, "see #1 and !2")).toEqual(["#1", "!2"]);
});
it("recognizes references only at word boundaries", () => {
expect(
matches(
github,
"(#1), #2. acme/tools#3, foo#4 #5abc &#6; https://x/#7 #0 #08 wow!9 user@host#10",
),
).toEqual(["#1", "#2", "acme/tools#3"]);
});
it("falls back to the cloud host forge when the daemon reports none", () => {
expect(
createForgeShorthand({ forge: undefined, remoteUrl: "git@github.com:a/b.git" })?.resolve(
"#1",
),
).toBe("https://github.com/a/b/issues/1");
});
it("is absent without a remote or a known forge", () => {
expect(createForgeShorthand({ forge: "github", remoteUrl: null })).toBeNull();
expect(
createForgeShorthand({ forge: undefined, remoteUrl: "git@git.corp:a/b.git" }),
).toBeNull();
});
});
@@ -0,0 +1,55 @@
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { forgeFromRemoteUrl } from "@/git/forge";
import { getClientForgeLogicModule } from "@/git/forges";
import { buildForgeReferenceUrl } from "@/git/forge-url";
/**
* Shorthand references in prose, such as `#12`, `!12` or `owner/repo#12`,
* resolved against one workspace remote.
*/
export interface ForgeShorthand {
/** Regex source for recognizing references in Markdown text. */
pattern: string;
/** Web URL for a recognized reference, or null. */
resolve(text: string): string | null;
}
// Keep these in the syntax shared by JavaScript, ICU and java.util.regex: the
// pattern runs in the native Markdown engines too.
const REPO = String.raw`[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+`;
const NUMBER = String.raw`[1-9][0-9]{0,8}`;
// A reference never continues a word, path, URL fragment or entity.
const BEFORE = String.raw`(?<![A-Za-z0-9_./@#!&-])`;
const AFTER = String.raw`(?![A-Za-z0-9_])`;
const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
export function createForgeShorthand(input: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
}): ForgeShorthand | null {
const remoteUrl = input.remoteUrl;
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
if (!forge || !remoteUrl) return null;
const sigils = forgeSigils(forge).filter(
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
);
if (sigils.length === 0) return null;
return {
pattern: `${BEFORE}(?:${REPO})?[${sigils.join("")}]${NUMBER}${AFTER}`,
resolve(text) {
const match = REFERENCE.exec(text);
if (!match) return null;
return buildForgeReferenceUrl(forge, {
remoteUrl,
repo: match[1] ?? null,
sigil: match[2] as ForgeReferenceSigil,
number: Number(match[3]),
});
},
};
}
function forgeSigils(forge: string): ForgeReferenceSigil[] {
const paths = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths ?? [];
return paths.flatMap((path) => (path.sigil ? [path.sigil] : []));
}
+68
View File
@@ -3,9 +3,77 @@ import {
buildForgeBlobUrl,
buildForgeBranchTreeUrl,
buildForgeChecksUrl,
buildForgeReferenceUrl,
hasForgeWebUrls,
} from "./forge-url";
describe("buildForgeReferenceUrl", () => {
it.each([
["github", "git@github.com:acme/repo.git", "#", null, "https://github.com/acme/repo/issues/12"],
["github", "git@github.com:acme/repo.git", "!", null, null],
[
"github",
"https://github.com/acme/repo",
"#",
"other/tools",
"https://github.com/other/tools/issues/12",
],
["gitlab", "git@gitlab.com:g/sub/p.git", "#", null, "https://gitlab.com/g/sub/p/-/issues/12"],
[
"gitlab",
"git@gitlab.com:g/sub/p.git",
"!",
null,
"https://gitlab.com/g/sub/p/-/merge_requests/12",
],
[
"gitlab",
"https://gitlab.corp:8443/g/p.git",
"!",
"g/q",
"https://gitlab.corp:8443/g/q/-/merge_requests/12",
],
[
"codeberg",
"https://codeberg.org/acme/repo.git",
"!",
null,
"https://codeberg.org/acme/repo/pulls/12",
],
[
"gitea",
"http://forge.test:3000/team/repo.git",
"#",
null,
"http://forge.test:3000/team/repo/issues/12",
],
[
"github",
"http://github.com/acme/repo.git",
"#",
null,
"https://github.com/acme/repo/issues/12",
],
[
"forgejo",
"git@forge.example:acme/repo.git",
"#",
null,
"https://forge.example/acme/repo/issues/12",
],
["bitbucket", "git@bitbucket.org:acme/repo.git", "#", null, null],
["github", null, "#", null, null],
["github", "git@github.com:acme/repo.git", "#", "../escape", null],
] as const)("builds %s %s %s in %s", (forge, remoteUrl, sigil, repo, expected) => {
expect(buildForgeReferenceUrl(forge, { remoteUrl, repo, sigil, number: 12 })).toBe(expected);
});
it("rejects non-positive numbers", () => {
const remoteUrl = "git@github.com:acme/repo.git";
expect(buildForgeReferenceUrl("github", { remoteUrl, sigil: "#", number: 0 })).toBeNull();
});
});
describe("buildForgeChecksUrl", () => {
it.each([
[
+46 -6
View File
@@ -12,6 +12,7 @@
*/
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
@@ -27,6 +28,14 @@ export interface ForgeBranchTreeUrlInput {
branch: string | null | undefined;
}
export interface ForgeReferenceUrlInput {
remoteUrl: string | null | undefined;
/** Repository path of a cross-repository reference; the remote's repository when absent. */
repo?: string | null;
sigil: ForgeReferenceSigil;
number: number;
}
export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): string | null {
const suffix = getClientForgeLogicModule(forge)?.urlGrammar?.changeRequestChecksSuffix;
if (!suffix) {
@@ -45,6 +54,8 @@ export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): st
}
interface ForgeWebLocation {
/** "http" only for a self-hosted http:// remote; every other remote browses over https. */
scheme: "http" | "https";
host: string;
/** Non-default port for a self-hosted http(s) origin, or undefined. */
port?: string;
@@ -87,16 +98,18 @@ function resolveForgeWebLocation(
!isCloudHost && (location.transport === "http" || location.transport === "https")
? location.port
: undefined;
return { host: webHost, port, repo: location.path };
const scheme = !isCloudHost && location.transport === "http" ? "http" : "https";
return { scheme, host: webHost, port, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
/** Host, plus `:port` when the remote pins a non-default port. */
function forgeAuthority(location: ForgeWebLocation): string {
return location.port ? `${location.host}:${location.port}` : location.host;
/** Scheme and host, plus `:port` when the remote pins a non-default port. */
function forgeOrigin(location: ForgeWebLocation): string {
const authority = location.port ? `${location.host}:${location.port}` : location.host;
return `${location.scheme}://${authority}`;
}
function normalizeBlobPath(path: string | null | undefined): string | null {
@@ -131,7 +144,7 @@ export function buildForgeBranchTreeUrl(
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
return `${forgeOrigin(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
@@ -143,13 +156,40 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
let url = `${forgeOrigin(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}
return url;
}
/**
* Web URL of a shorthand reference such as `#12` or `group/project!12`. A
* cross-repository reference stays on the remote's host. Null when the forge
* has no shorthand for the sigil.
*/
export function buildForgeReferenceUrl(
forge: string,
input: ForgeReferenceUrlInput,
): string | null {
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
(path) => path.sigil === input.sigil,
);
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const repo = input.repo ?? location?.repo;
if (
!referencePath ||
!location ||
!repo ||
!isValidRepoPath(repo) ||
!Number.isSafeInteger(input.number) ||
input.number <= 0
) {
return null;
}
return `${forgeOrigin(location)}/${repo}${referencePath.infix}${input.number}`;
}
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
export function hasForgeWebUrls(forge: string): boolean {
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
+2 -1
View File
@@ -89,7 +89,8 @@ export const githubForgeLogic = {
changeRequestChecksSuffix: "/checks",
referencePaths: [
{ kind: "change_request", infix: "/pull/" },
{ kind: "issue", infix: "/issues/" },
// GitHub redirects /issues/N to /pull/N, so `#N` covers both kinds.
{ kind: "issue", infix: "/issues/", sigil: "#" },
],
},
facts: defineForgeFacts({
+2 -2
View File
@@ -166,8 +166,8 @@ export const gitlabForgeLogic = {
lineAnchor: gitlabLineAnchor,
changeRequestChecksSuffix: "/pipelines",
referencePaths: [
{ kind: "change_request", infix: "/-/merge_requests/" },
{ kind: "issue", infix: "/-/issues/" },
{ kind: "change_request", infix: "/-/merge_requests/", sigil: "!" },
{ kind: "issue", infix: "/-/issues/", sigil: "#" },
],
},
facts: defineForgeFacts({
@@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, type ReactNode } from "r
import invariant from "tiny-invariant";
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
import { useForgeShorthandBridge } from "@/git/forge-shorthand";
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
@@ -56,13 +57,14 @@ export function useSheetPaneBridge(): ContextBridge {
const editing = useComposerEditingEnvironmentBridge();
const cache = useDiffDocumentWorkspaceCacheBridge();
const files = useAssistantFileLinkEnvironmentBridge();
const forgeShorthand = useForgeShorthandBridge();
const active = useRetainedPanelActive();
const serverId = useHostRouteServerId();
const navigation = useContext(NavigationContext);
const route = useContext(NavigationRouteContext);
return useCallback(
(children) => {
const content = editing(cache(files(children)));
const content = editing(cache(files(forgeShorthand(children))));
return (
<SheetPaneHostContext.Provider value={host}>
<NavigationContext.Provider value={navigation}>
@@ -79,6 +81,6 @@ export function useSheetPaneBridge(): ContextBridge {
</SheetPaneHostContext.Provider>
);
},
[host, editing, cache, files, active, serverId, navigation, route],
[host, editing, cache, files, forgeShorthand, active, serverId, navigation, route],
);
}
@@ -3,12 +3,14 @@ import { useHostFeature } from "@/runtime/host-features";
import {
AssistantFileLinkResolverProvider,
createInlinePathOpener,
type AssistantFileLinkResolverProviderProps,
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
import type { LayoutChangeEvent } from "react-native";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
import { ForgeShorthandProvider } from "@/git/forge-shorthand";
import { ExplorerFrame } from "./explorer-sidebar/frame";
import { WorkspaceTabDragScope } from "@/components/workspace-tab-drag-scope";
import {
@@ -3778,7 +3780,8 @@ function WorkspaceScreenContent({
workspaceKey={persistenceKey}
sheetPaneHost={sheetPaneHost}
>
<AssistantFileLinkResolverProvider
<WorkspaceMarkdownLinks
workspace={workspaceDescriptor}
client={client}
serverId={normalizedServerId}
workspaceRoot={workspaceDirectory ?? undefined}
@@ -3786,11 +3789,26 @@ function WorkspaceScreenContent({
toast={toast}
>
{renderedWorkspaceScreen}
</AssistantFileLinkResolverProvider>
</WorkspaceMarkdownLinks>
</WorkspaceContentProviders>
);
}
/** File links and forge references for every Markdown surface in the workspace. */
function WorkspaceMarkdownLinks({
workspace,
children,
...fileLinks
}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) {
return (
<AssistantFileLinkResolverProvider {...fileLinks}>
<ForgeShorthandProvider forge={workspace?.forge} remoteUrl={workspace?.gitRuntime?.remoteUrl}>
{children}
</ForgeShorthandProvider>
</AssistantFileLinkResolverProvider>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,