返回 DeepSeek-Reasonix
RemotePanel.tsx
根目录 / desktop / frontend / src / components / RemotePanel.tsx
1 import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2
3 import { app } from "../lib/bridge";
4 import { useT } from "../lib/i18n";
5 import { isRemoteDegradedWarning, isRemoteTerminalFailure, remoteConnectionErrorSummaryKey } from "../lib/remoteErrors";
6 import { resolveRemoteWorkspace } from "../lib/remoteWorkspace";
7 import { useOverlayStore } from "../store/overlays";
8 import { useRemoteStore, type RemoteExplorerTab } from "../store/remote";
9 import type { RemoteDirEntry, RemoteForwardView } from "../lib/types";
10 import { CodeViewer } from "./CodeViewer";
11 import { RemoteStatusChip } from "./RemoteHostsPage";
12
13 const EMPTY_REMOTE_FORWARDS: RemoteForwardView[] = [];
14
15 /** RemotePanel is the right-dock remote work surface: a host header with
16 * Files / Ports / Server tabs. */
17 export function RemotePanel({ onClose }: { onClose: () => void }) {
18 const t = useT();
19 const hostId = useRemoteStore((s) => s.explorerHostId);
20 const host = useRemoteStore((s) => s.hosts.find((item) => item.id === hostId));
21 const tab = useRemoteStore((s) => s.explorerTab);
22 const setTab = useRemoteStore((s) => s.setExplorerTab);
23 const status = useRemoteStore((s) => (hostId ? s.statuses[hostId] : undefined));
24 const setSettingsTarget = useOverlayStore((s) => s.setSettingsTarget);
25
26 if (!hostId) return null;
27 const connected = status?.state === "connected" || status?.state === "degraded";
28 const busy = status?.state === "connecting" || status?.state === "reconnecting" || status?.state === "pending_hostkey" || status?.state === "pending_secret";
29 const terminalFailure = isRemoteTerminalFailure(status);
30 const degradedWarning = isRemoteDegradedWarning(status);
31 const target = host ? `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}` : hostId;
32
33 return (
34 <section className="remote-panel" aria-label={t("remote.explorer")}>
35 <header className="remote-panel__header">
36 <span className="remote-panel__host-copy">
37 <span className="remote-panel__host">{host?.label || hostId}</span>
38 <span className="remote-panel__target">{target}</span>
39 </span>
40 <RemoteStatusChip state={status?.state ?? "stopped"} />
41 <div className="remote-panel__header-actions">
42 {connected ? (
43 <button className="btn btn--small" onClick={() => void app.DisconnectRemoteHost(hostId).catch(() => {})}>
44 {t("remote.disconnect")}
45 </button>
46 ) : (
47 <button className="btn btn--small btn--primary" disabled={busy} onClick={() => void app.ConnectRemoteHost(hostId).catch(() => {})}>
48 {busy ? t(`remote.status.${status?.state ?? "connecting"}`) : t("remote.connect")}
49 </button>
50 )}
51 <button className="btn btn--ghost" onClick={() => setSettingsTarget("remote")}>
52 {t("remote.manageHosts")}
53 </button>
54 <button className="btn btn--ghost" onClick={onClose} aria-label={t("rightDock.collapse")}>
55 ×
56 </button>
57 </div>
58 </header>
59
60 {(terminalFailure || degradedWarning) && status && (
61 <div className={`remote-panel__error-banner ${degradedWarning ? "remote-panel__error-banner--warning" : ""}`} role="alert">
62 <strong>{t(degradedWarning ? "remote.status.degraded" : "remote.status.failed")}</strong>
63 <span>{t(remoteConnectionErrorSummaryKey(status), { host: host?.label || hostId })}</span>
64 </div>
65 )}
66
67 {status?.state === "reconnecting" && (
68 <div className="remote-panel__banner" role="status">
69 {t("remote.banner.reconnecting", { n: status.attempt ?? 1 })}
70 </div>
71 )}
72
73 <nav className="remote-panel__tabs" role="tablist">
74 {(["files", "ports", "server"] as RemoteExplorerTab[]).map((id) => (
75 <button
76 key={id}
77 role="tab"
78 aria-selected={tab === id}
79 className={`remote-panel__tab ${tab === id ? "is-active" : ""}`}
80 onClick={() => setTab(id)}
81 >
82 {t(`remote.tab.${id}`)}
83 </button>
84 ))}
85 </nav>
86
87 <div className="remote-panel__body">
88 {tab === "files" && <RemoteFilesTab hostId={hostId} connected={connected} />}
89 {tab === "ports" && <RemotePortsTab hostId={hostId} connected={connected} />}
90 {tab === "server" && <RemoteServerTab hostId={hostId} connected={connected} defaultWorkspace={host?.defaultWorkspace} />}
91 </div>
92 </section>
93 );
94 }
95
96 // ── Files tab: lean lazy tree + preview/edit ──
97
98 function RemoteFilesTab({ hostId, connected }: { hostId: string; connected: boolean }) {
99 const t = useT();
100 const [entriesByDir, setEntriesByDir] = useState<Record<string, RemoteDirEntry[]>>({});
101 const [openDirs, setOpenDirs] = useState<Set<string>>(new Set());
102 const [selected, setSelected] = useState<string | null>(null);
103 const [loadErr, setLoadErr] = useState("");
104 const rootPath = "."; // remote home; RealPath resolves it server-side
105
106 const loadDir = useCallback(
107 async (path: string) => {
108 try {
109 const entries = await app.ListRemoteDir(hostId, path);
110 setEntriesByDir((m) => ({ ...m, [path]: entries }));
111 setLoadErr("");
112 } catch (e) {
113 setLoadErr(t("remote.tree.loadError", { err: String(e) }));
114 }
115 },
116 [hostId, t],
117 );
118
119 useEffect(() => {
120 if (connected) void loadDir(rootPath);
121 }, [connected, loadDir]);
122
123 const toggleDir = (path: string) => {
124 setOpenDirs((prev) => {
125 const next = new Set(prev);
126 if (next.has(path)) {
127 next.delete(path);
128 } else {
129 next.add(path);
130 if (!entriesByDir[path]) void loadDir(path);
131 }
132 return next;
133 });
134 };
135
136 const renderDir = (path: string, depth: number): ReactNode => {
137 const entries = entriesByDir[path];
138 if (!entries) return null;
139 if (entries.length === 0) return <li className="remote-tree__empty">{t("remote.tree.empty")}</li>;
140 return entries.map((e) => (
141 <li key={e.path} className="remote-tree__item" style={{ paddingLeft: depth * 12 }}>
142 {e.isDir ? (
143 <>
144 <button className="remote-tree__row" onClick={() => toggleDir(e.path)} role="treeitem" aria-expanded={openDirs.has(e.path)}>
145 {openDirs.has(e.path) ? "▾" : "▸"} {e.name}/
146 </button>
147 {openDirs.has(e.path) && <ul>{renderDir(e.path, depth + 1)}</ul>}
148 </>
149 ) : (
150 <button
151 className={`remote-tree__row ${selected === e.path ? "is-selected" : ""}`}
152 onClick={() => setSelected(e.path)}
153 role="treeitem"
154 >
155 {e.name}
156 </button>
157 )}
158 </li>
159 ));
160 };
161
162 if (!connected) return <p className="remote-panel__hint">{t("remote.status.stopped")}</p>;
163
164 return (
165 <div className="remote-files">
166 <div className="remote-files__tree" role="tree">
167 {loadErr && <p className="remote-panel__error" role="alert">{loadErr}</p>}
168 <ul>{renderDir(rootPath, 0)}</ul>
169 </div>
170 <div className="remote-files__view">
171 {selected ? <RemoteFileView hostId={hostId} path={selected} connected={connected} /> : null}
172 </div>
173 </div>
174 );
175 }
176
177 function RemoteFileView({ hostId, path, connected }: { hostId: string; path: string; connected: boolean }) {
178 const t = useT();
179 const [body, setBody] = useState("");
180 const [draft, setDraft] = useState<string | null>(null);
181 const [mtime, setMtime] = useState(0);
182 const [binary, setBinary] = useState(false);
183 const [truncated, setTruncated] = useState(false);
184 const [saving, setSaving] = useState(false);
185 const [conflict, setConflict] = useState(false);
186 const [err, setErr] = useState("");
187
188 const load = useCallback(async () => {
189 const p = await app.ReadRemoteFile(hostId, path);
190 setBody(p.body);
191 setDraft(null);
192 setMtime(p.mtimeUnix);
193 setBinary(p.binary);
194 setTruncated(p.truncated);
195 setErr(p.err ?? "");
196 }, [hostId, path]);
197
198 useEffect(() => {
199 void load();
200 }, [load]);
201
202 const editable = connected && !binary && !truncated && !err;
203 const dirty = draft !== null && draft !== body;
204
205 const save = async (force: boolean) => {
206 if (draft === null) return;
207 setSaving(true);
208 try {
209 const res = await app.WriteRemoteFile(hostId, path, draft, force ? 0 : mtime);
210 if (res.conflict) {
211 setConflict(true);
212 return;
213 }
214 setBody(draft);
215 setDraft(null);
216 setMtime(res.newMtimeUnix);
217 setConflict(false);
218 } finally {
219 setSaving(false);
220 }
221 };
222
223 return (
224 <div className="remote-file-view">
225 <div className="remote-file-view__toolbar">
226 <span className="remote-file-view__path">{path}</span>
227 {err && <span className="remote-panel__error">{err}</span>}
228 {binary && <span className="remote-panel__hint">{t("remote.editor.binaryBlocked")}</span>}
229 {truncated && <span className="remote-panel__hint">{t("remote.editor.truncatedBlocked")}</span>}
230 {editable && draft === null && (
231 <button className="btn" onClick={() => setDraft(body)}>{t("remote.editor.edit")}</button>
232 )}
233 {draft !== null && (
234 <button className="btn btn--primary" disabled={saving || !dirty || !connected} onClick={() => void save(false)}>
235 {saving ? t("remote.editor.saving") : t("remote.editor.save")}
236 </button>
237 )}
238 {draft !== null && !connected && <span className="remote-panel__hint">{t("remote.editor.readOnlyDisconnected")}</span>}
239 </div>
240 {draft === null ? (
241 <CodeViewer value={body} readOnly />
242 ) : (
243 <textarea
244 className="remote-file-view__editor"
245 value={draft}
246 spellCheck={false}
247 onChange={(e) => setDraft(e.target.value)}
248 />
249 )}
250 {conflict && (
251 <div className="remote-file-view__conflict" role="alertdialog">
252 <p><strong>{t("remote.editor.conflictTitle")}</strong></p>
253 <p>{t("remote.editor.conflictBody")}</p>
254 <button className="btn" onClick={() => void load()}>{t("remote.editor.reload")}</button>
255 <button className="btn btn--danger" onClick={() => void save(true)}>{t("remote.editor.overwrite")}</button>
256 </div>
257 )}
258 </div>
259 );
260 }
261
262 // ── Ports tab ──
263
264 function RemotePortsTab({ hostId, connected }: { hostId: string; connected: boolean }) {
265 const t = useT();
266 const forwards = useRemoteStore((s) => s.forwards[hostId] ?? EMPTY_REMOTE_FORWARDS);
267 const setForwards = useRemoteStore((s) => s.setForwards);
268 const [localPort, setLocalPort] = useState(8080);
269 const [remoteHost, setRemoteHost] = useState("127.0.0.1");
270 const [remotePort, setRemotePort] = useState(80);
271 const [label, setLabel] = useState("");
272 const [actionErr, setActionErr] = useState("");
273
274 useEffect(() => {
275 if (connected) void app.RemoteForwards(hostId).then((f) => setForwards(hostId, f));
276 }, [hostId, connected, setForwards]);
277
278 const add = async () => {
279 try {
280 await app.AddRemoteForward(hostId, { localPort, remoteHost, remotePort, label });
281 setLabel("");
282 setActionErr("");
283 } catch (e) {
284 setActionErr(String(e));
285 }
286 };
287
288 const remove = async (forwardId: string) => {
289 try {
290 await app.RemoveRemoteForward(hostId, forwardId);
291 setActionErr("");
292 } catch (e) {
293 setActionErr(String(e));
294 }
295 };
296
297 return (
298 <div className="remote-ports">
299 {actionErr && <p className="remote-panel__error" role="alert">{actionErr}</p>}
300 {forwards.length === 0 ? (
301 <p className="remote-panel__hint">{t("remote.ports.empty")}</p>
302 ) : (
303 <ul className="remote-ports__list">
304 {forwards.map((f: RemoteForwardView) => (
305 <li key={f.id} className="remote-ports__row">
306 <span className={`remote-dot remote-dot--${f.state}`} aria-hidden />
307 <span>{f.label || f.id}</span>
308 {f.error && <span className="remote-panel__error">{f.error}</span>}
309 <button className="btn btn--ghost" onClick={() => void remove(f.id)}>
310 {t("remote.ports.remove")}
311 </button>
312 </li>
313 ))}
314 </ul>
315 )}
316 <div className="remote-ports__form">
317 <input type="number" min={1} max={65535} aria-label={t("remote.ports.localPort")} value={localPort} onChange={(e) => setLocalPort(Number(e.target.value) || 0)} />
318 <input aria-label={t("remote.ports.remoteHost")} value={remoteHost} onChange={(e) => setRemoteHost(e.target.value)} />
319 <input type="number" min={1} max={65535} aria-label={t("remote.ports.remotePort")} value={remotePort} onChange={(e) => setRemotePort(Number(e.target.value) || 0)} />
320 <input aria-label={t("remote.ports.label")} placeholder={t("remote.ports.label")} value={label} onChange={(e) => setLabel(e.target.value)} />
321 <button className="btn btn--primary" disabled={!connected || !remoteHost.trim() || localPort < 1 || localPort > 65535 || remotePort < 1 || remotePort > 65535} onClick={() => void add()}>{t("remote.ports.add")}</button>
322 </div>
323 </div>
324 );
325 }
326
327 // ── Server tab ──
328
329 function RemoteServerTab({ hostId, connected, defaultWorkspace }: { hostId: string; connected: boolean; defaultWorkspace?: string }) {
330 const t = useT();
331 const server = useRemoteStore((s) => s.servers[hostId]);
332 const setServer = useRemoteStore((s) => s.setServer);
333 const [workspace, setWorkspace] = useState("");
334 const [logs, setLogs] = useState("");
335 const [actionErr, setActionErr] = useState("");
336 const logsOpen = useRef(false);
337 const workspaceEdited = useRef(false);
338
339 useEffect(() => {
340 let cancelled = false;
341 workspaceEdited.current = false;
342 setWorkspace(resolveRemoteWorkspace(undefined, defaultWorkspace));
343 void app.RemoteLastWorkspace(hostId)
344 .then((lastWorkspace) => {
345 if (!cancelled && !workspaceEdited.current) {
346 setWorkspace(resolveRemoteWorkspace(lastWorkspace, defaultWorkspace));
347 }
348 })
349 .catch(() => undefined);
350 void app.RemoteServerStatus(hostId).then(setServer);
351 return () => {
352 cancelled = true;
353 };
354 }, [defaultWorkspace, hostId, setServer]);
355
356 const refreshLogs = async () => {
357 logsOpen.current = true;
358 try {
359 setLogs(await app.RemoteServerLogs(hostId, 200));
360 setActionErr("");
361 } catch (e) {
362 setLogs("");
363 setActionErr(String(e));
364 }
365 };
366
367 const start = async () => {
368 try {
369 setActionErr("");
370 await app.OpenRemoteWorkspace(hostId, workspace);
371 } catch (e) {
372 setActionErr(String(e));
373 }
374 };
375
376 const stop = async () => {
377 try {
378 setActionErr("");
379 await app.StopRemoteServer(hostId);
380 } catch (e) {
381 setActionErr(String(e));
382 }
383 };
384
385 const state = server?.state ?? "stopped";
386 const busy = ["starting", "detect", "install", "waiting_lock", "launch", "health_check", "reuse"].includes(state);
387 const stateLabel = state === "ready"
388 ? t("remote.server.state.ready")
389 : state === "error"
390 ? t("remote.server.state.error")
391 : busy
392 ? t("remote.server.state.starting")
393 : t("remote.server.state.stopped");
394 const canManageServer = connected && Boolean(server?.workspace) && state !== "stopped";
395 return (
396 <div className="remote-server">
397 <label className="remote-server__ws">
398 {t("remote.server.workspace")}
399 <input
400 value={workspace}
401 onChange={(e) => {
402 workspaceEdited.current = true;
403 setWorkspace(e.target.value);
404 }}
405 placeholder="~"
406 />
407 </label>
408 <div className="remote-server__status">
409 {stateLabel}
410 {server?.message ? ` — ${server.message}` : ""}
411 {server?.error ? ` — ${server.error}` : ""}
412 {actionErr ? ` — ${actionErr}` : ""}
413 </div>
414 <div className="remote-server__actions">
415 <button className="btn btn--primary" disabled={!connected || !workspace || busy} onClick={() => void start()}>
416 {t("remote.server.openWeb")}
417 </button>
418 <button className="btn" disabled={!canManageServer || busy} onClick={() => void stop()}>
419 {t("remote.server.stop")}
420 </button>
421 <button className="btn btn--ghost" disabled={!canManageServer} onClick={() => void refreshLogs()}>
422 {t("remote.server.logs")}
423 </button>
424 </div>
425 <p className="remote-panel__hint">{t("remote.server.providerHint")}</p>
426 {logsOpen.current && (
427 <pre className="remote-server__logs">
428 {logs}
429 <button className="btn btn--ghost" onClick={() => void refreshLogs()}>{t("remote.server.refreshLogs")}</button>
430 </pre>
431 )}
432 </div>
433 );
434 }
435
435 lines Plain Text