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