mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
* 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:
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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  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] : []));
|
||||
}
|
||||
@@ -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([
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user