Skip to content

Commit 2a22f8a

Browse files
authored
Open GitHub links in preview popout (#399)
- Route GitHub PR links through the preview tab when possible - Fall back to the native external browser when preview is unavailable - Add tests for preview eligibility and external fallback
1 parent 57c8455 commit 2a22f8a

4 files changed

Lines changed: 191 additions & 22 deletions

File tree

apps/web/src/components/GitActionsControl.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
type ProjectId,
23
GitActionFailure as GitActionFailureSchema,
34
type GitActionFailure,
45
type GitActionProgressEvent,
@@ -84,14 +85,17 @@ import {
8485
invalidateGitQueries,
8586
} from "~/lib/gitReactQuery";
8687
import { subscribeToGitPullRequestAction } from "~/lib/gitPullRequestAction";
88+
import { openGitHubUrl } from "~/lib/openGitHubUrl";
8789
import { newCommandId, newMessageId, randomUUID } from "~/lib/utils";
88-
import { resolvePathLinkTarget } from "~/terminal-links";
8990
import { readNativeApi } from "~/nativeApi";
91+
import { usePreviewStateStore } from "~/previewStateStore";
92+
import { resolvePathLinkTarget } from "~/terminal-links";
9093
import { isWsRequestError } from "~/wsTransport";
9194

9295
interface GitActionsControlProps {
9396
gitCwd: string | null;
9497
activeThreadId: ThreadId | null;
98+
activeProjectId: ProjectId | null;
9599
}
96100

97101
interface PendingDefaultBranchAction {
@@ -355,8 +359,13 @@ function GitSyncActionIcon() {
355359
return <ArrowUpDownIcon className="size-3.5" />;
356360
}
357361

358-
export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
362+
export default function GitActionsControl({
363+
gitCwd,
364+
activeThreadId,
365+
activeProjectId,
366+
}: GitActionsControlProps) {
359367
const { settings } = useAppSettings();
368+
const setPreviewOpen = usePreviewStateStore((state) => state.setProjectOpen);
360369
const openFileInViewer = useFileViewNavigation();
361370
const threadToastData = useMemo(
362371
() => (activeThreadId ? { threadId: activeThreadId } : undefined),
@@ -575,15 +584,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
575584
}, [updateActiveProgressToast]);
576585

577586
const openExistingPr = useCallback(async () => {
578-
const api = readNativeApi();
579-
if (!api) {
580-
toastManager.add({
581-
type: "error",
582-
title: "Link opening is unavailable.",
583-
data: threadToastData,
584-
});
585-
return;
586-
}
587587
const prUrl = openPullRequest?.url ?? null;
588588
if (!prUrl) {
589589
toastManager.add({
@@ -593,15 +593,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
593593
});
594594
return;
595595
}
596-
void api.shell.openExternal(prUrl).catch((err) => {
596+
void openGitHubUrl({
597+
url: prUrl,
598+
projectId: activeProjectId,
599+
threadId: activeThreadId,
600+
setPreviewOpen,
601+
}).catch((err) => {
597602
toastManager.add({
598603
type: "error",
599604
title: "Unable to open PR link",
600605
description: err instanceof Error ? err.message : "An error occurred.",
601606
data: threadToastData,
602607
});
603608
});
604-
}, [openPullRequest, threadToastData]);
609+
}, [activeProjectId, activeThreadId, openPullRequest, setPreviewOpen, threadToastData]);
605610

606611
const copyOpenPullRequestNumber = useCallback(() => {
607612
if (!openPullRequest) return;
@@ -770,10 +775,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
770775
actionProps: {
771776
children: formatOpenPullRequestLabel(prNumber),
772777
onClick: () => {
773-
const api = readNativeApi();
774-
if (!api) return;
775778
closeResultToast();
776-
void api.shell.openExternal(prUrl);
779+
void openGitHubUrl({
780+
url: prUrl,
781+
projectId: activeProjectId,
782+
threadId: activeThreadId,
783+
setPreviewOpen,
784+
}).catch((err) => {
785+
toastManager.add({
786+
type: "error",
787+
title: "Unable to open PR link",
788+
description: err instanceof Error ? err.message : "An error occurred.",
789+
data: threadToastData,
790+
});
791+
});
777792
},
778793
},
779794
}

apps/web/src/components/chat/ChatHeader.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor";
1111
import { shortcutLabelsForCommand } from "~/keybindings";
1212
import type { ClientMode } from "~/lib/clientMode";
1313
import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
14-
import { ensureNativeApi } from "~/nativeApi";
14+
import { openGitHubUrl } from "~/lib/openGitHubUrl";
1515
import type { PreviewDock } from "~/previewStateStore";
1616
import type { ProjectScriptDraft } from "~/projectScriptDefaults";
1717
import { EditableThreadTitle } from "../EditableThreadTitle";
1818
import GitActionsControl from "../GitActionsControl";
1919
import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";
2020
import { Button } from "../ui/button";
2121
import { Kbd } from "../ui/kbd";
22+
import { toastManager } from "../ui/toast";
2223
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2324
import { HeaderPanelsMenu } from "./HeaderPanelsMenu";
2425

@@ -58,7 +59,7 @@ interface ChatHeaderProps {
5859
export const ChatHeader = memo(function ChatHeader({
5960
activeThreadId,
6061
activeThreadTitle,
61-
activeProjectId: _activeProjectId,
62+
activeProjectId,
6263
activeProjectName,
6364
activeProjectCwd,
6465
isLocalDraftThread,
@@ -121,9 +122,22 @@ export const ChatHeader = memo(function ChatHeader({
121122
const pullRequestShortcutLabels = shortcutLabelsForCommand(keybindings, "git.pullRequest");
122123
const primaryPullRequestShortcutLabel = pullRequestShortcutLabels[0] ?? null;
123124

124-
const openPrLink = useCallback((url: string) => {
125-
void ensureNativeApi().shell.openExternal(url);
126-
}, []);
125+
const openPrLink = useCallback(
126+
(url: string) => {
127+
void openGitHubUrl({
128+
url,
129+
projectId: activeProjectId ?? null,
130+
threadId: activeThreadId,
131+
}).catch((error) => {
132+
toastManager.add({
133+
type: "error",
134+
title: "Unable to open PR link",
135+
description: error instanceof Error ? error.message : "An error occurred.",
136+
});
137+
});
138+
},
139+
[activeProjectId, activeThreadId],
140+
);
127141

128142
return (
129143
<div className="flex min-w-0 flex-1 items-center gap-2">
@@ -217,7 +231,11 @@ export const ChatHeader = memo(function ChatHeader({
217231
</Tooltip>
218232
)}
219233
{!isMobileCompanion && activeProjectName && (
220-
<GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />
234+
<GitActionsControl
235+
gitCwd={gitCwd}
236+
activeThreadId={activeThreadId}
237+
activeProjectId={activeProjectId ?? null}
238+
/>
221239
)}
222240
{/* Overflow menu: all panel toggles consolidated */}
223241
{!isMobileCompanion && (
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { DesktopBridge, NativeApi } from "@okcode/contracts";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { canOpenGitHubUrlInPreview, openGitHubUrl } from "./openGitHubUrl";
5+
6+
describe("openGitHubUrl", () => {
7+
beforeEach(() => {
8+
vi.restoreAllMocks();
9+
});
10+
11+
it("opens GitHub URLs in preview when desktop preview context is available", async () => {
12+
const createTab = vi.fn<DesktopBridge["preview"]["createTab"]>().mockResolvedValue({
13+
tabId: "tab-1",
14+
state: { tabs: [], activeTabId: null, visible: true },
15+
});
16+
const previewBridge = { createTab } as unknown as DesktopBridge["preview"];
17+
const setPreviewOpen = vi.fn();
18+
const openExternal = vi.fn<NativeApi["shell"]["openExternal"]>().mockResolvedValue(undefined);
19+
const nativeApi = { shell: { openExternal } } as unknown as NativeApi;
20+
21+
const result = await openGitHubUrl({
22+
url: "https://github.com/OpenKnots/okcode/pull/42",
23+
projectId: "project-1" as never,
24+
threadId: "thread-1" as never,
25+
previewBridge,
26+
nativeApi,
27+
setPreviewOpen,
28+
});
29+
30+
expect(result).toBe("preview");
31+
expect(setPreviewOpen).toHaveBeenCalledWith("project-1", true);
32+
expect(createTab).toHaveBeenCalledWith({
33+
url: "https://github.com/OpenKnots/okcode/pull/42",
34+
threadId: "thread-1",
35+
});
36+
expect(openExternal).not.toHaveBeenCalled();
37+
});
38+
39+
it("falls back to external open when preview is unavailable", async () => {
40+
const openExternal = vi.fn<NativeApi["shell"]["openExternal"]>().mockResolvedValue(undefined);
41+
const nativeApi = { shell: { openExternal } } as unknown as NativeApi;
42+
const setPreviewOpen = vi.fn();
43+
44+
const result = await openGitHubUrl({
45+
url: "https://github.com/OpenKnots/okcode/issues/42",
46+
projectId: "project-1" as never,
47+
threadId: null,
48+
nativeApi,
49+
setPreviewOpen,
50+
});
51+
52+
expect(result).toBe("external");
53+
expect(setPreviewOpen).not.toHaveBeenCalled();
54+
expect(openExternal).toHaveBeenCalledWith("https://github.com/OpenKnots/okcode/issues/42");
55+
});
56+
57+
it("only treats GitHub http urls as preview eligible", () => {
58+
expect(
59+
canOpenGitHubUrlInPreview({
60+
url: "https://github.com/OpenKnots/okcode/pull/42",
61+
projectId: "project-1" as never,
62+
threadId: "thread-1" as never,
63+
previewBridge: {} as never,
64+
}),
65+
).toBe(true);
66+
67+
expect(
68+
canOpenGitHubUrlInPreview({
69+
url: "https://example.com/OpenKnots/okcode/pull/42",
70+
projectId: "project-1" as never,
71+
threadId: "thread-1" as never,
72+
previewBridge: {} as never,
73+
}),
74+
).toBe(false);
75+
});
76+
});

apps/web/src/lib/openGitHubUrl.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@okcode/contracts";
2+
3+
import { readDesktopPreviewBridge } from "~/desktopPreview";
4+
import { readNativeApi } from "~/nativeApi";
5+
import { usePreviewStateStore } from "~/previewStateStore";
6+
7+
export interface OpenGitHubUrlInput {
8+
url: string;
9+
projectId: ProjectId | null;
10+
threadId: ThreadId | null;
11+
nativeApi?: NativeApi | undefined;
12+
previewBridge?: DesktopBridge["preview"] | null | undefined;
13+
setPreviewOpen?: ((projectId: ProjectId, open: boolean) => void) | undefined;
14+
}
15+
16+
function isGitHubHttpUrl(url: string): boolean {
17+
try {
18+
const parsed = new URL(url);
19+
const hostname = parsed.hostname.toLowerCase();
20+
return (
21+
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
22+
(hostname === "github.com" || hostname === "www.github.com")
23+
);
24+
} catch {
25+
return false;
26+
}
27+
}
28+
29+
export function canOpenGitHubUrlInPreview(input: OpenGitHubUrlInput): boolean {
30+
return (
31+
isGitHubHttpUrl(input.url) &&
32+
input.projectId !== null &&
33+
input.threadId !== null &&
34+
(input.previewBridge ?? readDesktopPreviewBridge()) !== null
35+
);
36+
}
37+
38+
export async function openGitHubUrl(input: OpenGitHubUrlInput): Promise<"preview" | "external"> {
39+
const previewBridge = input.previewBridge ?? readDesktopPreviewBridge();
40+
const setPreviewOpen = input.setPreviewOpen ?? usePreviewStateStore.getState().setProjectOpen;
41+
42+
if (
43+
isGitHubHttpUrl(input.url) &&
44+
previewBridge !== null &&
45+
input.projectId !== null &&
46+
input.threadId !== null
47+
) {
48+
setPreviewOpen(input.projectId, true);
49+
await previewBridge.createTab({ url: input.url, threadId: input.threadId });
50+
return "preview";
51+
}
52+
53+
const nativeApi = input.nativeApi ?? readNativeApi();
54+
if (!nativeApi) {
55+
throw new Error("Link opening is unavailable.");
56+
}
57+
58+
await nativeApi.shell.openExternal(input.url);
59+
return "external";
60+
}

0 commit comments

Comments
 (0)