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