diff --git a/apps/web/src/lib/openGitHubUrl.ts b/apps/web/src/lib/openGitHubUrl.ts index 2431f008a..450d0e7a0 100644 --- a/apps/web/src/lib/openGitHubUrl.ts +++ b/apps/web/src/lib/openGitHubUrl.ts @@ -2,7 +2,7 @@ import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@okcode/cont import { readDesktopPreviewBridge } from "~/desktopPreview"; import { readNativeApi } from "~/nativeApi"; -import { usePreviewStateStore } from "~/previewStateStore"; +import { openUrlInAppBrowser } from "~/lib/openUrlInAppBrowser"; export interface OpenGitHubUrlInput { url: string; @@ -36,17 +36,15 @@ export function canOpenGitHubUrlInPreview(input: OpenGitHubUrlInput): boolean { } export async function openGitHubUrl(input: OpenGitHubUrlInput): Promise<"preview" | "external"> { - const previewBridge = input.previewBridge ?? readDesktopPreviewBridge(); - const setPreviewOpen = input.setPreviewOpen ?? usePreviewStateStore.getState().setProjectOpen; - - if ( - isGitHubHttpUrl(input.url) && - previewBridge !== null && - input.projectId !== null && - input.threadId !== null - ) { - setPreviewOpen(input.projectId, true); - await previewBridge.createTab({ url: input.url, threadId: input.threadId }); + if (isGitHubHttpUrl(input.url) && input.projectId !== null && input.threadId !== null) { + await openUrlInAppBrowser({ + url: input.url, + projectId: input.projectId, + threadId: input.threadId, + previewBridge: input.previewBridge ?? readDesktopPreviewBridge(), + setPreviewOpen: input.setPreviewOpen, + nativeApi: input.nativeApi, + }); return "preview"; } diff --git a/apps/web/src/lib/openUrlInAppBrowser.test.ts b/apps/web/src/lib/openUrlInAppBrowser.test.ts new file mode 100644 index 000000000..84e043f44 --- /dev/null +++ b/apps/web/src/lib/openUrlInAppBrowser.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@okcode/contracts"; + +import { openUrlInAppBrowser } from "./openUrlInAppBrowser"; + +function projectId(value: string): ProjectId { + return value as ProjectId; +} + +function threadId(value: string): ThreadId { + return value as ThreadId; +} + +describe("openUrlInAppBrowser", () => { + it("opens in the desktop preview when project and thread ids are available", async () => { + const createTab = vi.fn().mockResolvedValue({ + tabId: "tab-1", + state: { tabs: [], activeTabId: null, visible: false }, + }); + const setPreviewOpen = vi.fn(); + + const result = await openUrlInAppBrowser({ + url: "https://tweakcn.com", + projectId: projectId("project-1"), + threadId: threadId("thread-1"), + previewBridge: { createTab } as unknown as DesktopBridge["preview"], + setPreviewOpen, + }); + + expect(result).toBe("preview"); + expect(setPreviewOpen).toHaveBeenCalledWith(projectId("project-1"), true); + expect(createTab).toHaveBeenCalledWith({ + url: "https://tweakcn.com", + threadId: threadId("thread-1"), + }); + }); + + it("pops the preview out when requested", async () => { + const createTab = vi.fn().mockResolvedValue({ + tabId: "tab-1", + state: { tabs: [], activeTabId: null, visible: false }, + }); + const popOut = vi.fn().mockResolvedValue(undefined); + + const result = await openUrlInAppBrowser({ + url: "https://tweakcn.com", + projectId: projectId("project-1"), + threadId: threadId("thread-1"), + previewBridge: { createTab, popOut } as unknown as DesktopBridge["preview"], + setPreviewOpen: vi.fn(), + popOut: true, + }); + + expect(result).toBe("popout"); + expect(createTab).toHaveBeenCalledOnce(); + expect(popOut).toHaveBeenCalledOnce(); + }); + + it("falls back to an external open when preview context is unavailable", async () => { + const openExternal = vi.fn().mockResolvedValue(undefined); + + const result = await openUrlInAppBrowser({ + url: "https://tweakcn.com", + projectId: null, + threadId: null, + nativeApi: { shell: { openExternal } } as unknown as NativeApi, + }); + + expect(result).toBe("external"); + expect(openExternal).toHaveBeenCalledWith("https://tweakcn.com"); + }); +}); diff --git a/apps/web/src/lib/openUrlInAppBrowser.ts b/apps/web/src/lib/openUrlInAppBrowser.ts new file mode 100644 index 000000000..aeb43202a --- /dev/null +++ b/apps/web/src/lib/openUrlInAppBrowser.ts @@ -0,0 +1,40 @@ +import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@okcode/contracts"; + +import { readDesktopPreviewBridge } from "~/desktopPreview"; +import { readNativeApi } from "~/nativeApi"; +import { usePreviewStateStore } from "~/previewStateStore"; + +export interface OpenUrlInAppBrowserInput { + url: string; + projectId: ProjectId | null; + threadId: ThreadId | null; + nativeApi?: NativeApi | undefined; + previewBridge?: DesktopBridge["preview"] | null | undefined; + setPreviewOpen?: ((projectId: ProjectId, open: boolean) => void) | undefined; + popOut?: boolean | undefined; +} + +export async function openUrlInAppBrowser( + input: OpenUrlInAppBrowserInput, +): Promise<"preview" | "popout" | "external"> { + const previewBridge = input.previewBridge ?? readDesktopPreviewBridge(); + const setPreviewOpen = input.setPreviewOpen ?? usePreviewStateStore.getState().setProjectOpen; + + if (previewBridge !== null && input.projectId !== null && input.threadId !== null) { + setPreviewOpen(input.projectId, true); + await previewBridge.createTab({ url: input.url, threadId: input.threadId }); + if (input.popOut) { + await previewBridge.popOut(); + return "popout"; + } + return "preview"; + } + + const nativeApi = input.nativeApi ?? readNativeApi(); + if (!nativeApi) { + throw new Error("Link opening is unavailable."); + } + + await nativeApi.shell.openExternal(input.url); + return "external"; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e40214c97..842e5d1d2 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -4,6 +4,7 @@ import { CheckCircle2Icon, ChevronDownIcon, CpuIcon, + GlobeIcon, GitBranchIcon, ImportIcon, KeyboardIcon, @@ -95,6 +96,7 @@ import { setStoredRadiusOverride, type CustomThemeData, } from "../lib/customTheme"; +import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; @@ -598,6 +600,7 @@ function SettingsRouteView() { const serverConfigQuery = useQuery(serverConfigQueryOptions()); const queryClient = useQueryClient(); const projects = useStore((state) => state.projects); + const threads = useStore((state) => state.threads); const [selectedProjectId, setSelectedProjectId] = useState( () => projects[0]?.id ?? null, ); @@ -644,6 +647,15 @@ function SettingsRouteView() { const selectedProjectEnvironmentVariablesQuery = useQuery( projectEnvironmentVariablesQueryOptions(activeProjectId), ); + const activeProjectPreviewThreadId = + activeProjectId === null + ? null + : (threads + .filter((thread) => thread.projectId === activeProjectId) + .toSorted((a, b) => + (b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt), + ) + .at(0)?.id ?? null); useEffect(() => { if (projects.length === 0) { @@ -776,6 +788,19 @@ function SettingsRouteView() { ...(fontSizeOverride !== null ? ["Code font size"] : []), ]; + const openTweakcn = useCallback(() => { + void openUrlInAppBrowser({ + url: "https://tweakcn.com", + projectId: activeProjectId, + threadId: activeProjectPreviewThreadId, + popOut: true, + nativeApi: readNativeApi(), + }).catch(() => { + const nativeApi = ensureNativeApi(); + return nativeApi.shell.openExternal("https://tweakcn.com"); + }); + }, [activeProjectId, activeProjectPreviewThreadId]); + const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); @@ -1140,6 +1165,23 @@ function SettingsRouteView() { ))} + + + + + } + /> + + Open tweakcn in the in-app browser + +