Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andJiayang Wu bd5671fd49 fix(usage): discover migrated database logins and relative paths
Match the source application’s migration and database-path rules so usage discovery does not keep reporting a stale file login after migration.

Co-authored-by: Jiayang Wu <651475459@qq.com>
2026-11-03 08:29:14 +00:00
Jiayang Wu d5f19aed37 fix(usage): read the OpenCode v2 login store in the Codex usage source
OpenCode v2 keeps its credentials in its own database and no longer writes
the v1 credential file. The Codex usage source only knew the v1 stores, so
on a v2 host it kept reporting the last file login it ever saw, with that
file's expiry date, instead of the ChatGPT login the host actually uses.

Read the active OpenAI credential from the OpenCode v2 database when it
holds one, and keep the v1 file as the source for installations without
that database. The database wins when both exist, because the file login
is the one that goes stale.

Report the v2 store with the same OpenCode harness and refresh command as
the v1 file store, so a rejected or expired login still names opencode.

Cover discovery, store precedence, expiry, token rotation and read-only
access with tests against real temporary SQLite databases.

Related to getpaseo/paseo#8645
2026-11-03 15:31:15 +08:00
Tahir Khan b807c261ca Show progress while plugin screens load (#8123)
Plugin screens can report unavailable while a successful catalog request
is still pending. Track the host catalog state so these screens show
progress until their contributions register.

Keep missing surfaces, failed requests, and offline hosts distinct from
loading. Retain existing installed surfaces during catalog refreshes.

Cover pending-to-ready rendering with a real browser regression and
catalog lifecycle tests.

Related to getpaseo/paseo#7088
2026-11-03 06:05:00 +00:00
Mohamed Boudra 85be2121b8 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.
2026-11-03 13:00:24 +07:00
30 changed files with 1175 additions and 59 deletions
+8 -1
View File
@@ -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
+1 -1
View File
@@ -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`).
@@ -27,6 +27,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -133,6 +134,30 @@ function sidebarFooter(page: Page): Locator {
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
}
function isPluginCatalogResponse(message: string | Buffer): boolean {
if (typeof message !== "string") return false;
const frame = JSON.parse(message);
return frame.type === "session" && frame.message?.type === "plugin.catalog.get.response";
}
async function holdNextPluginCatalogResponse(page: Page) {
const response = Promise.withResolvers<() => void>();
let holdCatalog = true;
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (holdCatalog && isPluginCatalogResponse(message)) {
holdCatalog = false;
response.resolve(() => socket.send(message));
return;
}
socket.send(message);
});
});
return { response: response.promise };
}
test.describe("Plugin sidebar items", () => {
let workspaceId: string;
let cleanup: () => Promise<void>;
@@ -151,6 +176,38 @@ test.describe("Plugin sidebar items", () => {
await cleanup();
});
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const body = page.getByText("Deploys screen body", { exact: true });
await expect(body).toBeVisible();
const screenUrl = page.url();
const catalog = await holdNextPluginCatalogResponse(page);
await page.reload();
const releaseCatalog = await catalog.response;
try {
await expect(
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
).toBeVisible();
await expect(
page.getByText("This plugin surface is unavailable.", { exact: true }),
).toHaveCount(0);
await expect(body).toHaveCount(0);
} finally {
releaseCatalog();
}
await expect(body).toBeVisible();
await expect(
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
).toHaveCount(0);
await expect(page).toHaveURL(screenUrl);
});
test("a plugin's screen, header row, footer popover and command work together", async ({
page,
}) => {
@@ -38,12 +38,18 @@ export interface MarkdownImageContext {
export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
links: { commandNames?: ReadonlySet<string>; forgeReferencePattern?: string } = {},
) {
const occurrenceKey = useId();
const { commandNames, forgeReferencePattern } = links;
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot, commandNames],
() =>
prepareMarkdownSource(text, context.workspaceRoot, {
supportsSvg: isWeb,
commandNames,
forgeReferencePattern,
}),
[text, context.workspaceRoot, commandNames, forgeReferencePattern],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
@@ -3,6 +3,7 @@ import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/util
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { createForgeShorthand } from "@/git/forge-shorthand";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
@@ -443,6 +444,47 @@ it("lets the engine own code boundaries while classifying candidate values", asy
]);
});
it("recognizes forge references in prose but not in code, links or paths", async () => {
const shorthand = createForgeShorthand({
forge: "gitlab",
remoteUrl: "git@gitlab.com:g/p.git",
})!;
const source = [
"Fixed in #12 and !13, see g/sub/q#14 and https://example.com/a#15.",
"`#16` and [#17](https://example.com) and src/app/index.ts and src/a/b/c#18",
"```\n#19\n```",
].join("\n\n");
const prepared = prepareMarkdownSource(source, "/repo", {
forgeReferencePattern: shorthand.pattern,
});
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const recognized: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
recognized.push(String(node.attributes.url));
}
node.children?.forEach(collect);
}
collect(tree);
expect(recognized).toEqual(["#12", "!13", "g/sub/q#14", "src/app/index.ts", "src/a/b/c#18"]);
expect(recognized.map((url) => shorthand.resolve(url))).toEqual([
"https://gitlab.com/g/p/-/issues/12",
"https://gitlab.com/g/p/-/merge_requests/13",
"https://gitlab.com/g/sub/q/-/issues/14",
null,
"https://gitlab.com/src/a/b/c/-/issues/18",
]);
// A rejected native regex silently falls back to no recognition at all.
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
isDisabled: false,
isDefault: false,
});
});
describe("bare URL sentence punctuation", () => {
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
"leaves %s outside the link and preserves the rendered sentence",
@@ -7,6 +7,8 @@ import {
} from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { openExternalUrl } from "@/utils/open-external-url";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
@@ -89,6 +91,7 @@ const MarkdownPart = memo(function MarkdownPart({
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const forgeShorthand = useForgeShorthand();
const client =
imageContext.client ??
(environment.client?.readFile
@@ -102,7 +105,7 @@ const MarkdownPart = memo(function MarkdownPart({
...imageContext,
client,
},
commandHints?.names,
{ commandNames: commandHints?.names, forgeReferencePattern: forgeShorthand?.pattern },
);
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
@@ -111,6 +114,11 @@ const MarkdownPart = memo(function MarkdownPart({
commandHints.insert(command);
return;
}
const forgeUrl = sourceType === "text" ? forgeShorthand?.resolve(url) : null;
if (forgeUrl) {
void openExternalUrl(forgeUrl);
return;
}
const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
@@ -7,6 +7,7 @@ import type {
} from "react-native-enriched-markdown";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useFileLink } from "@/assistant-file-links";
import { useForgeShorthand } from "@/git/forge-shorthand";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
@@ -31,7 +32,13 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
const forgeShorthand = useForgeShorthand();
// A forge reference like `#12` carries its web URL so hover, copy and new-tab opens work.
const forgeUrl = link.sourceType === "text" ? forgeShorthand?.resolve(link.url) : null;
const { tooltipPath, onHoverIn } = useFileLink(
forgeUrl ? markdownLinkSource(forgeUrl) : markdownLinkSource(link.url, link.sourceType),
);
const tooltip = forgeUrl ?? tooltipPath;
const [hovered, setHovered] = useState(false);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
@@ -46,6 +53,7 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const anchor = (
<a
{...anchorProps}
href={forgeUrl ?? anchorProps.href}
style={anchorStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -58,10 +66,10 @@ function WebLink({ link, anchorProps, children }: LinkRendererProps) {
<TooltipTrigger asChild onHoverIn={onHoverIn}>
<InlineLinkTrigger>{anchor}</InlineLinkTrigger>
</TooltipTrigger>
{tooltipPath ? (
{tooltip ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}>
{tooltipPath}
{tooltip}
</Text>
</TooltipContent>
) : null}
+26 -6
View File
@@ -24,10 +24,12 @@ const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// Bare prose paths need a known extension, a line suffix, or at least two separators.
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
function createFilePattern(path: string): RegExp {
function createTextPattern(path: string, forgeReferencePattern?: string): RegExp {
// Forge references come before paths so `group/project#12` is not read as a path.
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
@@ -38,8 +40,20 @@ const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const FILE_PATTERN = createFilePattern(PROSE_PATH);
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
const textPatternsByForgeReference = new Map<string, RegExp>();
function textPattern(forgeReferencePattern?: string): RegExp {
if (!forgeReferencePattern) return TEXT_PATTERN;
let pattern = textPatternsByForgeReference.get(forgeReferencePattern);
if (!pattern) {
pattern = createTextPattern(PROSE_PATH, forgeReferencePattern);
textPatternsByForgeReference.set(forgeReferencePattern, pattern);
}
return pattern;
}
function linkMetadata(inlineCodePaths: ReadonlySet<string>, forgeReferencePattern?: string) {
// The engine accepts regexes on both platforms. Classify complete code spans
// first, then pass literal matches rather than maintaining a second path grammar.
const alternatives = [...inlineCodePaths].map((path) =>
@@ -47,7 +61,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
);
return {
linkRecognition: {
text: FILE_PATTERN,
text: textPattern(forgeReferencePattern),
inlineCode: new RegExp(
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
),
@@ -120,7 +134,12 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource(
source: string,
workspaceRoot?: string,
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
options: {
supportsSvg?: boolean;
commandNames?: ReadonlySet<string>;
/** Regex source for forge references such as `#12`; see `@/git/forge-shorthand`. */
forgeReferencePattern?: string;
} = {},
) {
source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = [];
@@ -132,6 +151,7 @@ export function prepareMarkdownSource(
}));
const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
options.forgeReferencePattern,
);
const edits: { start: number; end: number; text: string }[] = [];
if (
+9 -2
View File
@@ -41,8 +41,15 @@ export interface ForgeReferencePath {
kind: "change_request" | "issue";
/** Path between the repository path and the numeric local id. */
infix: string;
/**
* Shorthand marker the forge links in prose, e.g. GitHub `#12`, GitLab `!12`.
* Absent when the forge has no shorthand for this kind.
*/
sigil?: ForgeReferenceSigil;
}
export type ForgeReferenceSigil = "#" | "!";
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
@@ -54,8 +61,8 @@ export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
blobInfix: "/src/branch/",
lineAnchor: GITHUB_LINE_ANCHOR,
referencePaths: [
{ kind: "change_request", infix: "/pulls/" },
{ kind: "issue", infix: "/issues/" },
{ kind: "change_request", infix: "/pulls/", sigil: "!" },
{ kind: "issue", infix: "/issues/", sigil: "#" },
],
};
@@ -0,0 +1,35 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
export { createForgeShorthand, type ForgeShorthand } from "./shorthand";
const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
/** Makes `#12`-style references in descendant Markdown open on the workspace forge. */
export function ForgeShorthandProvider({
forge,
remoteUrl,
children,
}: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
children: ReactNode;
}) {
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
}
export function useForgeShorthand(): ForgeShorthand | null {
return useContext(ForgeShorthandContext);
}
/** Carries the shorthand environment across a portal. */
export function useForgeShorthandBridge() {
const value = useContext(ForgeShorthandContext);
return useCallback(
(children: ReactNode) => (
<ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>
),
[value],
);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createForgeShorthand } from "./shorthand";
function matches(shorthand: { pattern: string }, text: string): string[] {
return [...text.matchAll(new RegExp(shorthand.pattern, "g"))].map((match) => match[0]);
}
describe("createForgeShorthand", () => {
const github = createForgeShorthand({
forge: "github",
remoteUrl: "git@github.com:getpaseo/paseo.git",
})!;
const gitlab = createForgeShorthand({
forge: "gitlab",
remoteUrl: "https://gitlab.com/g/p.git",
})!;
it("resolves same-repo and cross-repo references on the remote's host", () => {
expect(github.resolve("#8613")).toBe("https://github.com/getpaseo/paseo/issues/8613");
expect(github.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
expect(gitlab.resolve("!7")).toBe("https://gitlab.com/g/p/-/merge_requests/7");
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
});
it("only recognizes the sigils the forge declares", () => {
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
expect(github.resolve("!2")).toBeNull();
expect(matches(gitlab, "see #1 and !2")).toEqual(["#1", "!2"]);
});
it("recognizes references only at word boundaries", () => {
expect(
matches(
github,
"(#1), #2. acme/tools#3, foo#4 #5abc &#6; https://x/#7 #0 #08 wow!9 user@host#10",
),
).toEqual(["#1", "#2", "acme/tools#3"]);
});
it("falls back to the cloud host forge when the daemon reports none", () => {
expect(
createForgeShorthand({ forge: undefined, remoteUrl: "git@github.com:a/b.git" })?.resolve(
"#1",
),
).toBe("https://github.com/a/b/issues/1");
});
it("is absent without a remote or a known forge", () => {
expect(createForgeShorthand({ forge: "github", remoteUrl: null })).toBeNull();
expect(
createForgeShorthand({ forge: undefined, remoteUrl: "git@git.corp:a/b.git" }),
).toBeNull();
});
});
@@ -0,0 +1,55 @@
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { forgeFromRemoteUrl } from "@/git/forge";
import { getClientForgeLogicModule } from "@/git/forges";
import { buildForgeReferenceUrl } from "@/git/forge-url";
/**
* Shorthand references in prose, such as `#12`, `!12` or `owner/repo#12`,
* resolved against one workspace remote.
*/
export interface ForgeShorthand {
/** Regex source for recognizing references in Markdown text. */
pattern: string;
/** Web URL for a recognized reference, or null. */
resolve(text: string): string | null;
}
// Keep these in the syntax shared by JavaScript, ICU and java.util.regex: the
// pattern runs in the native Markdown engines too.
const REPO = String.raw`[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+`;
const NUMBER = String.raw`[1-9][0-9]{0,8}`;
// A reference never continues a word, path, URL fragment or entity.
const BEFORE = String.raw`(?<![A-Za-z0-9_./@#!&-])`;
const AFTER = String.raw`(?![A-Za-z0-9_])`;
const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
export function createForgeShorthand(input: {
forge: string | null | undefined;
remoteUrl: string | null | undefined;
}): ForgeShorthand | null {
const remoteUrl = input.remoteUrl;
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
if (!forge || !remoteUrl) return null;
const sigils = forgeSigils(forge).filter(
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
);
if (sigils.length === 0) return null;
return {
pattern: `${BEFORE}(?:${REPO})?[${sigils.join("")}]${NUMBER}${AFTER}`,
resolve(text) {
const match = REFERENCE.exec(text);
if (!match) return null;
return buildForgeReferenceUrl(forge, {
remoteUrl,
repo: match[1] ?? null,
sigil: match[2] as ForgeReferenceSigil,
number: Number(match[3]),
});
},
};
}
function forgeSigils(forge: string): ForgeReferenceSigil[] {
const paths = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths ?? [];
return paths.flatMap((path) => (path.sigil ? [path.sigil] : []));
}
+68
View File
@@ -3,9 +3,77 @@ import {
buildForgeBlobUrl,
buildForgeBranchTreeUrl,
buildForgeChecksUrl,
buildForgeReferenceUrl,
hasForgeWebUrls,
} from "./forge-url";
describe("buildForgeReferenceUrl", () => {
it.each([
["github", "git@github.com:acme/repo.git", "#", null, "https://github.com/acme/repo/issues/12"],
["github", "git@github.com:acme/repo.git", "!", null, null],
[
"github",
"https://github.com/acme/repo",
"#",
"other/tools",
"https://github.com/other/tools/issues/12",
],
["gitlab", "git@gitlab.com:g/sub/p.git", "#", null, "https://gitlab.com/g/sub/p/-/issues/12"],
[
"gitlab",
"git@gitlab.com:g/sub/p.git",
"!",
null,
"https://gitlab.com/g/sub/p/-/merge_requests/12",
],
[
"gitlab",
"https://gitlab.corp:8443/g/p.git",
"!",
"g/q",
"https://gitlab.corp:8443/g/q/-/merge_requests/12",
],
[
"codeberg",
"https://codeberg.org/acme/repo.git",
"!",
null,
"https://codeberg.org/acme/repo/pulls/12",
],
[
"gitea",
"http://forge.test:3000/team/repo.git",
"#",
null,
"http://forge.test:3000/team/repo/issues/12",
],
[
"github",
"http://github.com/acme/repo.git",
"#",
null,
"https://github.com/acme/repo/issues/12",
],
[
"forgejo",
"git@forge.example:acme/repo.git",
"#",
null,
"https://forge.example/acme/repo/issues/12",
],
["bitbucket", "git@bitbucket.org:acme/repo.git", "#", null, null],
["github", null, "#", null, null],
["github", "git@github.com:acme/repo.git", "#", "../escape", null],
] as const)("builds %s %s %s in %s", (forge, remoteUrl, sigil, repo, expected) => {
expect(buildForgeReferenceUrl(forge, { remoteUrl, repo, sigil, number: 12 })).toBe(expected);
});
it("rejects non-positive numbers", () => {
const remoteUrl = "git@github.com:acme/repo.git";
expect(buildForgeReferenceUrl("github", { remoteUrl, sigil: "#", number: 0 })).toBeNull();
});
});
describe("buildForgeChecksUrl", () => {
it.each([
[
+46 -6
View File
@@ -12,6 +12,7 @@
*/
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
@@ -27,6 +28,14 @@ export interface ForgeBranchTreeUrlInput {
branch: string | null | undefined;
}
export interface ForgeReferenceUrlInput {
remoteUrl: string | null | undefined;
/** Repository path of a cross-repository reference; the remote's repository when absent. */
repo?: string | null;
sigil: ForgeReferenceSigil;
number: number;
}
export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): string | null {
const suffix = getClientForgeLogicModule(forge)?.urlGrammar?.changeRequestChecksSuffix;
if (!suffix) {
@@ -45,6 +54,8 @@ export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): st
}
interface ForgeWebLocation {
/** "http" only for a self-hosted http:// remote; every other remote browses over https. */
scheme: "http" | "https";
host: string;
/** Non-default port for a self-hosted http(s) origin, or undefined. */
port?: string;
@@ -87,16 +98,18 @@ function resolveForgeWebLocation(
!isCloudHost && (location.transport === "http" || location.transport === "https")
? location.port
: undefined;
return { host: webHost, port, repo: location.path };
const scheme = !isCloudHost && location.transport === "http" ? "http" : "https";
return { scheme, host: webHost, port, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
/** Host, plus `:port` when the remote pins a non-default port. */
function forgeAuthority(location: ForgeWebLocation): string {
return location.port ? `${location.host}:${location.port}` : location.host;
/** Scheme and host, plus `:port` when the remote pins a non-default port. */
function forgeOrigin(location: ForgeWebLocation): string {
const authority = location.port ? `${location.host}:${location.port}` : location.host;
return `${location.scheme}://${authority}`;
}
function normalizeBlobPath(path: string | null | undefined): string | null {
@@ -131,7 +144,7 @@ export function buildForgeBranchTreeUrl(
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
return `${forgeOrigin(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
@@ -143,13 +156,40 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
let url = `${forgeOrigin(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}
return url;
}
/**
* Web URL of a shorthand reference such as `#12` or `group/project!12`. A
* cross-repository reference stays on the remote's host. Null when the forge
* has no shorthand for the sigil.
*/
export function buildForgeReferenceUrl(
forge: string,
input: ForgeReferenceUrlInput,
): string | null {
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
(path) => path.sigil === input.sigil,
);
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const repo = input.repo ?? location?.repo;
if (
!referencePath ||
!location ||
!repo ||
!isValidRepoPath(repo) ||
!Number.isSafeInteger(input.number) ||
input.number <= 0
) {
return null;
}
return `${forgeOrigin(location)}/${repo}${referencePath.infix}${input.number}`;
}
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
export function hasForgeWebUrls(forge: string): boolean {
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
+2 -1
View File
@@ -89,7 +89,8 @@ export const githubForgeLogic = {
changeRequestChecksSuffix: "/checks",
referencePaths: [
{ kind: "change_request", infix: "/pull/" },
{ kind: "issue", infix: "/issues/" },
// GitHub redirects /issues/N to /pull/N, so `#N` covers both kinds.
{ kind: "issue", infix: "/issues/", sigil: "#" },
],
},
facts: defineForgeFacts({
+2 -2
View File
@@ -166,8 +166,8 @@ export const gitlabForgeLogic = {
lineAnchor: gitlabLineAnchor,
changeRequestChecksSuffix: "/pipelines",
referencePaths: [
{ kind: "change_request", infix: "/-/merge_requests/" },
{ kind: "issue", infix: "/-/issues/" },
{ kind: "change_request", infix: "/-/merge_requests/", sigil: "!" },
{ kind: "issue", infix: "/-/issues/", sigil: "#" },
],
},
facts: defineForgeFacts({
@@ -199,6 +199,83 @@ async function connected() {
return { ...h, stop };
}
it("keeps a restored plugin screen loading until its catalog arrives", async () => {
const h = harness();
expect(h.registry.getCatalogStatus("host")).toBe("loading");
const pending = Promise.withResolvers<Catalog>();
class PendingClient extends CatalogClient {
override getPluginCatalog() {
this.fetches++;
return pending.promise;
}
}
const client = new PendingClient();
h.sync({ client });
expect(h.registry.getCatalogStatus("host")).toBe("loading");
expect(h.registry.getSnapshot()).toEqual([]);
pending.resolve(client.catalog);
await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready");
expect(h.installed().id).toBe("reports");
expect(client.fetches).toBe(1);
});
it("settles an empty catalog without leaving missing plugin screens loading", async () => {
const h = harness();
h.client.catalog = [];
const snapshot = h.registry.getSnapshot();
const statuses: string[] = [];
cleanups.push(
h.registry.subscribe(() => {
statuses.push(h.registry.getCatalogStatus("host"));
}),
);
h.sync();
await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready");
expect(h.registry.getSnapshot()).toBe(snapshot);
expect(statuses).toEqual(["loading", "ready"]);
});
it("settles a failed catalog request instead of loading indefinitely", async () => {
const h = harness();
class FailingClient extends CatalogClient {
override async getPluginCatalog(): Promise<Catalog> {
throw new Error("Catalog request failed");
}
}
h.sync({ client: new FailingClient() });
await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("error");
expect(h.registry.getSnapshot()).toEqual([]);
});
it("ignores an old failed request after a new connection loads its catalog", async () => {
const h = harness();
const pending = Promise.withResolvers<Catalog>();
const started = Promise.withResolvers<void>();
class PendingClient extends CatalogClient {
override getPluginCatalog() {
started.resolve();
return pending.promise;
}
}
const stop = h.sync({ client: new PendingClient() });
await started.promise;
stop();
h.sync();
await expect.poll(() => h.registry.getCatalogStatus("host")).toBe("ready");
const plugin = h.installed();
pending.reject(new Error("Old connection failed"));
await pending.promise.catch(() => undefined);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(h.registry.getCatalogStatus("host")).toBe("ready");
expect(h.installed()).toBe(plugin);
});
function settingsQuery(plugin: InstalledPlugin) {
return {
queryKey: pluginSettingsKey("preferences"),
+14 -5
View File
@@ -9,7 +9,10 @@ interface CatalogSyncInput {
audio: Pick<AudioEngine, "play"> | null;
connected: boolean;
supported: boolean;
registry: Pick<PluginRegistry, "installCatalog" | "getSnapshot" | "removeHost">;
registry: Pick<
PluginRegistry,
"installCatalog" | "getSnapshot" | "removeHost" | "setCatalogStatus"
>;
}
export function syncPluginCatalog({
@@ -22,30 +25,36 @@ export function syncPluginCatalog({
}: CatalogSyncInput): () => void {
if (!supported || !audio) {
registry.removeHost(serverId);
registry.setCatalogStatus(serverId, "ready");
return () => {};
}
// A dropped socket reconnects on the same client, so installations keep rendering offline.
if (!connected) return () => {};
registry.setCatalogStatus(serverId, "loading");
const installationOptions = { client, audio };
let cancelled = false;
let refreshQueue = Promise.resolve();
function refresh(replacePluginId?: string) {
refreshQueue = refreshQueue.then(() =>
client
refreshQueue = refreshQueue.then(() => {
if (cancelled) return undefined;
registry.setCatalogStatus(serverId, "loading");
return client
.getPluginCatalog()
.then((catalog) => {
if (!cancelled) {
registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId });
registry.setCatalogStatus(serverId, "ready");
}
return undefined;
})
.catch((error) => {
if (!cancelled) {
registry.setCatalogStatus(serverId, "error");
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
}
return undefined;
}),
);
});
});
return refreshQueue;
}
const observation = client.observeEvents([
+27 -1
View File
@@ -13,6 +13,7 @@ import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
type CatalogPlugin = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>[number];
export type PluginCatalogStatus = "loading" | "ready" | "error";
export class PluginRegistry {
private readonly byHost = new Map<string, InstalledPlugin[]>();
@@ -20,6 +21,7 @@ export class PluginRegistry {
private snapshot: InstalledPlugin[] = [];
private readonly disposed = new WeakSet<InstalledPlugin>();
private readonly evaluationErrors = new Map<string, string>();
private readonly catalogStatuses = new Map<string, PluginCatalogStatus>();
/** The client each host's installations were bound to; their Paseo API and RPCs call it. */
private readonly clientByHost = new Map<string, DaemonClient>();
@@ -42,6 +44,16 @@ export class PluginRegistry {
return this.evaluationErrors.get(`${serverId}/${pluginId}`);
}
getCatalogStatus(serverId: string): PluginCatalogStatus {
return this.catalogStatuses.get(serverId) ?? "loading";
}
setCatalogStatus(serverId: string, status: PluginCatalogStatus): void {
if (this.catalogStatuses.get(serverId) === status) return;
this.catalogStatuses.set(serverId, status);
for (const listener of this.listeners) listener();
}
installCatalog(
serverId: string,
catalog: CatalogPlugin[],
@@ -177,8 +189,14 @@ export class PluginRegistry {
}
removeHost(serverId: string): void {
const hadCatalog = this.catalogStatuses.delete(serverId);
const installed = this.byHost.get(serverId);
if (!installed) return;
if (!installed) {
if (hadCatalog) {
for (const listener of this.listeners) listener();
}
return;
}
for (const plugin of installed) this.dispose(plugin);
for (const key of this.evaluationErrors.keys()) {
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
@@ -253,6 +271,14 @@ export function useInstalledPlugin(serverId: string, pluginId: string): Installe
);
}
export function usePluginCatalogStatus(serverId: string): PluginCatalogStatus {
return useSyncExternalStore(
pluginRegistry.subscribe,
() => pluginRegistry.getCatalogStatus(serverId),
() => pluginRegistry.getCatalogStatus(serverId),
);
}
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
const installed = useInstalledPlugins();
return useMemo(() => installed.filter((plugin) => plugin.id === pluginId), [installed, pluginId]);
@@ -8,8 +8,25 @@ import {
parsePluginOpenScreenInput,
resolvePluginScreenTitle,
resolvePluginSurfaceContribution,
resolvePluginSurfacePlaceholder,
} from "./surface-contribution";
it("shows loading instead of unavailable while the host catalog is pending", () => {
expect(
resolvePluginSurfacePlaceholder({ connectionStatus: "online", catalogStatus: "loading" }),
).toBe("loading");
});
it.each([
["online", "ready", "unavailable"],
["online", "error", "error"],
["connecting", "ready", "loading"],
["offline", "loading", "offline"],
["error", "loading", "offline"],
] as const)("shows %s host with %s catalog as %s", (connectionStatus, catalogStatus, expected) => {
expect(resolvePluginSurfacePlaceholder({ connectionStatus, catalogStatus })).toBe(expected);
});
function installation(
serverId: string,
surfaces: string[],
@@ -5,6 +5,26 @@ import {
pluginScreenParamsFromRoute,
} from "./routes";
import type { InstalledPlugin } from "./types";
import type { PluginCatalogStatus } from "./registry";
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
export type PluginSurfacePlaceholderStatus = "loading" | "offline" | "error" | "unavailable";
interface PluginSurfacePlaceholderInput {
connectionStatus: HostRuntimeConnectionStatus;
catalogStatus: PluginCatalogStatus;
}
export function resolvePluginSurfacePlaceholder({
connectionStatus,
catalogStatus,
}: PluginSurfacePlaceholderInput): PluginSurfacePlaceholderStatus {
if (connectionStatus === "offline" || connectionStatus === "error") return "offline";
if (connectionStatus !== "online") return "loading";
if (catalogStatus === "loading") return "loading";
if (catalogStatus === "error") return "error";
return "unavailable";
}
export type PluginSurfaceContributionIdentity =
| { kind: "sidebar"; id: string }
+40 -5
View File
@@ -12,14 +12,19 @@ import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostFilter } from "@/components/hosts/host-filter";
import type { HostPickerHost } from "@/components/hosts/host-picker";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import {
useHostRuntimeClient,
useHostRuntimeConnectionStatus,
useHosts,
} from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { usePluginHostNavigation } from "./host-navigation";
import { PluginIconView } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { useInstalledPlugin, usePluginCatalogStatus, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
import {
legacySidebarItemHostKey,
@@ -32,7 +37,9 @@ import {
getPluginSurfaceContributionServerIds,
resolvePluginScreenTitle,
resolvePluginSurfaceContribution,
resolvePluginSurfacePlaceholder,
type PluginSurfaceContributionIdentity,
type PluginSurfacePlaceholderStatus,
} from "./surface-contribution";
import { resolvePluginPlatform } from "./platform";
@@ -42,6 +49,28 @@ const pluginThemeMapping = (theme: Theme) => ({
theme: toPluginTheme(theme),
});
const ThemedX = withUnistyles(X);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const SURFACE_ERRORS = {
offline: "Plugin host is offline.",
error: "Could not load plugins. Reconnect to try again.",
unavailable: "This plugin surface is unavailable.",
} as const;
function PluginSurfacePlaceholder({ status }: { status: PluginSurfacePlaceholderStatus }) {
if (status === "loading") {
return (
<View
testID="plugin-surface-loading"
accessibilityRole="progressbar"
accessibilityLabel="Loading plugin"
style={styles.loading}
>
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
</View>
);
}
return <Text style={styles.errorText}>{SURFACE_ERRORS[status]}</Text>;
}
function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
@@ -137,6 +166,9 @@ export function PluginSurfaceScreen() {
const installations = usePluginInstallations(pluginId);
const hosts = useHosts();
const client = useHostRuntimeClient(serverId);
const connectionStatus = useHostRuntimeConnectionStatus(serverId);
const catalogStatus = usePluginCatalogStatus(serverId);
const placeholderStatus = resolvePluginSurfacePlaceholder({ connectionStatus, catalogStatus });
const compact = useIsCompactFormFactor();
const { sidebarItem, surface } = useMemo(
() => resolvePluginSurfaceContribution(plugin, identity),
@@ -239,9 +271,7 @@ export function PluginSurfaceScreen() {
/>
</SurfaceErrorBoundary>
) : (
<Text style={styles.errorText}>
{plugin && surface ? "Plugin host is offline." : "This plugin surface is unavailable."}
</Text>
<PluginSurfacePlaceholder status={placeholderStatus} />
)}
</View>
</View>
@@ -256,6 +286,11 @@ const styles = StyleSheet.create((theme) => ({
body: {
flex: 1,
},
loading: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
@@ -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,
@@ -224,7 +224,7 @@ async function fetchApi(url, init) {
return new Response(JSON.stringify({ account: { uuid: second ? 'claude-two' : 'claude-one' }, organization: { uuid: second ? 'org-two' : 'org-one' } }));
}
if (token === 'Bearer fixture-revoked') return new Response(null, { status: 401 });
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-opencode-db': 23, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } }));
}
export default function contribute(server) {
@@ -270,6 +270,31 @@ export default function contribute(server) {
(await client.listUsageReports({ reportIds: ["codex:chatgpt-one"], forceRefresh: true }))
.reports[0];
expect((await refresh())?.report.windows[0]?.usedPct).toBe(22);
const openCodeDatabase = path.join(path.dirname(openCodePath), "opencode.db");
const openCodeDb = new DatabaseSync(openCodeDatabase);
openCodeDb.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
// OpenCode's v1 migration leaves active NULL and does not update auth.json.
openCodeDb.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, NULL, 1, 1)").run(
"migrated-login",
"openai",
"OpenAI",
JSON.stringify({
type: "oauth",
access: "fixture-opencode-db",
expires: Date.now() + 60_000,
metadata: { accountID: "chatgpt-one" },
}),
);
openCodeDb.close();
const rediscover = async () =>
(await client.listUsageReports({ forceRefresh: true })).reports.find(
(entry) => entry.id === "codex:chatgpt-one",
);
expect((await rediscover())!.report.windows[0]!.usedPct).toBe(23);
await rm(openCodeDatabase);
expect((await rediscover())!.report.windows[0]!.usedPct).toBe(22);
await rm(openCodePath);
expect((await refresh())?.report.windows[0]?.usedPct).toBe(33);
await json(piPath, {
@@ -78,6 +78,27 @@ async function database(path: string) {
db.close();
}
async function opencodeDatabase(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
return db;
}
function opencodeOauth(access: string, expires: number, accountId?: string) {
return JSON.stringify({
type: "oauth",
methodID: "chatgpt-headless",
access,
refresh: "fixture-refresh",
expires,
...(accountId ? { metadata: { accountID: accountId } } : {}),
});
}
test("Pi uses its own OAuth identity and re-reads rotated tokens without writing", async () => {
const path = join(home, ".pi", "agent", "auth.json");
await json(path, {
@@ -260,3 +281,269 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login
),
).rejects.toThrow("login store no longer exists");
});
function opencodeUsageResponse() {
return Response.json({
plan_type: "plus",
rate_limit: {
primary_window: { used_percent: 30, limit_window_seconds: 18000 },
secondary_window: { used_percent: 81, limit_window_seconds: 604800 },
},
});
}
test("OpenCode v2 database login replaces a stale v1 file login", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
await json(join(data, "opencode", "auth.json"), {
openai: { type: "oauth", access: "fixture-expired", expires: 500 },
});
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const inputs = await logins(lookup(env));
expect(inputs).toEqual([
{ route: { store: "opencode-db", path: databasePath, credentialId: "cred_1" } },
]);
expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "db-account" });
const report = await fetchUsage(
inputs[0]!,
async (_url, init) => {
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer fixture-db");
expect(headers.get("ChatGPT-Account-Id")).toBe("db-account");
return opencodeUsageResponse();
},
lookup(env),
);
expect(report).toMatchObject({
status: "available",
planLabel: "plus",
windows: [
{ id: "five_hour", usedPct: 30 },
{ id: "weekly", usedPct: 81 },
],
});
});
test("OpenCode v2 database login with an expired token is unavailable without a request", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-expired", 500, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const [input] = await logins(lookup(env));
expect(
await fetchUsage(
input!,
async () => {
throw new Error("must not fetch an expired token");
},
lookup(env),
),
).toEqual({
status: "unavailable",
problem: { kind: "expired", expiresAt: new Date(500).toISOString(), refreshedBy: "opencode" },
});
});
test("OpenCode v2 database is unchanged after its login is read", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const before = await readFile(databasePath);
const [input] = await logins(lookup(env));
await fetchUsage(input!, async () => opencodeUsageResponse(), lookup(env));
expect(await readFile(databasePath)).toEqual(before);
});
test("OpenCode v2 database login is re-read after the stored token rotates", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-first", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const [input] = await logins(lookup(env));
const { DatabaseSync } = await import("node:sqlite");
const writer = new DatabaseSync(databasePath);
writer
.prepare("UPDATE credential SET value = ? WHERE id = 'cred_1'")
.run(opencodeOauth("fixture-rotated", 2000, "db-account"));
writer.close();
const report = await fetchUsage(
input!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-rotated");
return opencodeUsageResponse();
},
lookup(env),
);
expect(report.status).toBe("available");
});
test.each([
["there is no v2 database", null],
[
"the v2 database holds no OpenAI login",
{ integration: "zenai", value: JSON.stringify({ type: "key", key: "fixture-key" }) },
],
[
"the v2 OpenAI login is not active",
{
integration: "openai",
value: opencodeOauth("fixture-inactive", 2000, "db-account"),
active: 0,
},
],
])("OpenCode v1 file login stays when %s", async (_name, row) => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const file = join(data, "opencode", "auth.json");
await json(file, { openai: { type: "oauth", access: "fixture-file", expires: 2000 } });
if (row) {
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
row.integration,
"OpenAI",
row.value,
null,
"chatgpt-headless",
"active" in row ? row.active : 1,
1,
1,
);
db.close();
}
expect(await logins(lookup(env))).toEqual([{ route: { store: "opencode", path: file } }]);
});
test.each(["absolute", "relative"])(
"OpenCode v2 database path honours %s OPENCODE_DB",
async (kind) => {
const data = join(home, "data");
const databasePath = join(data, "opencode", "custom-opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
expect(
await logins(
lookup({
XDG_DATA_HOME: data,
OPENCODE_DB: kind === "absolute" ? databasePath : "custom-opencode.db",
}),
),
).toEqual([{ route: { store: "opencode-db", path: databasePath, credentialId: "cred_1" } }]);
},
);
test.each([false, true])(
"OpenCode v2 selects its current login after legacy migration (explicit selection: %s)",
async (selected) => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
const insert = db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
// The v1 migration leaves active NULL; OpenCode uses creation order until a login is selected.
insert.run(
"older",
"openai",
"OpenAI",
opencodeOauth("fixture-older", 2000, "older-account"),
null,
"chatgpt-headless",
selected ? 1 : null,
1,
3,
);
insert.run(
"newer",
"openai",
"OpenAI",
opencodeOauth("fixture-newer", 2000, "newer-account"),
null,
"chatgpt-headless",
null,
2,
2,
);
db.close();
const inputs = await logins(lookup(env));
expect(inputs).toEqual([
{
route: {
store: "opencode-db",
path: databasePath,
credentialId: selected ? "older" : "newer",
},
},
]);
expect(await accountIdentity(inputs[0]!, env)).toEqual({
key: selected ? "older-account" : "newer-account",
});
},
);
@@ -27,6 +27,22 @@ export const oauthSchema = z.object({
const rowSchema = z.object({ id: z.number().int().positive(), data: z.string() });
export type Route = z.infer<typeof routeSchema>;
export interface OpencodeOauthLogin {
id: string;
access: string;
accountId?: string;
expires?: number;
}
const opencodeRowSchema = z.object({ id: z.string(), value: z.string() });
const opencodeColumnSchema = z.object({ name: z.string() });
const opencodeCredentialSchema = z.object({
type: z.literal("oauth"),
access: z.string().min(1),
expires: z.number().optional(),
metadata: z.object({ accountID: z.string().min(1).optional() }).optional(),
});
export async function readJson(path: string): Promise<unknown> {
try {
return JSON.parse(await readFile(path, "utf8"));
@@ -47,6 +63,20 @@ export function piAuthPath(lookup: StoreLookup): string {
return join(directory, "auth.json");
}
export interface OpencodeStorePaths {
file: string;
database: string;
}
export function opencodeStorePaths(env: NodeJS.ProcessEnv, home: string): OpencodeStorePaths {
const dataHome = env.XDG_DATA_HOME || join(home, ".local", "share");
const directory = join(dataHome, "opencode");
return {
file: join(directory, "auth.json"),
database: resolve(directory, env.OPENCODE_DB || "opencode.db"),
};
}
function ompDatabasePath(lookup: StoreLookup): string {
const home = lookup.home ?? homedir();
const env = lookup.env ?? process.env;
@@ -118,6 +148,61 @@ export function readOmp(route: Extract<Route, { store: "omp" }>, lookup: StoreLo
return ompRows(route.path, lookup).find((row) => row.id === route.credentialId)?.oauth ?? null;
}
function parseCredential(value: string): unknown | null {
try {
return JSON.parse(value);
} catch (error) {
if (error instanceof SyntaxError) return null;
throw error;
}
}
/**
* OpenCode v2 keeps its logins in its own database and no longer writes the v1
* credential file, so the file alone cannot describe the active ChatGPT login.
*/
export function readOpencodeOauth(path: string, lookup: StoreLookup = {}): OpencodeOauthLogin[] {
const sqlite = (lookup.sqlite ?? builtinSqlite)();
if (!sqlite || !existsSync(path)) return [];
let db: Database | undefined;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true });
const columns = db
.prepare("PRAGMA table_info(credential)")
.all()
.map((value) => opencodeColumnSchema.safeParse(value));
if (!columns.some((column) => column.success && column.data.name === "integration_id")) {
return [];
}
const rows = db
.prepare(
// Migrated v1 rows have no active flag. OpenCode selects the newest
// created login until an explicit selection pins another one.
"SELECT id, value FROM credential WHERE integration_id = ? AND (active = 1 OR active IS NULL) ORDER BY active DESC, time_created DESC, id DESC LIMIT 1",
)
.all("openai");
return rows.flatMap((value) => {
const row = opencodeRowSchema.safeParse(value);
if (!row.success) return [];
const credential = opencodeCredentialSchema.safeParse(parseCredential(row.data.value));
if (!credential.success) return [];
const accountId = credential.data.metadata?.accountID;
return [
{
id: row.data.id,
access: credential.data.access,
...(accountId ? { accountId } : {}),
...(credential.data.expires !== undefined ? { expires: credential.data.expires } : {}),
},
];
});
} catch {
return [];
} finally {
db?.close();
}
}
export async function readHarness(
route: Extract<Route, { path: string }>,
lookup: StoreLookup = {},
@@ -230,6 +230,72 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => {
}
});
test("OpenCode v2 database login reports usage while the v1 file login expired", async () => {
const directory = await mkdtemp(join(tmpdir(), "usage-opencode-v2-"));
try {
const data = join(directory, "data");
await mkdir(join(data, "opencode"), { recursive: true });
await writeFile(
join(data, "opencode", "auth.json"),
JSON.stringify({ openai: { type: "oauth", access: "fixture-expired", expires: 500 } }),
);
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(join(data, "opencode", "opencode.db"));
db.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
JSON.stringify({
type: "oauth",
methodID: "chatgpt-headless",
access: "fixture-db",
expires: 2000,
metadata: { accountID: "db-account" },
}),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const lookup = {
home: directory,
env: { XDG_DATA_HOME: data },
platform: "linux" as const,
now: () => 1000,
};
const accounts = await discover({ kind: "global" }, lookup);
expect(accounts.map((account) => account.input)).toEqual([
{
route: {
store: "opencode-db",
path: join(data, "opencode", "opencode.db"),
credentialId: "cred_1",
},
},
]);
const report = await fetchUsage(
inputSchema.parse(accounts[0]!.input),
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-db");
return Response.json({
plan_type: "plus",
rate_limit: { primary_window: { used_percent: 30, limit_window_seconds: 18000 } },
});
},
lookup,
);
expect(report).toMatchObject({ status: "available", planLabel: "plus" });
} finally {
await rm(directory, { recursive: true, force: true });
}
});
async function accountFor(input: CodexUsageInput) {
const accounts = await discover(
{ kind: "global" },
+51 -17
View File
@@ -1,4 +1,12 @@
import { discoverOmp, piAuthPath, readHarness, readJson, type StoreLookup } from "./stores.js";
import {
discoverOmp,
opencodeStorePaths,
piAuthPath,
readHarness,
readJson,
readOpencodeOauth,
type StoreLookup,
} from "./stores.js";
import { homedir } from "node:os";
import { join } from "node:path";
import {
@@ -88,19 +96,33 @@ function globalRoutes(lookup: StoreLookup): CodexUsageInput[] {
const candidates: CodexUsageInput[] = [...new Set(paths)].map((path) => ({
route: { store: "codex", path },
}));
candidates.push(...opencodeRoutes(env, home, lookup));
candidates.push(
{
route: {
store: "opencode",
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
},
},
{ route: { store: "pi", path: piAuthPath(lookup) } },
...discoverOmp(lookup).map((route) => ({ route })),
);
return candidates;
}
/**
* OpenCode v2 stores its logins in a database and leaves the v1 credential file
* untouched, so a file login can be stale while the database login is current.
* The database wins whenever it holds an OpenAI login; the file stays the
* source for v1 installations that have no such database.
*/
function opencodeRoutes(
env: NodeJS.ProcessEnv,
home: string,
lookup: StoreLookup,
): CodexUsageInput[] {
const { file, database } = opencodeStorePaths(env, home);
const logins = readOpencodeOauth(database, lookup);
if (!logins.length) return [{ route: { store: "opencode", path: file } }];
return logins.map((login) => ({
route: { store: "opencode-db", path: database, credentialId: login.id },
}));
}
function sessionRoutes(
scope: Extract<UsageScope, { kind: "session" }>,
lookup: StoreLookup,
@@ -119,14 +141,7 @@ function sessionRoutes(
if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }];
if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route }));
if (scope.provider === "opencode")
return [
{
route: {
store: "opencode",
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
},
},
];
return opencodeRoutes(scope.env, lookup.home ?? homedir(), lookup);
return [];
}
@@ -135,6 +150,14 @@ export async function readAuth(
lookup: StoreLookup = {},
): Promise<Auth | null> {
const route = input.route;
if (route.store === "opencode-db") {
const login = readOpencodeOauth(route.path, lookup).find(
(candidate) => candidate.id === route.credentialId,
);
return login
? { token: login.access, accountId: login.accountId, expires: login.expires }
: null;
}
if (route.store !== "codex") {
const oauth = await readHarness(route, lookup);
return oauth
@@ -191,7 +214,7 @@ export async function fetchUsage(
): Promise<UsageReport> {
const auth = await readAuth(input, lookup);
if (!auth) throw new Error("Codex login store no longer exists");
const refreshedBy = input.route.store;
const refreshedBy = refreshCommand(input.route.store);
if (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)())
return unavailable({
kind: "expired",
@@ -297,6 +320,17 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab
}
function harnessLabel(input: CodexUsageInput): string {
const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" };
const labels = {
codex: "Codex",
opencode: "OpenCode",
"opencode-db": "OpenCode",
pi: "Pi",
omp: "OMP",
};
return labels[input.route.store];
}
/** The CLI that refreshes the login behind each store. */
function refreshCommand(store: CodexUsageInput["route"]["store"]): string {
return store === "opencode-db" ? "opencode" : store;
}
@@ -2,6 +2,13 @@ import { z } from "zod";
export const routeSchema = z.discriminatedUnion("store", [
z.object({ store: z.enum(["codex", "opencode", "pi"]), path: z.string().min(1) }).strict(),
z
.object({
store: z.literal("opencode-db"),
path: z.string().min(1),
credentialId: z.string().min(1),
})
.strict(),
z
.object({
store: z.literal("omp"),