返回 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): 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
193 lines TYPESCRIPT