| 1 | import { create } from "zustand"; |
| 2 | |
| 3 | import { normalizeAddress, zoomStep } from "./browserAddress"; |
| 4 | import { app } from "./bridge"; |
| 5 | import type { BrowserDownloadView, BrowserNavigationTarget, BrowserTabView, DesktopBrowserHost } from "./browserHost"; |
| 6 | |
| 7 | /** Tabs the user opens from the panel belong to this pseudo task and show beside every task's tabs. */ |
| 8 | export const USER_TASK_ID = "user"; |
| 9 | const DOWNLOAD_LIMIT = 50; |
| 10 | |
| 11 | type Projection = { tabs: BrowserTabView[]; taskId: string; visible: boolean; activeTabId: string | null }; |
| 12 | |
| 13 | export type BrowserPanelState = Projection & { |
| 14 | host: DesktopBrowserHost | null; |
| 15 | notify: (message: string) => void; |
| 16 | shown: BrowserTabView[]; |
| 17 | activated: string | null; |
| 18 | downloads: BrowserDownloadView[]; |
| 19 | drafts: Record<string, string>; |
| 20 | attach(host: DesktopBrowserHost, notify: (message: string) => void): () => void; |
| 21 | setTaskId(taskId: string | undefined): void; |
| 22 | setVisible(visible: boolean): void; |
| 23 | setDraft(tabId: string | null, value: string): void; |
| 24 | clearDraft(tabId: string | null): void; |
| 25 | clearDownloads(): void; |
| 26 | activate(tabId: string): void; |
| 27 | open(url: string, temporary?: boolean, signal?: AbortSignal): Promise<void>; |
| 28 | submitAddress(): Promise<void>; |
| 29 | openDraft(): Promise<boolean>; |
| 30 | close(tabId: string): Promise<void>; |
| 31 | navigate(tabId: string, target: BrowserNavigationTarget): Promise<void>; |
| 32 | zoom(tabId: string, direction: -1 | 0 | 1): Promise<void>; |
| 33 | toggleDevTools(tabId: string): Promise<void>; |
| 34 | resume(tabId: string): Promise<void>; |
| 35 | takeover(tabId: string): Promise<void>; |
| 36 | }; |
| 37 | |
| 38 | const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)); |
| 39 | const draftKey = (tabId: string | null) => tabId ?? ""; |
| 40 | |
| 41 | export const shownTabs = (tabs: BrowserTabView[], taskId: string) => |
| 42 | tabs.filter((tab) => tab.taskId === USER_TASK_ID || tab.taskId === taskId); |
| 43 | |
| 44 | export const selectActiveTab = (state: BrowserPanelState) => state.shown.find((tab) => tab.id === state.activeTabId); |
| 45 | export const selectAddress = (state: BrowserPanelState) => |
| 46 | state.drafts[draftKey(state.activeTabId)] ?? selectActiveTab(state)?.url ?? ""; |
| 47 | |
| 48 | export function waitForBrowserHost(timeoutMs = 2000): Promise<DesktopBrowserHost> { |
| 49 | const current = useBrowserPanelStore.getState().host; |
| 50 | if (current) return Promise.resolve(current); |
| 51 | return new Promise((resolve, reject) => { |
| 52 | let settled = false; |
| 53 | const finish = (host?: DesktopBrowserHost) => { |
| 54 | if (settled) return; |
| 55 | settled = true; |
| 56 | clearTimeout(timer); |
| 57 | unsubscribe(); |
| 58 | if (host) resolve(host); |
| 59 | else reject(new Error("Built-in browser is not ready")); |
| 60 | }; |
| 61 | const unsubscribe = useBrowserPanelStore.subscribe(state => { |
| 62 | if (state.host) finish(state.host); |
| 63 | }); |
| 64 | const timer = setTimeout(() => finish(), timeoutMs); |
| 65 | const attached = useBrowserPanelStore.getState().host; |
| 66 | if (attached) finish(attached); |
| 67 | }); |
| 68 | } |
| 69 | |
| 70 | export const useBrowserPanelStore = create<BrowserPanelState>((set, get) => { |
| 71 | const call = (promise: Promise<unknown>) => promise.then(() => undefined, (error: unknown) => get().notify(errorText(error))); |
| 72 | |
| 73 | // Every projection input funnels through here so the shell is told about |
| 74 | // exactly one visible tab per state, never a tab of another task. |
| 75 | const project = (patch: Partial<Projection>) => { |
| 76 | const previous = get(); |
| 77 | const next = { ...previous, ...patch }; |
| 78 | const shown = shownTabs(next.tabs, next.taskId); |
| 79 | const activeTabId = shown.some((tab) => tab.id === next.activeTabId) ? next.activeTabId : shown[shown.length - 1]?.id ?? null; |
| 80 | const activated = next.visible && next.host ? activeTabId : null; |
| 81 | const drafts = Object.fromEntries(Object.entries(previous.drafts) |
| 82 | .filter(([key]) => key === "" || next.tabs.some((tab) => tab.id === key))); |
| 83 | set({ ...patch, shown, activeTabId, activated, drafts }); |
| 84 | if (activated !== previous.activated && next.host) void call(next.host.activate(activated)); |
| 85 | }; |
| 86 | const clearDraft = (key: string) => { |
| 87 | const drafts = { ...get().drafts }; |
| 88 | delete drafts[key]; |
| 89 | set({ drafts }); |
| 90 | }; |
| 91 | |
| 92 | return { |
| 93 | host: null, |
| 94 | notify: () => {}, |
| 95 | tabs: [], |
| 96 | shown: [], |
| 97 | taskId: "", |
| 98 | visible: false, |
| 99 | activeTabId: null, |
| 100 | activated: null, |
| 101 | downloads: [], |
| 102 | drafts: {}, |
| 103 | attach(host, notify) { |
| 104 | set({ host, notify }); |
| 105 | const offTabs = host.onTabs((tabs) => project({ tabs })); |
| 106 | const offDownload = host.onDownload((download) => set((state) => ({ |
| 107 | downloads: [download, ...state.downloads.filter((entry) => entry.id !== download.id)].slice(0, DOWNLOAD_LIMIT), |
| 108 | }))); |
| 109 | // A subscription update that landed before this reply is newer; never |
| 110 | // let the initial list clobber it. |
| 111 | const atAttach = get().tabs; |
| 112 | void call(host.list().then((tabs) => { |
| 113 | if (get().tabs === atAttach) project({ tabs }); |
| 114 | })); |
| 115 | project({}); |
| 116 | return () => { |
| 117 | offTabs(); |
| 118 | offDownload(); |
| 119 | if (get().host !== host) return; |
| 120 | if (get().activated !== null) { |
| 121 | set({ activated: null }); |
| 122 | void call(host.activate(null)); |
| 123 | } |
| 124 | set({ host: null, notify: () => {} }); |
| 125 | }; |
| 126 | }, |
| 127 | setTaskId: (taskId) => project({ taskId: taskId ?? "" }), |
| 128 | setVisible: (visible) => project({ visible }), |
| 129 | setDraft: (tabId, value) => set((state) => ({ drafts: { ...state.drafts, [draftKey(tabId)]: value } })), |
| 130 | clearDraft: (tabId) => clearDraft(draftKey(tabId)), |
| 131 | clearDownloads: () => set((state) => ({ downloads: state.downloads.filter((entry) => entry.state === "progressing") })), |
| 132 | activate: (tabId) => project({ activeTabId: tabId }), |
| 133 | async open(url, temporary = false, signal) { |
| 134 | const { host } = get(); |
| 135 | if (!host || signal?.aborted) return; |
| 136 | await call(host.open(url, { taskId: USER_TASK_ID, temporary }).then(async (tab) => { |
| 137 | if (signal?.aborted || get().host !== host) { |
| 138 | await host.close(tab.id); |
| 139 | return; |
| 140 | } |
| 141 | const tabs = get().tabs; |
| 142 | project({ tabs: tabs.some((entry) => entry.id === tab.id) ? tabs : [...tabs, tab], activeTabId: tab.id }); |
| 143 | })); |
| 144 | }, |
| 145 | async submitAddress() { |
| 146 | const state = get(); |
| 147 | const key = draftKey(state.activeTabId); |
| 148 | const url = normalizeAddress(state.drafts[key] ?? selectActiveTab(state)?.url ?? ""); |
| 149 | if (!url) return; |
| 150 | clearDraft(key); |
| 151 | if (state.activeTabId && state.host) await call(state.host.navigate(state.activeTabId, { url })); |
| 152 | else await get().open(url); |
| 153 | }, |
| 154 | async openDraft() { |
| 155 | const state = get(); |
| 156 | const key = draftKey(state.activeTabId); |
| 157 | const url = normalizeAddress(state.drafts[key] ?? ""); |
| 158 | if (!url) return false; |
| 159 | clearDraft(key); |
| 160 | await get().open(url); |
| 161 | return true; |
| 162 | }, |
| 163 | async close(tabId) { |
| 164 | const { host } = get(); |
| 165 | if (!host) return; |
| 166 | const closingURL = get().tabs.find(tab => tab.id === tabId)?.url; |
| 167 | await call(host.close(tabId).then(() => project({ tabs: get().tabs.filter((tab) => tab.id !== tabId) }))); |
| 168 | const revokePreview = (app as Partial<typeof app>).RevokeWorkspaceBrowserPreview; |
| 169 | if (closingURL && typeof revokePreview === "function") void revokePreview.call(app, closingURL).catch(() => undefined); |
| 170 | }, |
| 171 | async navigate(tabId, target) { |
| 172 | const { host } = get(); |
| 173 | if (host) await call(host.navigate(tabId, target)); |
| 174 | }, |
| 175 | async zoom(tabId, direction) { |
| 176 | const { host, tabs } = get(); |
| 177 | if (host) await call(host.setZoom(tabId, zoomStep(tabs.find((tab) => tab.id === tabId)?.zoom ?? 1, direction))); |
| 178 | }, |
| 179 | async toggleDevTools(tabId) { |
| 180 | const { host } = get(); |
| 181 | if (host) await call(host.toggleDevTools(tabId)); |
| 182 | }, |
| 183 | async resume(tabId) { |
| 184 | const { host } = get(); |
| 185 | if (host) await call(host.resume(tabId)); |
| 186 | }, |
| 187 | async takeover(tabId) { |
| 188 | const { host } = get(); |
| 189 | if (host) await call(host.takeover(tabId)); |
| 190 | }, |
| 191 | }; |
| 192 | }); |
| 193 |