Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"core:window:allow-close",
"notification:default",
"opener:default",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "cursor:*" }, { "url": "vscode:*" }]
},
"websocket:default",
"window-state:default",
"dialog:default",
Expand Down
5 changes: 3 additions & 2 deletions desktop/src/features/messages/lib/openPopoverLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import type { ParsedMessageLink } from "./messageLink";
/**
* Open a link the same way the rendered-message link path does:
* `buzz://message?…` deep-links navigate in-app, everything else (http(s),
* other buzz:// URLs) goes to the OS opener. Mirrors `markdown.tsx`'s `a`
* renderer so the composer popover and the rendered link behave identically.
* editor deep links, other buzz:// URLs) goes to the OS opener. Mirrors
* `markdown.tsx`'s `a` renderer so the composer popover and the rendered
* link behave identically.
*/
export function openPopoverLink(
url: string,
Expand Down
47 changes: 47 additions & 0 deletions desktop/src/features/messages/lib/remarkEditorDeepLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Remark plugin that detects bare `cursor://…` and `vscode://…` URLs in text
* nodes and replaces each with a standard mdast `link` so the markdown `a`
* renderer (and OS opener) can open them.
*
* Why this plugin exists: `remark-gfm`'s autolinker only covers `http(s)://`
* and `www.`. Editor deep links only reach the `<a>` override when written as
* an explicit `[label](cursor://…)` markdown link — or when this plugin
* promotes a bare URL into a link node.
*
* Mirrors `remarkMessageLinks` trailing-punctuation handling so a URL pasted
* at end-of-sentence still keeps `.` / `,` / `)` outside the href.
*/
import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts";

const EDITOR_DEEP_LINK_PATTERN = /(?:cursor|vscode):\/\/[^\s<>"')\]]+/g;
const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/;

function trimEditorDeepLinkMatch(matchText: string) {
let value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, "");
while (/[)\]]$/.test(value) && isUnmatchedClosing(value)) {
value = value.slice(0, -1).replace(TRAILING_PUNCTUATION_PATTERN, "");
}
return { value, trailing: matchText.slice(value.length) };
}

function isUnmatchedClosing(value: string): boolean {
const closing = value[value.length - 1];
const opening = closing === ")" ? "(" : "[";
return value.split(closing).length > value.split(opening).length;
}

export default function remarkEditorDeepLinks() {
return createRemarkPrefixPlugin(EDITOR_DEEP_LINK_PATTERN, (matchText) => {
const { value, trailing } = trimEditorDeepLinkMatch(matchText);

return {
node: {
type: "link",
url: value,
title: null,
children: [{ type: "text", value }],
},
trailing,
};
});
}
9 changes: 5 additions & 4 deletions desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isMacPlatform,
} from "@/shared/lib/platform";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import { EDITOR_DEEP_LINK_TIPTAP_PROTOCOLS } from "@/shared/lib/url";

import { resolveLinkAt, type LinkSelectionInfo } from "./resolveLinkAt";

