From 85be2121b8926740099d9507ea2687ee97eee92f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 13:00:24 +0700 Subject: [PATCH] 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. --- docs/forge-providers.md | 9 ++- docs/markdown-renderer.md | 2 +- .../app/src/components/markdown/images.ts | 12 +++- .../src/components/markdown/renderer.test.ts | 42 ++++++++++++ .../app/src/components/markdown/renderer.tsx | 10 ++- .../src/components/markdown/renderers.web.tsx | 14 +++- .../app/src/components/markdown/source.ts | 32 +++++++-- packages/app/src/git/client-forge-module.ts | 11 ++- .../app/src/git/forge-shorthand/index.tsx | 35 ++++++++++ .../src/git/forge-shorthand/shorthand.test.ts | 54 +++++++++++++++ .../app/src/git/forge-shorthand/shorthand.ts | 55 +++++++++++++++ packages/app/src/git/forge-url.test.ts | 68 +++++++++++++++++++ packages/app/src/git/forge-url.ts | 52 ++++++++++++-- packages/app/src/git/forges/github.ts | 3 +- packages/app/src/git/forges/gitlab.ts | 4 +- .../src/screens/workspace/sheet-pane/host.tsx | 6 +- .../screens/workspace/workspace-screen.tsx | 22 +++++- 17 files changed, 401 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/git/forge-shorthand/index.tsx create mode 100644 packages/app/src/git/forge-shorthand/shorthand.test.ts create mode 100644 packages/app/src/git/forge-shorthand/shorthand.ts diff --git a/docs/forge-providers.md b/docs/forge-providers.md index f69c7eb64a..376915d3ce 100644 --- a/docs/forge-providers.md +++ b/docs/forge-providers.md @@ -125,7 +125,8 @@ rendering stack: `acme.ts` exports a `ClientForgeLogicModule`: - `id` -- optional `urlGrammar` (including `referencePaths` for composer auto-attachment) +- optional `urlGrammar` (including `referencePaths` for composer auto-attachment + and Markdown shorthand references) - optional `facts` registration (schema, merge-capability, native-check fallbacks) `acme.view.tsx` exports a `ClientForgeViewModule`: @@ -212,6 +213,12 @@ are stale, run `npm run build:server`. neutral extractor matches the pasted host and full repository path against the current remote before it invokes cwd-routed forge search. Do not add a central forge switch or guess a self-hosted Gitea-family brand from URL shape. +- Shorthand references in rendered Markdown (`#12`, GitLab and Gitea `!12`, + cross-repository `owner/repo#12`) come from the `sigil` on each + `referencePaths` entry. Leave `sigil` off a kind the forge does not link in + prose: GitHub has no `!`, and its `#` targets `/issues/`, which GitHub + redirects to the pull request. `packages/app/src/git/forge-shorthand` builds + the recognition pattern from the workspace descriptor's forge and remote. - GitLab pipeline status constants belong to the GitLab adapter/client module, not protocol. - GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index 91151f5136..9a69631071 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -10,7 +10,7 @@ Every block is separated by one gap, `density.blockGap`: 12 on compact layouts, ## Links and images -Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. +Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. Inside a workspace, forge references such as `#12` and `owner/repo#12` are recognized in prose (not code) and open the workspace forge; the reference pattern comes before path recognition so `group/project#12` is not read as a path. See [forge providers](forge-providers.md#gotchas). Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline at line height and keep their aspect ratio. A linked image counts as an image, so review-bot badges such as `[![Retrigger](badge.svg)](url) Confidence` stay inline on web; the library's web renderer only checked direct paragraph children, so it rendered them as full-width block images (local patch in `patches/react-native-enriched-markdown+*.patch`). diff --git a/packages/app/src/components/markdown/images.ts b/packages/app/src/components/markdown/images.ts index e2f8d07992..9a52491125 100644 --- a/packages/app/src/components/markdown/images.ts +++ b/packages/app/src/components/markdown/images.ts @@ -38,12 +38,18 @@ export interface MarkdownImageContext { export function useMarkdownSource( text: string, context: MarkdownImageContext, - commandNames?: ReadonlySet, + links: { commandNames?: ReadonlySet; 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) => { diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index f6aeca2286..6ce7e59ca2 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -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", diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index c14ecb5198..19d3134eec 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -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(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"); diff --git a/packages/app/src/components/markdown/renderers.web.tsx b/packages/app/src/components/markdown/renderers.web.tsx index 1d05b3b603..dd1b23f097 100644 --- a/packages/app/src/components/markdown/renderers.web.tsx +++ b/packages/app/src/components/markdown/renderers.web.tsx @@ -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 = ( {anchor} - {tooltipPath ? ( + {tooltip ? ( - {tooltipPath} + {tooltip} ) : null} diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index 2ca3ebc474..8b15b8ff98 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -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}|(?()"'\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) { +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(); +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, 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) { ); 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 } = {}, + options: { + supportsSvg?: boolean; + commandNames?: ReadonlySet; + /** 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 ( diff --git a/packages/app/src/git/client-forge-module.ts b/packages/app/src/git/client-forge-module.ts index d8293fc26f..f36fb18fb3 100644 --- a/packages/app/src/git/client-forge-module.ts +++ b/packages/app/src/git/client-forge-module.ts @@ -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: "#" }, ], }; diff --git a/packages/app/src/git/forge-shorthand/index.tsx b/packages/app/src/git/forge-shorthand/index.tsx new file mode 100644 index 0000000000..5e40874c0a --- /dev/null +++ b/packages/app/src/git/forge-shorthand/index.tsx @@ -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(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 {children}; +} + +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) => ( + {children} + ), + [value], + ); +} diff --git a/packages/app/src/git/forge-shorthand/shorthand.test.ts b/packages/app/src/git/forge-shorthand/shorthand.test.ts new file mode 100644 index 0000000000..7da4ea1014 --- /dev/null +++ b/packages/app/src/git/forge-shorthand/shorthand.test.ts @@ -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  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(); + }); +}); diff --git a/packages/app/src/git/forge-shorthand/shorthand.ts b/packages/app/src/git/forge-shorthand/shorthand.ts new file mode 100644 index 0000000000..84a97bfb29 --- /dev/null +++ b/packages/app/src/git/forge-shorthand/shorthand.ts @@ -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`(? 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] : [])); +} diff --git a/packages/app/src/git/forge-url.test.ts b/packages/app/src/git/forge-url.test.ts index c7a250f9a4..e0deca00ff 100644 --- a/packages/app/src/git/forge-url.test.ts +++ b/packages/app/src/git/forge-url.test.ts @@ -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([ [ diff --git a/packages/app/src/git/forge-url.ts b/packages/app/src/git/forge-url.ts index 61bf6bdb8d..4fd86950e9 100644 --- a/packages/app/src/git/forge-url.ts +++ b/packages/app/src/git/forge-url.ts @@ -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; diff --git a/packages/app/src/git/forges/github.ts b/packages/app/src/git/forges/github.ts index 10290a2849..146fec827d 100644 --- a/packages/app/src/git/forges/github.ts +++ b/packages/app/src/git/forges/github.ts @@ -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({ diff --git a/packages/app/src/git/forges/gitlab.ts b/packages/app/src/git/forges/gitlab.ts index 15a67aeedf..5a98478b79 100644 --- a/packages/app/src/git/forges/gitlab.ts +++ b/packages/app/src/git/forges/gitlab.ts @@ -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({ diff --git a/packages/app/src/screens/workspace/sheet-pane/host.tsx b/packages/app/src/screens/workspace/sheet-pane/host.tsx index 3f8e60783a..74f5079fba 100644 --- a/packages/app/src/screens/workspace/sheet-pane/host.tsx +++ b/packages/app/src/screens/workspace/sheet-pane/host.tsx @@ -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 ( @@ -79,6 +81,6 @@ export function useSheetPaneBridge(): ContextBridge { ); }, - [host, editing, cache, files, active, serverId, navigation, route], + [host, editing, cache, files, forgeShorthand, active, serverId, navigation, route], ); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index dd806e1fa1..2aa5a54bae 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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} > - {renderedWorkspaceScreen} - + ); } +/** File links and forge references for every Markdown surface in the workspace. */ +function WorkspaceMarkdownLinks({ + workspace, + children, + ...fileLinks +}: AssistantFileLinkResolverProviderProps & { workspace: WorkspaceDescriptor | null | undefined }) { + return ( + + + {children} + + + ); +} + const styles = StyleSheet.create((theme) => ({ container: { flex: 1,