diff --git a/src/bundled/github/GitHubPanel.test.tsx b/src/bundled/github/GitHubPanel.test.tsx index 940b77ef5a..9491c56c8b 100644 --- a/src/bundled/github/GitHubPanel.test.tsx +++ b/src/bundled/github/GitHubPanel.test.tsx @@ -275,6 +275,51 @@ it("links the PR owner and repository and moves its number to the title", async ).not.toBeInTheDocument(); }); +it("opens Command-clicked panel links in the desktop browser", async () => { + const invoke = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("isTauri", true); + vi.stubGlobal("__TAURI_INTERNALS__", { invoke }); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ title: "A small improvement" })), + ), + ); + const target = "https://github.com/block/buzz-app/pull/629"; + render( {}} />); + const title = await screen.findByRole("link", { + name: "A small improvement #629", + }); + const plain = new MouseEvent("click", { bubbles: true, cancelable: true }); + fireEvent(title, plain); + // A plain click stays with Tauri's own link opener. + expect(plain.defaultPrevented).toBe(false); + expect(invoke).not.toHaveBeenCalled(); + for (const [link, url] of [ + [title, target], + [ + screen.getByRole("link", { name: "buzz-app" }), + "https://github.com/block/buzz-app", + ], + ] as const) { + invoke.mockClear(); + const command = new MouseEvent("click", { + bubbles: true, + cancelable: true, + metaKey: true, + }); + fireEvent(link, command); + expect(command.defaultPrevented).toBe(true); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + "plugin:opener|open_url", + { url }, + undefined, + ); + } +}); + it.each([ ["https://github.com/example/project", "Repository project"], ["https://github.com/example/project/issues/2", "Issue #2"], diff --git a/src/bundled/github/index.tsx b/src/bundled/github/index.tsx index 17861c8b28..e4b9e5cb5f 100644 --- a/src/bundled/github/index.tsx +++ b/src/bundled/github/index.tsx @@ -1,3 +1,4 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; import { ResolvePull } from "./ResolvePull"; import type { PullChecklist as Checklist } from "./pull"; import { Button } from "../../shared/design-system/ui/Button"; @@ -9,6 +10,7 @@ import { useRef, useState, useSyncExternalStore, + type MouseEvent, type ReactNode, } from "react"; import { @@ -378,6 +380,27 @@ export function GitHubPanel({ ); } +/** Tauri's automatic link opener skips Command-clicks, which left the panel's + * GitHub links doing nothing; send them to the browser as message links do. */ +function openCommandClickInBrowser(event: MouseEvent) { + if ( + event.defaultPrevented || + !event.metaKey || + event.button !== 0 || + !isTauri() + ) + return; + const anchor = + event.target instanceof Element ? event.target.closest("a[href]") : null; + if ( + !(anchor instanceof HTMLAnchorElement) || + (anchor.protocol !== "https:" && anchor.protocol !== "http:") + ) + return; + event.preventDefault(); + void invoke("plugin:opener|open_url", { url: anchor.href }).catch(() => {}); +} + function ObjectPanel({ reference, gh, @@ -467,7 +490,9 @@ function ObjectPanel({ ); return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: Delegates Command-clicks on the links inside; it adds no interaction of its own. + // biome-ignore lint/a11y/useKeyWithClickEvents: Command-click is a pointer gesture on a link, which keeps its own keyboard behavior. +