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