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:
@@ -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
|
||||
|
||||
@@ -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 `[](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`).
|
||||
|
||||
|
||||
@@ -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