Expand Down Expand Up @@ -465,10 +466,10 @@ export function useRichTextEditor({
openOnClick: false,
autolink: true,
linkOnPaste: true,
// Allow Buzz message links through TipTap's URL sanitiser.
// http(s) and mailto are accepted by default; non-listed protocols are
// stripped on paste/typed input.
protocols: ["buzz"],
// Allow Buzz message links and editor deep links through TipTap's
// URL sanitiser. http(s) and mailto are accepted by default;
// non-listed protocols are stripped on paste/typed input.
protocols: ["buzz", ...EDITOR_DEEP_LINK_TIPTAP_PROTOCOLS],
HTMLAttributes: {
class: "text-primary underline underline-offset-4 cursor-text",
},
Expand Down
37 changes: 37 additions & 0 deletions desktop/src/shared/lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,40 @@ export function isSafeUrl(url: string | undefined): url is string {
return false;
}
}

/**
* Editor deep-link schemes that Buzz may render and hand to the OS opener.
*
* Keep this list tight: custom schemes bypass the browser and invoke whatever
* app registered the protocol. Do not add schemes that can execute code
* (`javascript:`) or that lack a clear local-app owner.
*/
export const EDITOR_DEEP_LINK_PROTOCOLS = ["cursor:", "vscode:"] as const;

export type EditorDeepLinkProtocol =
(typeof EDITOR_DEEP_LINK_PROTOCOLS)[number];

/**
* TipTap Link `protocols` entries (scheme without trailing colon).
* http(s) and mailto are accepted by TipTap by default.
*/
export const EDITOR_DEEP_LINK_TIPTAP_PROTOCOLS = EDITOR_DEEP_LINK_PROTOCOLS.map(
(protocol) => protocol.slice(0, -1),
);

/**
* True when `url` is a cursor:// or vscode:// deep link the OS can hand off
* to the registered editor. Used by the markdown renderer and click path so
* react-markdown's defaultUrlTransform does not strip the href before open.
*/
export function isEditorDeepLink(url: string | undefined): url is string {
if (!url) return false;
try {
const parsed = new URL(url);
return (EDITOR_DEEP_LINK_PROTOCOLS as readonly string[]).includes(
parsed.protocol,
);
} catch {
return false;
}
}
64 changes: 59 additions & 5 deletions desktop/src/shared/ui/markdown.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -521,23 +521,25 @@ test("rehypeImageGallery: leaves a single trailing image in the text flow", () =

// Regression test: react-markdown's `defaultUrlTransform` strips unknown
// schemes (returns `""`) before our `a` component override can see them,
// which would break copy → paste → click for `buzz://message?…` links
// end-to-end. We pass a custom `urlTransform` that delegates to the
// default for `buzz://message` and legacy `buzz://message` hrefs.
// which would break copy → paste → click for `buzz://message?…` and editor
// deep links (`cursor://…`, `vscode://…`) end-to-end. We pass a custom
// `urlTransform` that preserves those hrefs and delegates everything else
// to the default.
//
// This test renders real `<ReactMarkdown>` with the production transform
// and asserts the link href survives to the rendered DOM. Mirrors the
// `markdown.tsx` source — keep in sync if either changes.
// `markdown/utils.ts` source — keep in sync if either changes.

import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";

import { isMessageLink } from "../../features/messages/lib/messageLink.ts";
import { isEditorDeepLink } from "../lib/url.ts";
import remarkSpoilers from "../lib/remarkSpoilers.ts";

function messageLinkUrlTransform(value, key) {
if (key === "href" && isMessageLink(value)) {
if (key === "href" && (isMessageLink(value) || isEditorDeepLink(value))) {
return value;
}
return defaultUrlTransform(value);
Expand Down Expand Up @@ -585,6 +587,21 @@ test("messageLinkUrlTransform: passes http(s) through unchanged", () => {
assert.match(html, /href="https:\/\/example\.com\/path"/);
});

test("messageLinkUrlTransform: preserves cursor:// file deep link", () => {
const html = renderMarkdown(
"[open](cursor://file/Users/example/agent-kit/README.md)",
);
assert.match(
html,
/href="cursor:\/\/file\/Users\/example\/agent-kit\/README\.md"/,
);
});

test("messageLinkUrlTransform: preserves vscode:// deep link", () => {
const html = renderMarkdown("[open](vscode://file/Users/example/agent-kit)");
assert.match(html, /href="vscode:\/\/file\/Users\/example\/agent-kit"/);
});

test("messageLinkUrlTransform: preserves legacy buzz://message href", () => {
const html = renderMarkdown(
"Click [here](buzz://message?channel=abc&id=xyz)",
Expand Down Expand Up @@ -738,6 +755,43 @@ test("remarkMessageLinks: non-message buzz:// URLs are not matched", () => {
assert.equal(kids[0].value, original);
});

import remarkEditorDeepLinks from "../../features/messages/lib/remarkEditorDeepLinks.ts";

function runEditorDeepLinkPlugin(tree) {
remarkEditorDeepLinks()(tree);
return tree;
}

test("remarkEditorDeepLinks: bare cursor:// URL becomes a link node", () => {
const href = "cursor://file/Users/example/agent-kit";
const tree = runEditorDeepLinkPlugin(paragraph(text(href)));
const kids = tree.children[0].children;
assert.equal(kids.length, 1);
assert.equal(kids[0].type, "link");
assert.equal(kids[0].url, href);
assert.equal(kids[0].children[0].value, href);
});

test("remarkEditorDeepLinks: bare vscode:// URL becomes a link node", () => {
const href = "vscode://file/Users/example/agent-kit/README.md";
const tree = runEditorDeepLinkPlugin(paragraph(text(href)));
const kids = tree.children[0].children;
assert.equal(kids.length, 1);
assert.equal(kids[0].type, "link");
assert.equal(kids[0].url, href);
});

test("remarkEditorDeepLinks: trailing punctuation stays outside URL", () => {
const href = "cursor://file/Users/example/agent-kit";
const tree = runEditorDeepLinkPlugin(paragraph(text(`open ${href}.`)));
const kids = tree.children[0].children;
assert.equal(kids.length, 3);
assert.equal(kids[0].value, "open ");
assert.equal(kids[1].type, "link");
assert.equal(kids[1].url, href);
assert.equal(kids[2].value, ".");
});

test("remarkMessageLinks: text inside inlineCode is left alone", () => {
// The shared factory's tree walker descends into all non-text nodes; an
// `inlineCode` node has its URL stored in `value` (not children), so the
Expand Down
83 changes: 1 addition & 82 deletions desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
ZoomOut,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { toast } from "sonner";

import { useAppNavigation } from "@/app/navigation/useAppNavigation";
Expand All @@ -23,7 +22,6 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
extractSupportedLinkPreviews,
parseSupportedLinkPreview,
Expand Down Expand Up @@ -108,7 +106,7 @@ import {
visibleImageGalleryForTrigger,
} from "./markdown/imageLightbox";
import { MarkdownTable } from "./markdown/MarkdownTable";
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor";
import { ProgressiveImage } from "./markdown/ProgressiveImage";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { renderCachedMarkdown } from "./markdown/nodeCache";
Expand Down Expand Up @@ -1272,85 +1270,6 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) {
);
}

/**
* An external `[text](href)` link with a custom right-click menu.
*
* Buzz renders inside a native webview whose default context menu has no
* useful link actions, so a plain right-click on a link is a no-op. This adds
* an in-app menu with "Open link" (via the OS opener, matching the anchor's
* left-click `target="_blank"` behavior) and "Copy link" (the real href, not
* the masked display text).
*/
function ExternalLinkAnchor({
anchorProps,
children,
href,
isLinearLink,
label,
}: {
anchorProps: React.ComponentPropsWithoutRef<"a">;
children: React.ReactNode;
href: string | undefined;
isLinearLink: boolean;
label: string;
}) {
const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setMenu(null), []);
useDismissMediaContextMenu(Boolean(menu), closeMenu);

const anchor = (
<a
{...anchorProps}
className={cn(
"font-medium underline underline-offset-4 transition-colors",
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
)}
href={href}
onContextMenuCapture={(event) => {
if (!href) return;
event.preventDefault();
setMenu({ x: event.clientX, y: event.clientY });
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);

return (
<>
<MaskedLinkTooltip disabled={isLinearLink} href={href} label={label}>
{anchor}
</MaskedLinkTooltip>
{menu && href ? (
<MediaContextMenu
dataAttributes={["data-link-context-menu"]}
items={[
{
label: "Open link",
onSelect: () => {
closeMenu();
void openUrl(href).catch(() => {
toast.error("Failed to open link");
});
},
},
{
label: "Copy link",
onSelect: () => {
closeMenu();
copyTextToClipboard(href, "Link copied to clipboard");
},
},
]}
position={menu}
/>
) : null}
</>
);
}

function createMarkdownComponents(
interactive = true,
mediaInset = false,
Expand Down
Loading