返回 DeepSeek-Reasonix
browserPanelStore.ts
根目录 / desktop / frontend / src / lib / browserPanelStore.ts
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
202 lines TYPESCRIPT