| 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 |