返回 DeepSeek-Reasonix
RemotePanel.tsx
根目录 / desktop / frontend / src / components / RemotePanel.tsx
1 import { useAppNavigationStore } from "../store/appNavigation";
2 import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3
4 import { app } from "../lib/bridge";
5 import { useT } from "../lib/i18n";
6 import { isRemoteDegradedWarning, isRemoteTerminalFailure, remoteConnectionErrorSummaryKey } from "../lib/remoteErrors";
7 import { resolveRemoteWorkspace } from "../lib/remoteWorkspace";
8 import { publishNavigationIntent } from "../lib/useNavigationIntentFence";
9 import { fileNavigationOwner } from "../lib/fileNavigationCommands";
10 import type { FileNavigationOwner, FileNavigationScope, FileNavigationSnapshot } from "../lib/fileNavigationOwner";
11 import { useFileNavigationRecord } from "../app-shell/useFileNavigation";
12 import { useRemoteStore, type RemoteExplorerTab } from "../store/remote";
13 import type { RemoteDirEntry, RemoteForwardView } from "../lib/types";
14 import { CodeViewer } from "./CodeViewer";
15 import { RemoteStatusChip } from "./RemoteHostsPage";
16
17 const EMPTY_REMOTE_FORWARDS: RemoteForwardView[] = [];
18
19 /** RemotePanel is the right-dock remote work surface: a host header with
20 * Files / Ports / Server tabs. */
21 export function RemotePanel({ onClose, tabId, dockTabId, fileNavigation: fileNavigationProp, navigationSignal }: { onClose: () => void; tabId?: string; dockTabId?: string; fileNavigation?: FileNavigationOwner; navigationSignal?: AbortSignal }) {
22 const t = useT();
23 const hostId = useRemoteStore((s) => s.explorerHostId);
24 const host = useRemoteStore((s) => s.hosts.find((item) => item.id === hostId));
25 const tab = useRemoteStore((s) => s.explorerTab);
26 const setTab = useRemoteStore((s) => s.setExplorerTab);
27 const status = useRemoteStore((s) => (hostId ? s.statuses[hostId] : undefined));
28 const setSettingsTarget = useAppNavigationStore((s) => s.setSettingsTarget);
29 const [fallbackFileNavigation] = useState(fileNavigationOwner);
30 const fileNavigation = fileNavigationProp ?? fallbackFileNavigation;
31 const fileScope = useMemo(() => ({ sessionTabId: tabId ?? "", dockTabId: dockTabId ?? "" }), [dockTabId, tabId]);
32 // Another host is another resource space: binding it replaces this dock's
33 // record, so no path or access context crosses between hosts.
34 const fileKey = useMemo(
35 () => ({ resource: hostId ?? "", session: `${tabId ?? ""}\u0000${hostId ?? ""}` }),
36 [hostId, tabId],
37 );
38 const fileRecord = useFileNavigationRecord(fileNavigation, fileScope, fileKey);
39
40 if (!hostId) return null;
41 const connected = status?.state === "connected" || status?.state === "degraded";
42 const busy = status?.state === "connecting" || status?.state === "reconnecting" || status?.state === "pending_hostkey" || status?.state === "pending_secret";
43 const terminalFailure = isRemoteTerminalFailure(status);
44 const degradedWarning = isRemoteDegradedWarning(status);
45 const target = host ? `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}` : hostId;
46
47 return (
48 <section className="remote-panel" aria-label={t("remote.explorer")}>
49 <header className="remote-panel__header">
50 <span className="remote-panel__host-copy">
51 <span className="remote-panel__host">{host?.label || hostId}</span>
52 <span className="remote-panel__target">{target}</span>
53 </span>
54 <RemoteStatusChip state={status?.state ?? "stopped"} />
55 <div className="remote-panel__header-actions">
56 {connected ? (
57 <button className="btn btn--small" onClick={() => void app.DisconnectRemoteHost(hostId).catch(() => {})}>
58 {t("remote.disconnect")}
59 </button>
60 ) : (
61 <button className="btn btn--small btn--primary" disabled={busy} onClick={() => void app.ConnectRemoteHost(hostId).catch(() => {})}>
62 {busy ? t(`remote.status.${status?.state ?? "connecting"}`) : t("remote.connect")}
63 </button>
64 )}
65 <button className="btn btn--ghost" onClick={() => setSettingsTarget("remote")}>
66 {t("remote.manageHosts")}
67 </button>
68 <button className="btn btn--ghost" onClick={onClose} aria-label={t("rightDock.collapse")}>
69 ×
70 </button>
71 </div>
72 </header>
73
74 {(terminalFailure || degradedWarning) && status && (
75 <div className={`remote-panel__error-banner ${degradedWarning ? "remote-panel__error-banner--warning" : ""}`} role="alert">
76 <strong>{t(degradedWarning ? "remote.status.degraded" : "remote.status.failed")}</strong>
77 <span>{t(remoteConnectionErrorSummaryKey(status), { host: host?.label || hostId })}</span>
78 </div>
79 )}
80
81 {status?.state === "reconnecting" && (
82 <div className="remote-panel__banner" role="status">
83 {t("remote.banner.reconnecting", { n: status.attempt ?? 1 })}
84 </div>
85 )}
86
87 <nav className="remote-panel__tabs" role="tablist">
88 {(["files", "ports", "server"] as RemoteExplorerTab[]).map((id) => (
89 <button
90 key={id}
91 role="tab"
92 aria-selected={tab === id}
93 className={`remote-panel__tab ${tab === id ? "is-active" : ""}`}
94 onClick={() => setTab(id)}
95 >
96 {t(`remote.tab.${id}`)}
97 </button>
98 ))}
99 </nav>
100
101 <div className="remote-panel__body">
102 {tab === "files" && (
103 <RemoteFilesTab
104 key={hostId}
105 hostId={hostId}
106 connected={connected}
107 navigationSignal={navigationSignal}
108 fileNavigation={fileNavigation}
109 fileScope={fileScope}
110 record={fileRecord}
111 />
112 )}
113 {tab === "ports" && <RemotePortsTab hostId={hostId} connected={connected} />}
114 {tab === "server" && <RemoteServerTab hostId={hostId} connected={connected} defaultWorkspace={host?.defaultWorkspace} />}
115 </div>
116 </section>
117 );
118 }
119
120 // ── Files tab: lean lazy tree + preview/edit ──
121
122 function RemoteFilesTab({ hostId, connected, navigationSignal, fileNavigation, fileScope, record }: {
123 hostId: string;
124 connected: boolean;
125 navigationSignal?: AbortSignal;
126 fileNavigation: FileNavigationOwner;
127 fileScope: FileNavigationScope;
128 record: FileNavigationSnapshot | null;
129 }) {
130 const t = useT();
131 const [entriesByDir, setEntriesByDir] = useState<Record<string, RemoteDirEntry[]>>({});
132 const [openDirs, setOpenDirs] = useState<Set<string>>(new Set());
133 // One preview at a time, chosen by the dock's committed navigation: the panel
134 // reads a result rather than keeping a second selection of its own.
135 const selectedEntry = record?.selected ?? null;
136 const selected = selectedEntry?.resource.path ?? null;
137 const presentedSelection = selectedEntry?.resource.access.source === "presented" ? selected : null;
138 const [loadErr, setLoadErr] = useState("");
139 const rootPath = "."; // remote home; RealPath resolves it server-side
140 const lifetime = useRef(0);
141 const loads = useRef(new Map<string, number>());
142 useEffect(() => () => { lifetime.current++; loads.current.clear(); }, []);
143
144 const loadDir = useCallback(
145 async (path: string, signal?: AbortSignal) => {
146 const owner = lifetime.current;
147 const generation = (loads.current.get(path) ?? 0) + 1;
148 loads.current.set(path, generation);
149 const current = () => !signal?.aborted && !navigationSignal?.aborted && owner === lifetime.current && loads.current.get(path) === generation;
150 try {
151 const entries = await app.ListRemoteDir(hostId, path);
152 if (!current()) return;
153 setEntriesByDir((m) => ({ ...m, [path]: entries }));
154 setLoadErr("");
155 } catch (e) {
156 if (!current()) return;
157 setLoadErr(t("remote.tree.loadError", { err: String(e) }));
158 }
159 },
160 [hostId, t, navigationSignal],
161 );
162
163 useEffect(() => {
164 if (connected) void loadDir(rootPath);
165 }, [connected, loadDir]);
166
167 // Expanding the ancestors of a committed selection is the remote tree's whole
168 // reveal. It runs from the record's revision, so a remount re-expands the
169 // retained selection without replaying the command that opened it.
170 const appliedRevealRef = useRef("");
171 const revealRevision = selected ? `${record?.generation ?? 0}:${record?.contentRevision ?? 0}:${record?.treeReveal ?? 0}:${selected}` : "";
172 useEffect(() => {
173 if (!connected || !selected || !revealRevision) return;
174 if (appliedRevealRef.current === revealRevision) return;
175 appliedRevealRef.current = revealRevision;
176 const slashPath = selected.replaceAll("\\\\", "/");
177 const parts = slashPath.split("/").filter(Boolean);
178 const absolute = slashPath.startsWith("/");
179 const ancestors: string[] = [];
180 for (let i = 1; i < parts.length; i += 1) {
181 ancestors.push(`${absolute ? "/" : ""}${parts.slice(0, i).join("/")}`);
182 }
183 const lifetimeAtStart = lifetime.current;
184 void (async () => {
185 for (const dir of ancestors) {
186 if (lifetime.current !== lifetimeAtStart || navigationSignal?.aborted) return;
187 await loadDir(dir, record?.signal);
188 if (lifetime.current !== lifetimeAtStart || navigationSignal?.aborted) return;
189 setOpenDirs(prev => new Set(prev).add(dir));
190 }
191 })();
192 }, [connected, loadDir, navigationSignal, record?.signal, revealRevision, selected]);
193
194 const toggleDir = (path: string) => {
195 setOpenDirs((prev) => {
196 const next = new Set(prev);
197 if (next.has(path)) {
198 next.delete(path);
199 } else {
200 next.add(path);
201 if (!entriesByDir[path]) void loadDir(path);
202 }
203 return next;
204 });
205 };
206
207 const renderDir = (path: string, depth: number): ReactNode => {
208 const entries = entriesByDir[path];
209 if (!entries) return null;
210 if (entries.length === 0) return <li className="remote-tree__empty">{t("remote.tree.empty")}</li>;
211 return entries.map((e) => (
212 <li key={e.path} className="remote-tree__item" style={{ paddingLeft: depth * 12 }}>
213 {e.isDir ? (
214 <>
215 <button className="remote-tree__row" onClick={() => toggleDir(e.path)} role="treeitem" aria-expanded={openDirs.has(e.path)}>
216 {openDirs.has(e.path) ? "▾" : "▸"} {e.name}/
217 </button>
218 {openDirs.has(e.path) && <ul>{renderDir(e.path, depth + 1)}</ul>}
219 </>
220 ) : (
221 <button
222 className={`remote-tree__row ${selected === e.path ? "is-selected" : ""}`}
223 onClick={() => { void Promise.resolve(fileNavigation.selectPath(fileScope, { hostId, path: e.path })); }}
224 role="treeitem"
225 >
226 {e.name}
227 </button>
228 )}
229 </li>
230 ));
231 };
232
233 if (!connected) return <p className="remote-panel__hint">{t("remote.status.stopped")}</p>;
234
235 return (
236 <div className="remote-files">
237 <div className="remote-files__tree" role="tree">
238 {loadErr && <p className="remote-panel__error" role="alert">{loadErr}</p>}
239 {presentedSelection && (
240 <button
241 className="remote-tree__row is-selected remote-tree__presented"
242 onClick={() => { void Promise.resolve(fileNavigation.selectPath(fileScope, { hostId, path: presentedSelection })); }}
243 role="treeitem"
244 title={presentedSelection}
245 >
246 {presentedSelection.split(/[\\/]/).filter(Boolean).slice(-1)[0] || presentedSelection}
247 </button>
248 )}
249 <ul>{renderDir(rootPath, 0)}</ul>
250 </div>
251 <div className="remote-files__view">
252 {selected ? (
253 <RemoteFileView
254 key={`${hostId}::${selected}`}
255 hostId={hostId}
256 path={selected}
257 connected={connected}
258 dockGeneration={record?.generation ?? 0}
259 forceReadOnly={selected === presentedSelection}
260 />
261 ) : null}
262 </div>
263 </div>
264 );
265 }
266
267 function RemoteFileView({ hostId, path, connected, dockGeneration, forceReadOnly = false }: { hostId: string; path: string; connected: boolean; dockGeneration: number; forceReadOnly?: boolean }) {
268 const t = useT();
269 const [body, setBody] = useState("");
270 const [draft, setDraft] = useState<string | null>(null);
271 const [mtime, setMtime] = useState(0);
272 const [binary, setBinary] = useState(false);
273 const [truncated, setTruncated] = useState(false);
274 const [saving, setSaving] = useState(false);
275 const [conflict, setConflict] = useState(false);
276 const [err, setErr] = useState("");
277 const operation = useRef(0);
278 // This view's identity: a receipt is only applied while the same dock, host
279 // and path it was issued for are still the ones on screen.
280 const identity = `${dockGeneration}\u0000${hostId}\u0000${path}`;
281 const identityRef = useRef(identity);
282 identityRef.current = identity;
283 useEffect(() => () => { operation.current++; }, []);
284
285 // The remote read entry point is host-scoped: it revalidates connectivity on
286 // the host the user authenticated, not the presented tool scope an entry was
287 // opened with. A presented remote file is therefore read with host
288 // credentials and shown read-only; parity with the local presented entry
289 // points needs a bridge entry point this change does not add.
290 const load = useCallback(async () => {
291 const generation = ++operation.current;
292 const at = identity;
293 const current = () => operation.current === generation && identityRef.current === at;
294 try {
295 const p = await app.ReadRemoteFile(hostId, path);
296 if (!current()) return;
297 setBody(p.body);
298 setDraft(null);
299 setMtime(p.mtimeUnix);
300 setBinary(p.binary);
301 setTruncated(p.truncated);
302 setErr(p.err ?? "");
303 } catch (error) { if (current()) setErr(String(error)); }
304 }, [hostId, identity, path]);
305
306 useEffect(() => {
307 void load();
308 }, [load]);
309
310 const editable = !forceReadOnly && connected && !binary && !truncated && !err;
311 const dirty = draft !== null && draft !== body;
312
313 const save = async (force: boolean) => {
314 if (draft === null) return;
315 const generation = ++operation.current;
316 const submitted = draft;
317 // The write itself is issued for the file captured here and completes
318 // against it; navigating away only takes away the right to update this
319 // editor and to report the receipt to it.
320 const at = identity;
321 const ownsReceipt = () => operation.current === generation && identityRef.current === at;
322 setSaving(true);
323 try {
324 const res = await app.WriteRemoteFile(hostId, path, draft, force ? 0 : mtime);
325 if (!ownsReceipt()) return;
326 if (res.conflict) {
327 setConflict(true);
328 return;
329 }
330 setBody(submitted);
331 setDraft(current => current === submitted ? null : current);
332 setMtime(res.newMtimeUnix);
333 setConflict(false);
334 } catch (error) {
335 if (ownsReceipt()) setErr(String(error));
336 } finally {
337 if (ownsReceipt()) setSaving(false);
338 }
339 };
340
341 return (
342 <div className="remote-file-view">
343 <div className="remote-file-view__toolbar">
344 <span className="remote-file-view__path">{path}</span>
345 {err && <span className="remote-panel__error">{err}</span>}
346 {binary && <span className="remote-panel__hint">{t("remote.editor.binaryBlocked")}</span>}
347 {truncated && <span className="remote-panel__hint">{t("remote.editor.truncatedBlocked")}</span>}
348 {editable && draft === null && (
349 <button className="btn" onClick={() => setDraft(body)}>{t("remote.editor.edit")}</button>
350 )}
351 {draft !== null && (
352 <button className="btn btn--primary" disabled={saving || !dirty || !connected} onClick={() => void save(false)}>
353 {saving ? t("remote.editor.saving") : t("remote.editor.save")}
354 </button>
355 )}
356 {draft !== null && !connected && <span className="remote-panel__hint">{t("remote.editor.readOnlyDisconnected")}</span>}
357 </div>
358 {draft === null ? (
359 <CodeViewer value={body} readOnly />
360 ) : (
361 <textarea
362 className="remote-file-view__editor"
363 value={draft}
364 spellCheck={false}
365 onChange={(e) => setDraft(e.target.value)}
366 />
367 )}
368 {conflict && (
369 <div className="remote-file-view__conflict" role="alertdialog">
370 <p><strong>{t("remote.editor.conflictTitle")}</strong></p>
371 <p>{t("remote.editor.conflictBody")}</p>
372 <button className="btn" onClick={() => void load()}>{t("remote.editor.reload")}</button>
373 <button className="btn btn--danger" onClick={() => void save(true)}>{t("remote.editor.overwrite")}</button>
374 </div>
375 )}
376 </div>
377 );
378 }
379
380 // ── Ports tab ──
381
382 function RemotePortsTab({ hostId, connected }: { hostId: string; connected: boolean }) {
383 const t = useT();
384 const forwards = useRemoteStore((s) => s.forwards[hostId] ?? EMPTY_REMOTE_FORWARDS);
385 const setForwards = useRemoteStore((s) => s.setForwards);
386 const [localPort, setLocalPort] = useState(8080);
387 const [remoteHost, setRemoteHost] = useState("127.0.0.1");
388 const [remotePort, setRemotePort] = useState(80);
389 const [label, setLabel] = useState("");
390 const [actionErr, setActionErr] = useState("");
391
392 useEffect(() => {
393 if (connected) void app.RemoteForwards(hostId).then((f) => setForwards(hostId, f));
394 }, [hostId, connected, setForwards]);
395
396 const add = async () => {
397 try {
398 await app.AddRemoteForward(hostId, { localPort, remoteHost, remotePort, label });
399 setLabel("");
400 setActionErr("");
401 } catch (e) {
402 setActionErr(String(e));
403 }
404 };
405
406 const remove = async (forwardId: string) => {
407 try {
408 await app.RemoveRemoteForward(hostId, forwardId);
409 setActionErr("");
410 } catch (e) {
411 setActionErr(String(e));
412 }
413 };
414
415 return (
416 <div className="remote-ports">
417 {actionErr && <p className="remote-panel__error" role="alert">{actionErr}</p>}
418 {forwards.length === 0 ? (
419 <p className="remote-panel__hint">{t("remote.ports.empty")}</p>
420 ) : (
421 <ul className="remote-ports__list">
422 {forwards.map((f: RemoteForwardView) => (
423 <li key={f.id} className="remote-ports__row">
424 <span className={`remote-dot remote-dot--${f.state}`} aria-hidden />
425 <span>{f.label || f.id}</span>
426 {f.error && <span className="remote-panel__error">{f.error}</span>}
427 <button className="btn btn--ghost" onClick={() => void remove(f.id)}>
428 {t("remote.ports.remove")}
429 </button>
430 </li>
431 ))}
432 </ul>
433 )}
434 <div className="remote-ports__form">
435 <input type="number" min={1} max={65535} aria-label={t("remote.ports.localPort")} value={localPort} onChange={(e) => setLocalPort(Number(e.target.value) || 0)} />
436 <input aria-label={t("remote.ports.remoteHost")} value={remoteHost} onChange={(e) => setRemoteHost(e.target.value)} />
437 <input type="number" min={1} max={65535} aria-label={t("remote.ports.remotePort")} value={remotePort} onChange={(e) => setRemotePort(Number(e.target.value) || 0)} />
438 <input aria-label={t("remote.ports.label")} placeholder={t("remote.ports.label")} value={label} onChange={(e) => setLabel(e.target.value)} />
439 <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>
440 </div>
441 </div>
442 );
443 }
444
445 // ── Server tab ──
446
447 function RemoteServerTab({ hostId, connected, defaultWorkspace }: { hostId: string; connected: boolean; defaultWorkspace?: string }) {
448 const t = useT();
449 const hostServers = useRemoteStore((s) => s.servers[hostId]);
450 const setServer = useRemoteStore((s) => s.setServer);
451 const [workspace, setWorkspace] = useState("");
452 const [logs, setLogs] = useState("");
453 const [actionErr, setActionErr] = useState("");
454 const logsOpen = useRef(false);
455 const workspaceEdited = useRef(false);
456
457 useEffect(() => {
458 let cancelled = false;
459 workspaceEdited.current = false;
460 setWorkspace(resolveRemoteWorkspace(undefined, defaultWorkspace));
461 void app.RemoteLastWorkspace(hostId)
462 .then((lastWorkspace) => {
463 if (!cancelled && !workspaceEdited.current) {
464 setWorkspace(resolveRemoteWorkspace(lastWorkspace, defaultWorkspace));
465 }
466 })
467 .catch(() => undefined);
468 return () => {
469 cancelled = true;
470 };
471 }, [defaultWorkspace, hostId]);
472
473 // The panel manages one workspace at a time: the input value, or the host's
474 // first registered serve while the input is still empty.
475 const statusWorkspace = workspace || Object.keys(hostServers ?? {})[0] || "";
476 const server = statusWorkspace ? hostServers?.[statusWorkspace] : undefined;
477
478 useEffect(() => {
479 if (!statusWorkspace) return;
480 let cancelled = false;
481 void app.RemoteServerStatus(hostId, statusWorkspace)
482 .then((s) => {
483 if (!cancelled) setServer(s);
484 })
485 .catch(() => undefined);
486 return () => {
487 cancelled = true;
488 };
489 }, [hostId, statusWorkspace, setServer]);
490
491 const refreshLogs = async () => {
492 if (!statusWorkspace) return;
493 logsOpen.current = true;
494 try {
495 setLogs(await app.RemoteServerLogs(hostId, statusWorkspace, 200));
496 setActionErr("");
497 } catch (e) {
498 setLogs("");
499 setActionErr(String(e));
500 }
501 };
502
503 const start = async () => {
504 try {
505 setActionErr("");
506 await publishNavigationIntent("remote-workspace");
507 await app.OpenRemoteWorkspace(hostId, workspace);
508 } catch (e) {
509 setActionErr(String(e));
510 }
511 };
512
513 const stop = async () => {
514 if (!statusWorkspace) return;
515 try {
516 setActionErr("");
517 await app.StopRemoteServer(hostId, statusWorkspace);
518 } catch (e) {
519 setActionErr(String(e));
520 }
521 };
522
523 const state = server?.state ?? "stopped";
524 const busy = ["starting", "detect", "install", "waiting_lock", "launch", "health_check", "reuse"].includes(state);
525 const stateLabel = state === "ready"
526 ? t("remote.server.state.ready")
527 : state === "error"
528 ? t("remote.server.state.error")
529 : busy
530 ? t("remote.server.state.starting")
531 : t("remote.server.state.stopped");
532 const canManageServer = connected && Boolean(server?.workspace) && state !== "stopped";
533 return (
534 <div className="remote-server">
535 <label className="remote-server__ws">
536 {t("remote.server.workspace")}
537 <input
538 value={workspace}
539 onChange={(e) => {
540 workspaceEdited.current = true;
541 setWorkspace(e.target.value);
542 }}
543 placeholder="~"
544 />
545 </label>
546 <div className="remote-server__status">
547 {stateLabel}
548 {server?.message ? ` — ${server.message}` : ""}
549 {server?.error ? ` — ${server.error}` : ""}
550 {actionErr ? ` — ${actionErr}` : ""}
551 </div>
552 <div className="remote-server__actions">
553 <button className="btn btn--primary" disabled={!connected || !workspace || busy} onClick={() => void start()}>
554 {t("remote.server.openWeb")}
555 </button>
556 <button className="btn" disabled={!canManageServer || busy} onClick={() => void stop()}>
557 {t("remote.server.stop")}
558 </button>
559 <button className="btn btn--ghost" disabled={!canManageServer} onClick={() => void refreshLogs()}>
560 {t("remote.server.logs")}
561 </button>
562 </div>
563 <p className="remote-panel__hint">{t("remote.server.providerHint")}</p>
564 {logsOpen.current && (
565 <pre className="remote-server__logs">
566 {logs}
567 <button className="btn btn--ghost" onClick={() => void refreshLogs()}>{t("remote.server.refreshLogs")}</button>
568 </pre>
569 )}
570 </div>
571 );
572 }
573
573 lines Plain Text