返回 DeepSeek-Reasonix
BrowserPanel.tsx
根目录 / desktop / frontend / src / components / BrowserPanel.tsx
1 import { ArrowLeft, ArrowRight, Bug, Compass, Download, Hand, Plus, RotateCw, TriangleAlert, X, ZoomIn, ZoomOut } from "lucide-react";
2 import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type RefObject } from "react";
3
4 import { zoomPercent } from "../lib/browserAddress";
5 import type { BrowserDownloadView, BrowserTabView } from "../lib/browserHost";
6 import { useBrowserCopy, type BrowserCopy } from "../lib/browserPanelCopy";
7 import { selectActiveTab, selectAddress, useBrowserPanelStore } from "../lib/browserPanelStore";
8 import { desktopHost } from "../lib/desktopHost";
9 import { useI18n } from "../lib/i18n";
10 import { useToast } from "../lib/toast";
11 import { useBrowserSurfaceLayout } from "../lib/useBrowserSurfaceLayout";
12
13 const DOWNLOAD_STRIP_LIMIT = 5;
14
15 // The dock tab's label bypasses the shared dictionaries because both locale
16 // chunks sit on their bundle ratchet (check-bundle-budget.mjs).
17 const DOCK_TAB_LABEL: Record<string, string> = { zh: "浏览器", "zh-TW": "瀏覽器" };
18
19 /** Browser entry in the workspace dock's tab bar; mirrors the DockTab markup. */
20 export function BrowserDockTab({ active, onSelect }: { active: boolean; onSelect: () => void }) {
21 const { locale } = useI18n();
22 return (
23 <button type="button" role="tab" aria-selected={active} className={`workbench-dock__tab${active ? " workbench-dock__tab--active" : ""}`} onClick={onSelect}>
24 <Compass size={13} /><span className="workbench-dock__tab-label">{DOCK_TAB_LABEL[locale] ?? "Browser"}</span>
25 </button>
26 );
27 }
28
29 export function BrowserPanel({ taskId }: { taskId: string | undefined }) {
30 const copy = useBrowserCopy();
31 const { showToast } = useToast();
32 const host = desktopHost().browser;
33 const tabs = useBrowserPanelStore((state) => state.shown);
34 const activeTab = useBrowserPanelStore(selectActiveTab);
35 const address = useBrowserPanelStore(selectAddress);
36 const downloads = useBrowserPanelStore((state) => state.downloads);
37 const addressRef = useRef<HTMLInputElement>(null);
38 const [surface, setSurface] = useState<HTMLDivElement | null>(null);
39 const notifyRef = useRef((message: string) => showToast(copy.actionFailed(message), "error"));
40 notifyRef.current = (message) => showToast(copy.actionFailed(message), "error");
41
42 useEffect(() => {
43 if (!host) return;
44 return useBrowserPanelStore.getState().attach(host, (message) => notifyRef.current(message));
45 }, [host]);
46 useEffect(() => useBrowserPanelStore.getState().setTaskId(taskId), [taskId]);
47 useEffect(() => {
48 useBrowserPanelStore.getState().setVisible(true);
49 return () => useBrowserPanelStore.getState().setVisible(false);
50 }, []);
51 useBrowserSurfaceLayout(host, surface);
52
53 const focusAddress = useCallback(() => {
54 addressRef.current?.focus();
55 addressRef.current?.select();
56 }, []);
57 const onPanelKeyDown = (event: KeyboardEvent<HTMLElement>) => {
58 if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "l") {
59 event.preventDefault();
60 focusAddress();
61 }
62 };
63 const store = useBrowserPanelStore.getState;
64 const openFromDraft = async () => {
65 if (!(await store().openDraft())) focusAddress();
66 };
67 const addressBar = <AddressBar copy={copy} value={address} inputRef={addressRef} tabId={activeTab?.id ?? null} />;
68
69 return (
70 <section className="browser-panel" aria-label={copy.panel} onKeyDown={onPanelKeyDown}>
71 {tabs.length > 0 && (
72 <div className="browser-panel__tabs" role="tablist" aria-label={copy.tabs}>
73 {tabs.map((tab) => (
74 <BrowserTab key={tab.id} tab={tab} active={tab.id === activeTab?.id} copy={copy} />
75 ))}
76 <button type="button" className="browser-panel__icon-btn browser-panel__new-tab" aria-label={copy.newTab} onClick={() => void openFromDraft()}>
77 <Plus size={14} />
78 </button>
79 </div>
80 )}
81 {tabs.length > 0 && (
82 <div className="browser-panel__toolbar">
83 <button type="button" className="browser-panel__icon-btn" aria-label={copy.back} disabled={!activeTab?.canGoBack}
84 onClick={() => activeTab && void store().navigate(activeTab.id, { action: "back" })}><ArrowLeft size={14} /></button>
85 <button type="button" className="browser-panel__icon-btn" aria-label={copy.forward} disabled={!activeTab?.canGoForward}
86 onClick={() => activeTab && void store().navigate(activeTab.id, { action: "forward" })}><ArrowRight size={14} /></button>
87 {activeTab?.loading
88 ? <button type="button" className="browser-panel__icon-btn" aria-label={copy.stop} onClick={() => void store().navigate(activeTab.id, { action: "stop" })}><X size={14} /></button>
89 : <button type="button" className="browser-panel__icon-btn" aria-label={copy.reload} disabled={!activeTab}
90 onClick={() => activeTab && void store().navigate(activeTab.id, { action: "reload" })}><RotateCw size={14} /></button>}
91 {addressBar}
92 {activeTab?.mode === "agent" && (
93 <button type="button" className="btn btn--small" onClick={() => void store().takeover(activeTab.id)}>{copy.takeControl}</button>
94 )}
95 <button type="button" className="browser-panel__icon-btn" aria-label={copy.zoomOut} disabled={!activeTab}
96 onClick={() => activeTab && void store().zoom(activeTab.id, -1)}><ZoomOut size={14} /></button>
97 <button type="button" className="browser-panel__zoom" aria-label={copy.zoomReset(zoomPercent(activeTab?.zoom ?? 1))} disabled={!activeTab}
98 onClick={() => activeTab && void store().zoom(activeTab.id, 0)}>{zoomPercent(activeTab?.zoom ?? 1)}%</button>
99 <button type="button" className="browser-panel__icon-btn" aria-label={copy.zoomIn} disabled={!activeTab}
100 onClick={() => activeTab && void store().zoom(activeTab.id, 1)}><ZoomIn size={14} /></button>
101 <button type="button" className="browser-panel__icon-btn" aria-label={copy.devTools} disabled={!activeTab}
102 onClick={() => activeTab && void store().toggleDevTools(activeTab.id)}><Bug size={14} /></button>
103 </div>
104 )}
105 {activeTab?.mode === "human" && (
106 <div className="browser-panel__takeover" role="status">
107 <Hand size={14} aria-hidden="true" />
108 <span>{copy.takeover}</span>
109 <button type="button" className="btn btn--small" onClick={() => void store().resume(activeTab.id)}>{copy.resume}</button>
110 </div>
111 )}
112 <div className="browser-panel__content">
113 {tabs.length === 0 ? (
114 <div className="browser-panel__empty">
115 <Compass size={28} aria-hidden="true" />
116 <p className="browser-panel__empty-title">{copy.emptyTitle}</p>
117 <p className="browser-panel__empty-hint">{copy.emptyHint}</p>
118 {addressBar}
119 </div>
120 ) : activeTab?.error ? (
121 <div className="browser-panel__error" role="alert">
122 <TriangleAlert size={22} aria-hidden="true" />
123 <p className="browser-panel__error-title">{copy.errorTitle}</p>
124 <p className="browser-panel__error-detail">{copy.errorDetail(activeTab.error.code, activeTab.error.description)}</p>
125 <code className="browser-panel__error-url">{activeTab.url}</code>
126 <button type="button" className="btn btn--small" onClick={() => void store().navigate(activeTab.id, { action: "reload" })}>{copy.retry}</button>
127 </div>
128 ) : (
129 <div ref={setSurface} data-browser-surface="" className="browser-panel__surface" />
130 )}
131 </div>
132 {downloads.length > 0 && <DownloadStrip downloads={downloads} copy={copy} />}
133 </section>
134 );
135 }
136
137 function AddressBar({ copy, value, inputRef, tabId }: { copy: BrowserCopy; value: string; inputRef: RefObject<HTMLInputElement | null>; tabId: string | null }) {
138 const store = useBrowserPanelStore.getState;
139 return (
140 <input
141 ref={inputRef}
142 className="browser-panel__address"
143 type="text"
144 aria-label={copy.address}
145 placeholder={copy.addressPlaceholder}
146 spellCheck={false}
147 autoComplete="off"
148 value={value}
149 onChange={(event) => store().setDraft(tabId, event.target.value)}
150 onFocus={(event) => event.currentTarget.select()}
151 onKeyDown={(event) => {
152 if (event.key === "Enter") {
153 event.preventDefault();
154 void store().submitAddress();
155 } else if (event.key === "Escape") {
156 store().clearDraft(tabId);
157 event.currentTarget.blur();
158 }
159 }}
160 />
161 );
162 }
163
164 function BrowserTab({ tab, active, copy }: { tab: BrowserTabView; active: boolean; copy: BrowserCopy }) {
165 const title = tab.title || tab.url || copy.untitled;
166 const store = useBrowserPanelStore.getState;
167 return (
168 <div className={`browser-tab${active ? " browser-tab--active" : ""}`}>
169 <button type="button" role="tab" aria-selected={active} className="browser-tab__select" title={tab.url} onClick={() => store().activate(tab.id)}>
170 {tab.loading && <span className="browser-tab__spinner" role="img" aria-label={copy.loading} />}
171 <span className="browser-tab__title">{title}</span>
172 {tab.temporary && <span className="browser-tab__badge">{copy.temporary}</span>}
173 </button>
174 <button type="button" className="browser-tab__close" aria-label={copy.closeTab(title)} onClick={() => void store().close(tab.id)}>
175 <X size={12} />
176 </button>
177 </div>
178 );
179 }
180
181 function downloadProgress(download: BrowserDownloadView, copy: BrowserCopy): string {
182 if (download.state !== "progressing") return copy.downloadState[download.state];
183 if (download.total > 0) return `${Math.min(100, Math.round((download.received / download.total) * 100))}%`;
184 return copy.downloadState.progressing;
185 }
186
187 function DownloadStrip({ downloads, copy }: { downloads: BrowserDownloadView[]; copy: BrowserCopy }) {
188 return (
189 <div className="browser-panel__downloads" aria-label={copy.downloads}>
190 <div className="browser-panel__downloads-head">
191 <Download size={12} aria-hidden="true" />
192 <span>{copy.downloads}</span>
193 <button type="button" className="browser-panel__icon-btn" aria-label={copy.clearDownloads} onClick={() => useBrowserPanelStore.getState().clearDownloads()}>
194 <X size={12} />
195 </button>
196 </div>
197 <ul className="browser-panel__download-list">
198 {downloads.slice(0, DOWNLOAD_STRIP_LIMIT).map((download) => (
199 <li key={download.id} className={`browser-download browser-download--${download.state}`} title={download.path}>
200 <span className="browser-download__name">{download.filename}</span>
201 <span className="browser-download__state">{downloadProgress(download, copy)}</span>
202 {download.state === "progressing" && (
203 <progress className="browser-download__bar" max={download.total > 0 ? download.total : undefined} value={download.total > 0 ? download.received : undefined} aria-label={download.filename} />
204 )}
205 </li>
206 ))}
207 </ul>
208 </div>
209 );
210 }
211
211 lines Plain Text