Skip to content

Commit 95528d8

Browse files
authored
Add in-app browser helper for Tweakcn settings (#418)
- Share URL-opening logic between GitHub links and the settings browser button - Support preview pop-out with external fallback when in-app context is unavailable
1 parent b034201 commit 95528d8

4 files changed

Lines changed: 165 additions & 12 deletions

File tree

apps/web/src/lib/openGitHubUrl.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@okcode/cont
22

33
import { readDesktopPreviewBridge } from "~/desktopPreview";
44
import { readNativeApi } from "~/nativeApi";
5-
import { usePreviewStateStore } from "~/previewStateStore";
5+
import { openUrlInAppBrowser } from "~/lib/openUrlInAppBrowser";
66

77
export interface OpenGitHubUrlInput {
88
url: string;
@@ -36,17 +36,15 @@ export function canOpenGitHubUrlInPreview(input: OpenGitHubUrlInput): boolean {
3636
}
3737

3838
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 });
39+
if (isGitHubHttpUrl(input.url) && input.projectId !== null && input.threadId !== null) {
40+
await openUrlInAppBrowser({
41+
url: input.url,
42+
projectId: input.projectId,
43+
threadId: input.threadId,
44+
previewBridge: input.previewBridge ?? readDesktopPreviewBridge(),
45+
setPreviewOpen: input.setPreviewOpen,
46+
nativeApi: input.nativeApi,
47+
});
5048
return "preview";
5149
}
5250

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@okcode/contracts";
4+
5+
import { openUrlInAppBrowser } from "./openUrlInAppBrowser";
6+
7+
function projectId(value: string): ProjectId {
8+
return value as ProjectId;
9+
}
10+
11+
function threadId(value: string): ThreadId {
12+
return value as ThreadId;
13+
}
14+
15+
describe("openUrlInAppBrowser", () => {
16+
it("opens in the desktop preview when project and thread ids are available", async () => {
17+
const createTab = vi.fn<DesktopBridge["preview"]["createTab"]>().mockResolvedValue({
18+
tabId: "tab-1",
19+
state: { tabs: [], activeTabId: null, visible: false },
20+
});
21+
const setPreviewOpen = vi.fn();
22+
23+
const result = await openUrlInAppBrowser({
24+
url: "https://tweakcn.com",
25+
projectId: projectId("project-1"),
26+
threadId: threadId("thread-1"),
27+
previewBridge: { createTab } as unknown as DesktopBridge["preview"],
28+
setPreviewOpen,
29+
});
30+
31+
expect(result).toBe("preview");
32+
expect(setPreviewOpen).toHaveBeenCalledWith(projectId("project-1"), true);
33+
expect(createTab).toHaveBeenCalledWith({
34+
url: "https://tweakcn.com",
35+
threadId: threadId("thread-1"),
36+
});
37+
});
38+
39+
it("pops the preview out when requested", async () => {
40+
const createTab = vi.fn<DesktopBridge["preview"]["createTab"]>().mockResolvedValue({
41+
tabId: "tab-1",
42+
state: { tabs: [], activeTabId: null, visible: false },
43+
});
44+
const popOut = vi.fn<DesktopBridge["preview"]["popOut"]>().mockResolvedValue(undefined);
45+
46+
const result = await openUrlInAppBrowser({
47+
url: "https://tweakcn.com",
48+
projectId: projectId("project-1"),
49+
threadId: threadId("thread-1"),
50+
previewBridge: { createTab, popOut } as unknown as DesktopBridge["preview"],
51+
setPreviewOpen: vi.fn(),
52+
popOut: true,
53+
});
54+
55+
expect(result).toBe("popout");
56+
expect(createTab).toHaveBeenCalledOnce();
57+
expect(popOut).toHaveBeenCalledOnce();
58+
});
59+
60+
it("falls back to an external open when preview context is unavailable", async () => {
61+
const openExternal = vi.fn<NativeApi["shell"]["openExternal"]>().mockResolvedValue(undefined);
62+
63+
const result = await openUrlInAppBrowser({
64+
url: "https://tweakcn.com",
65+
projectId: null,
66+
threadId: null,
67+
nativeApi: { shell: { openExternal } } as unknown as NativeApi,
68+
});
69+
70+
expect(result).toBe("external");
71+
expect(openExternal).toHaveBeenCalledWith("https://tweakcn.com");
72+
});
73+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 OpenUrlInAppBrowserInput {
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+
popOut?: boolean | undefined;
15+
}
16+
17+
export async function openUrlInAppBrowser(
18+
input: OpenUrlInAppBrowserInput,
19+
): Promise<"preview" | "popout" | "external"> {
20+
const previewBridge = input.previewBridge ?? readDesktopPreviewBridge();
21+
const setPreviewOpen = input.setPreviewOpen ?? usePreviewStateStore.getState().setProjectOpen;
22+
23+
if (previewBridge !== null && input.projectId !== null && input.threadId !== null) {
24+
setPreviewOpen(input.projectId, true);
25+
await previewBridge.createTab({ url: input.url, threadId: input.threadId });
26+
if (input.popOut) {
27+
await previewBridge.popOut();
28+
return "popout";
29+
}
30+
return "preview";
31+
}
32+
33+
const nativeApi = input.nativeApi ?? readNativeApi();
34+
if (!nativeApi) {
35+
throw new Error("Link opening is unavailable.");
36+
}
37+
38+
await nativeApi.shell.openExternal(input.url);
39+
return "external";
40+
}

apps/web/src/routes/_chat.settings.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
CheckCircle2Icon,
55
ChevronDownIcon,
66
CpuIcon,
7+
GlobeIcon,
78
GitBranchIcon,
89
ImportIcon,
910
KeyboardIcon,
@@ -95,6 +96,7 @@ import {
9596
setStoredRadiusOverride,
9697
type CustomThemeData,
9798
} from "../lib/customTheme";
99+
import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser";
98100
import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
99101
import { cn } from "../lib/utils";
100102
import { ensureNativeApi, readNativeApi } from "../nativeApi";
@@ -598,6 +600,7 @@ function SettingsRouteView() {
598600
const serverConfigQuery = useQuery(serverConfigQueryOptions());
599601
const queryClient = useQueryClient();
600602
const projects = useStore((state) => state.projects);
603+
const threads = useStore((state) => state.threads);
601604
const [selectedProjectId, setSelectedProjectId] = useState<ProjectId | null>(
602605
() => projects[0]?.id ?? null,
603606
);
@@ -644,6 +647,15 @@ function SettingsRouteView() {
644647
const selectedProjectEnvironmentVariablesQuery = useQuery(
645648
projectEnvironmentVariablesQueryOptions(activeProjectId),
646649
);
650+
const activeProjectPreviewThreadId =
651+
activeProjectId === null
652+
? null
653+
: (threads
654+
.filter((thread) => thread.projectId === activeProjectId)
655+
.toSorted((a, b) =>
656+
(b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt),
657+
)
658+
.at(0)?.id ?? null);
647659

648660
useEffect(() => {
649661
if (projects.length === 0) {
@@ -776,6 +788,19 @@ function SettingsRouteView() {
776788
...(fontSizeOverride !== null ? ["Code font size"] : []),
777789
];
778790

791+
const openTweakcn = useCallback(() => {
792+
void openUrlInAppBrowser({
793+
url: "https://tweakcn.com",
794+
projectId: activeProjectId,
795+
threadId: activeProjectPreviewThreadId,
796+
popOut: true,
797+
nativeApi: readNativeApi(),
798+
}).catch(() => {
799+
const nativeApi = ensureNativeApi();
800+
return nativeApi.shell.openExternal("https://tweakcn.com");
801+
});
802+
}, [activeProjectId, activeProjectPreviewThreadId]);
803+
779804
const openKeybindingsFile = useCallback(() => {
780805
if (!keybindingsConfigPath) return;
781806
setOpenKeybindingsError(null);
@@ -1140,6 +1165,23 @@ function SettingsRouteView() {
11401165
))}
11411166
</SelectPopup>
11421167
</Select>
1168+
<Tooltip>
1169+
<TooltipTrigger
1170+
render={
1171+
<Button
1172+
size="xs"
1173+
variant="outline"
1174+
onClick={openTweakcn}
1175+
aria-label="Open tweakcn"
1176+
>
1177+
<GlobeIcon className="size-3.5" />
1178+
</Button>
1179+
}
1180+
/>
1181+
<TooltipPopup side="top">
1182+
Open tweakcn in the in-app browser
1183+
</TooltipPopup>
1184+
</Tooltip>
11431185
<Tooltip>
11441186
<TooltipTrigger
11451187
render={

0 commit comments

Comments
 (0)