1import { AuthPreviewOperateScope, type ScopedThreadRef } from "@t3tools/contracts";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import { useCallback } from "react";
7
8import { recordVisitForThread } from "~/browserHistoryStore";
9import { readLocalApi } from "~/localApi";
10import { previewEnvironment } from "~/state/preview";
11import { readEnvironmentScope } from "~/state/session";
12import { useAtomCommand } from "~/state/use-atom-command";
13
14import {
15 canOpenLinksInApp,
16 resolveBrowserLinkTargetPreference,
17 resolveLinkTarget,
18} from "./browserLinkTarget";
19import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview";
20
21const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;
22
23/**
24 * Opens a URL where the "Open links in" setting says, for buttons that sit
25 * beside a thread but are not markdown anchors: CI check details, a pull
26 * request that has no project to open in the panel. Without a thread there is
27 * nowhere to put an in-app tab, so the link goes to the system browser.
28 *
29 * An in-app open that fails falls back to the system browser rather than
30 * dropping the click: the user asked for the link, and the setting only says
31 * where it should go first. Failed settings reads reject without opening a
32 * browser. The promise also rejects if the system-browser fallback fails.
33 */
34export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
35 url: string,
36 options?: {
37 readonly event?: { readonly metaKey: boolean; readonly ctrlKey: boolean };
38 /** Thread to open beside when it is not the hook's own, e.g. a sidebar row's. */
39 readonly threadRef?: ScopedThreadRef | undefined;
40 },
41) => Promise<void> {
42 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
43 return useCallback(
44 async (url, options = {}) => {
45 const targetThreadRef = options.threadRef ?? threadRef;
46 const target = resolveLinkTarget({
47 url,
48 event: options.event ?? NO_MODIFIER,
49 preference: await resolveBrowserLinkTargetPreference(),
50 canOpenInApp:
51 targetThreadRef != null &&
52 readEnvironmentScope(targetThreadRef.environmentId, AuthPreviewOperateScope) &&
53 canOpenLinksInApp(targetThreadRef),
54 });
55 if (target === "app" && targetThreadRef) {
56 const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });
57 if (isAtomCommandInterrupted(result)) return;
58 if (result._tag === "Success") {
59 recordVisitForThread(targetThreadRef, url);
60 return;
61 }
62 const failure = squashAtomCommandFailure(result);
63 if (failure instanceof BrowserSettingsReadError) throw failure;
64 console.error(result.cause);
65 }
66 const api = readLocalApi();
67 if (!api) throw new Error("Link opening is unavailable.");
68 await api.shell.openExternal(url);
69 },
70 [openPreview, threadRef],
71 );
72}