mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +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.
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
import { Image } from "react-native";
|
|
import { isWeb } from "@/constants/platform";
|
|
import { useMemo, useEffect, useId } from "react";
|
|
import { useFetchQueries } from "@/data/query";
|
|
import { createAssistantImageFileAcquisition } from "@/assistant-image/file-acquisition";
|
|
import {
|
|
persistAttachmentFromBytes,
|
|
resolveAttachmentPreviewUrl,
|
|
releaseAttachmentPreviewUrl,
|
|
} from "@/attachments/service";
|
|
import { createAssistantImageAcquisitionCache } from "@/assistant-image/acquisition-cache";
|
|
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
|
|
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import type { AttachmentMetadata } from "@/attachments/types";
|
|
import { resolveMarkdownPreviewImageSource } from "./image-source";
|
|
import { prepareMarkdownSource } from "./source";
|
|
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
|
|
|
|
const urls = createAssistantImageAcquisitionCache<{ uri: string; attachment: AttachmentMetadata }>({
|
|
capacity: 500,
|
|
onRetain: ({ uri, attachment }) => {
|
|
const release = retainAttachmentForGarbageCollection(attachment.id);
|
|
return () => {
|
|
release();
|
|
void releaseAttachmentPreviewUrl({ attachment, url: uri });
|
|
};
|
|
},
|
|
});
|
|
|
|
export interface MarkdownImageContext {
|
|
client?: Pick<DaemonClient, "readFile"> | null;
|
|
workspaceRoot?: string;
|
|
serverId?: string;
|
|
filePath?: string;
|
|
}
|
|
|
|
export function useMarkdownSource(
|
|
text: string,
|
|
context: MarkdownImageContext,
|
|
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
|
|
) {
|
|
const occurrenceKey = useId();
|
|
const { commandNames, forgeReferencePattern } = links;
|
|
const prepared = useMemo(
|
|
() =>
|
|
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) => {
|
|
const resolved = resolveMarkdownPreviewImageSource({
|
|
source,
|
|
markdownPath: context.filePath ?? "",
|
|
workspaceRoot: context.workspaceRoot,
|
|
});
|
|
return createAssistantImageFileAcquisition({
|
|
port: context.client
|
|
? {
|
|
readFile: (cwd, path) => context.client!.readFile(cwd, path),
|
|
persist: persistAttachmentFromBytes,
|
|
}
|
|
: null,
|
|
resolution: resolved
|
|
? resolveAssistantImageSource({ source: resolved, workspaceRoot: context.workspaceRoot })
|
|
: null,
|
|
serverId: context.serverId,
|
|
occurrenceKey,
|
|
unavailableMessage: "Image unavailable",
|
|
});
|
|
});
|
|
const results = useFetchQueries(
|
|
acquisitions.map((acquisition, index) => ({
|
|
queryKey: ["markdownImage", acquisition?.key ?? sources[index]],
|
|
enabled: acquisition !== null,
|
|
immutableWhen: () => true,
|
|
dataShape: "value",
|
|
gcTime: 0,
|
|
queryFn: async () => {
|
|
if (!acquisition) throw new Error("No image acquisition");
|
|
return urls.acquire(acquisition.key, async () => {
|
|
const attachment = await acquisition.locate();
|
|
const uri = await resolveAttachmentPreviewUrl(attachment);
|
|
return { attachment, uri };
|
|
});
|
|
},
|
|
})),
|
|
);
|
|
useEffect(() => {
|
|
const releases = results.flatMap((result, index) => {
|
|
const acquisition = acquisitions[index];
|
|
if (!result.data || !acquisition) return [];
|
|
const value = result.data;
|
|
return [urls.acquireRetained(acquisition.key, async () => value).release];
|
|
});
|
|
return () => releases.forEach((release) => release());
|
|
}, [results, acquisitions]);
|
|
const resolved = new Map<string, string>();
|
|
results.forEach((result, index) => {
|
|
if (result.data) resolved.set(sources[index]!, result.data.uri);
|
|
});
|
|
const displayUrls = sources.map(
|
|
(source) =>
|
|
resolved.get(source) ?? (/^(https?:|data:|blob:)/i.test(source) ? source : undefined),
|
|
);
|
|
const decoded = useFetchQueries(
|
|
displayUrls.map((uri, index) => ({
|
|
queryKey: ["markdownImageDecode", occurrenceKey, uri ?? sources[index]],
|
|
refetchOnReconnect: true,
|
|
enabled: uri !== undefined,
|
|
immutableWhen: () => true,
|
|
dataShape: "value",
|
|
gcTime: 0,
|
|
queryFn: () => {
|
|
if (!uri) throw new Error("No image URL to decode");
|
|
return decodeMarkdownImage(uri, Image.getSize);
|
|
},
|
|
})),
|
|
);
|
|
const statuses = sources.map((_source, index) =>
|
|
getMarkdownImageStatus({
|
|
canAcquire: acquisitions[index] !== null,
|
|
url: displayUrls[index],
|
|
acquisitionFailed: results[index].isError,
|
|
decodeFailed: decoded[index].isError,
|
|
decoded: decoded[index].data === true,
|
|
}),
|
|
);
|
|
const unavailableImages = new Set(
|
|
sources.filter((_source, index) => statuses[index] === "unavailable"),
|
|
);
|
|
const pendingImages = new Set(sources.filter((_source, index) => statuses[index] === "pending"));
|
|
return {
|
|
markdown: prepared.render(resolved, unavailableImages, pendingImages),
|
|
linkRecognition: prepared.linkRecognition,
|
|
linkSource: prepared.linkSource,
|
|
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
|
|
};
|
|
}
|