@xgspz/cli-ui 0.0.0-stage → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +512 -2
  3. package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
  4. package/dist/cli-ui-index-C34qFb4u.js +11209 -0
  5. package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
  6. package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
  7. package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +932 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +577 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1103 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +505 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1949 -0
  149. package/src/operator.css +1949 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -0,0 +1,262 @@
1
+ // 工作区文件:面包屑 + 列表。目录点进去,文件点开预览;行尾可直接下载。
2
+ // 列表随任务进展自动刷新(CliBoard 给 refreshKey),这次刷新里新出现 / 改过的文件标出来。
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import type { BoardBackend } from "../core/client";
6
+ import type { FileEntry } from "../core/types";
7
+ import { Icon } from "../ui/icons";
8
+ import { relTime } from "../ui/format";
9
+ import { downloadFile } from "./fileOps";
10
+ import { fmtSize } from "./fileKind";
11
+ import { retainFileScope } from "./fileScope";
12
+ import { downloadFolder, FAILED_LIST_NAME, type FolderProgress } from "./folderDownload";
13
+
14
+ export interface FilesPaneProps {
15
+ backend: BoardBackend;
16
+ jobId: string;
17
+ dir: string;
18
+ onDir: (dir: string) => void;
19
+ onOpen: (entry: FileEntry) => void;
20
+ refreshKey: number;
21
+ /** 列表读到了(看板用来判断打开着的文件有没有更新)。 */
22
+ toolbarHost?: HTMLElement | null;
23
+ onListed?: (dir: string, entries: FileEntry[]) => void;
24
+ }
25
+
26
+ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onListed, toolbarHost }: FilesPaneProps) {
27
+ const [entries, setEntries] = useState<FileEntry[] | null>(null);
28
+ const [truncated, setTruncated] = useState(false);
29
+ const [total, setTotal] = useState<number | undefined>(undefined);
30
+ const [error, setError] = useState<string | null>(null);
31
+ const [loading, setLoading] = useState(false);
32
+ const [retry, setRetry] = useState(0);
33
+ const [nextCursor, setNextCursor] = useState<string | undefined>();
34
+ const [moreLoading, setMoreLoading] = useState(false);
35
+ const [scrollTop, setScrollTop] = useState(0);
36
+ const [viewport, setViewport] = useState(640);
37
+ const [inputDirectory, setInputDirectory] = useState<string | null>(null);
38
+ const inInputs = !!dir && (dir === inputDirectory || /\/(?:uploads|attachments)\/[^/]+\/?$/.test(dir));
39
+ useEffect(() => {
40
+ const controller = new AbortController(); setInputDirectory(null);
41
+ void backend.uploadDirectory?.(jobId, controller.signal).then(path => { if (!controller.signal.aborted) setInputDirectory(path); }, () => {});
42
+ return () => controller.abort();
43
+ }, [backend, jobId]);
44
+ const listRef = useRef<HTMLDivElement>(null);
45
+ const pageAbort = useRef<AbortController | null>(null);
46
+ const generation = useRef(0);
47
+ const entriesRef = useRef(entries); entriesRef.current = entries;
48
+ const rowHeight = 52;
49
+ const first = Math.min(Math.max(0, (entries?.length ?? 0) - 1), Math.max(0, Math.floor(scrollTop / rowHeight) - 4));
50
+ const end = Math.min(entries?.length ?? 0, first + Math.min(80, Math.ceil(viewport / rowHeight) + 8));
51
+ const visiblePaths = (entries ?? []).slice(first, end).filter(file => file.type === "file").map(file => file.path);
52
+ const scopeKey = JSON.stringify(visiblePaths);
53
+ useEffect(() => retainFileScope(backend, jobId, { dirs: [dir || "."], paths: JSON.parse(scopeKey) as string[], onDenied: message => { generation.current++; pageAbort.current?.abort(); setLoading(false); setMoreLoading(false); setEntries(null); setError(message || "工作区访问受限,请检查任务文件目录或访问权限"); } }), [backend, jobId, dir, scopeKey]);
54
+ useEffect(() => {
55
+ if (!listRef.current || typeof ResizeObserver === "undefined") return;
56
+ const observer = new ResizeObserver(([box]) => { if (box.contentRect.height) setViewport(box.contentRect.height); });
57
+ observer.observe(listRef.current); return () => observer.disconnect();
58
+ }, [!!entries]);
59
+ const page = (cursor: string | undefined, signal: AbortSignal) => backend.listFiles
60
+ ? backend.listFiles(jobId, dir || ".", { cursor, limit: 200, signal })
61
+ : backend.stat(jobId, dir || ".");
62
+ const loadMore = async () => {
63
+ if (!nextCursor || moreLoading || loading || !backend.listFiles) return;
64
+ const token = generation.current, ctl = new AbortController(); pageAbort.current = ctl;
65
+ setMoreLoading(true);
66
+ try {
67
+ const result = await page(nextCursor, ctl.signal);
68
+ if (ctl.signal.aborted || token !== generation.current || result.type !== "dir") return;
69
+ const previous = entriesRef.current ?? [], paths = new Set(previous.map(file => file.path));
70
+ const joined = [...previous, ...result.entries.filter(file => !paths.has(file.path))];
71
+ setEntries(joined); setNextCursor(result.nextCursor); setTruncated(result.truncated); setTotal(result.total);
72
+ onListedRef.current?.(dir, joined);
73
+ } catch (error) {
74
+ if (ctl.signal.aborted || token !== generation.current) return;
75
+ if ((error as { status?: number }).status === 409) { setRetry(value => value + 1); return; }
76
+ setError(error instanceof Error ? error.message : String(error));
77
+ } finally { if (token === generation.current) setMoreLoading(false); }
78
+ };
79
+ const crumbsRef = useRef<HTMLDivElement>(null);
80
+ // 这个目录上一次看到的 mtime:用来标「新」。第一次打开目录不标。
81
+ const seen = useRef<{ dir: string; mtimes: Map<string, number> | null }>({ dir, mtimes: null });
82
+ const [fresh, setFresh] = useState<Set<string>>(new Set());
83
+ const onListedRef = useRef(onListed);
84
+ onListedRef.current = onListed;
85
+ const onDirRef = useRef(onDir);
86
+ onDirRef.current = onDir;
87
+ // 打包下载文件夹:同一时间只打一个,可以取消。
88
+ const [zip, setZip] = useState<{ dir: string; progress: FolderProgress; abort: AbortController } | null>(null);
89
+ // 下载的结果 / 失败(和「同步失败」分开:下载出错不代表列表过期)。
90
+ const [notice, setNotice] = useState<{ text: string; err: boolean } | null>(null);
91
+ const zipAbort = useRef<AbortController | null>(null);
92
+ const downloadScope = useRef<object | null>({});
93
+ const zipDir = (target: string) => {
94
+ if (zipAbort.current) return;
95
+ const abort = new AbortController();
96
+ zipAbort.current = abort;
97
+ const current = () => zipAbort.current === abort && !abort.signal.aborted;
98
+ setNotice(null);
99
+ setZip({ dir: target, progress: { phase: "listing", files: 0, total: 0, bytes: 0 }, abort });
100
+ downloadFolder(backend, jobId, target, { signal: abort.signal, onProgress: (progress) => { if (current()) setZip((z) => (z && z.abort === abort ? { ...z, progress } : z)); } })
101
+ .then(({ files, failed, failedListName }) => {
102
+ if (current() && failed.length) setNotice({ err: true, text: `已打包 ${files} 个文件;${failed.length} 个没读到(${failed[0].reason}${failed.length > 1 ? " 等" : ""}),清单在压缩包里的 ${failedListName??FAILED_LIST_NAME}` });
103
+ })
104
+ .catch((err: unknown) => { if (current() && !(err instanceof DOMException && err.name === "AbortError")) setNotice({ err: true, text: `打包下载失败:${err instanceof Error ? err.message : String(err)}` }); })
105
+ .finally(() => { if (zipAbort.current === abort) { zipAbort.current = null; setZip(null); } });
106
+ };
107
+ const downloadOne = (path: string, size: number) => {
108
+ const scope = downloadScope.current;
109
+ setNotice(null);
110
+ void downloadFile(backend, jobId, path, size).catch((err: unknown) => { if (scope === downloadScope.current) setNotice({ err: true, text: `下载失败:${err instanceof Error ? err.message : String(err)}` }); });
111
+ };
112
+ useEffect(() => {
113
+ downloadScope.current = {};
114
+ setZip(null);
115
+ setNotice(null);
116
+ setEntries(null);
117
+ seen.current = { dir, mtimes: null };
118
+ setFresh(new Set());
119
+ return () => {
120
+ downloadScope.current = null;
121
+ zipAbort.current?.abort();
122
+ zipAbort.current = null;
123
+ };
124
+ }, [jobId, backend]); // eslint-disable-line react-hooks/exhaustive-deps
125
+
126
+ useEffect(() => {
127
+ let alive = true;
128
+ const ctl = new AbortController();
129
+ const token = ++generation.current; pageAbort.current?.abort(); pageAbort.current = ctl; setMoreLoading(false);
130
+ setLoading(true);
131
+ const sameDirectory = seen.current.dir === dir;
132
+ const wanted = sameDirectory ? Math.max(200, entriesRef.current?.length ?? 0) : 200;
133
+ if (!sameDirectory) { seen.current = { dir, mtimes: null }; setEntries(null); setFresh(new Set()); setScrollTop(0); if (listRef.current) listRef.current.scrollTop = 0; }
134
+ const reload = async () => {
135
+ let result = await page(undefined, ctl.signal);
136
+ if (result.type !== "dir") return result;
137
+ const combined = [...result.entries];
138
+ // Preserve only pages the user has already requested, never preload the directory.
139
+ while (backend.listFiles && result.nextCursor && combined.length < wanted && !ctl.signal.aborted) {
140
+ const next = await page(result.nextCursor, ctl.signal);
141
+ if (next.type !== "dir") break;
142
+ combined.push(...next.entries); result = next;
143
+ }
144
+ return { ...result, entries: combined };
145
+ };
146
+ reload().then((res) => {
147
+ if (!alive || ctl.signal.aborted || token !== generation.current) return;
148
+ setLoading(false);
149
+ if (res.type !== "dir") { setError("不是目录"); return; }
150
+ setError(null);
151
+ const prev = seen.current.mtimes;
152
+ if (prev) {
153
+ const changed = res.entries.filter((e) => e.type === "file" && prev.get(e.path) !== e.mtime).map((e) => e.path);
154
+ if (changed.length) setFresh((old) => new Set([...old, ...changed]));
155
+ }
156
+ seen.current.mtimes = new Map(res.entries.map((e) => [e.path, e.mtime]));
157
+ setEntries(res.entries);
158
+ setTruncated(res.truncated);
159
+ setTotal(res.total);
160
+ setNextCursor(res.nextCursor);
161
+ onListedRef.current?.(dir, res.entries);
162
+ }, (e: unknown) => {
163
+ if (!alive || ctl.signal.aborted || token !== generation.current) return;
164
+ setLoading(false);
165
+ if ((e as { status?: number }).status === 409) { setNextCursor(undefined); setEntries(null); entriesRef.current = null; setError("目录在加载时发生变化,请重新加载"); return; }
166
+ // 正在看的目录被删了(任务清理了输出):回到上一级。
167
+ if (dir && (e as { status?: number }).status === 404) { onDirRef.current(inInputs ? "" : dir.split("/").slice(0, -1).join("/")); return; }
168
+ setError(e instanceof Error ? e.message : String(e));
169
+ });
170
+ return () => { alive = false; ctl.abort(); pageAbort.current?.abort(); };
171
+ }, [backend, jobId, dir, refreshKey, retry]);
172
+ useEffect(() => { if (crumbsRef.current) crumbsRef.current.scrollLeft = crumbsRef.current.scrollWidth; }, [dir, !!entries]);
173
+
174
+ const segs = inInputs ? ["输入文件"] : dir ? dir.split("/").filter(Boolean) : [];
175
+ const compact = toolbarHost !== undefined;
176
+ const toolbar = <>
177
+ {loading && entries && <span className="cli-files-sync" title="正在同步">同步中…</span>}
178
+ {entries && entries.length > 0 && !zip && (
179
+ <button className="cli-fv-icon" title={`把「${dir || "工作区"}」打包成 ZIP 下载`} aria-label={`打包下载 ${dir || "工作区"}`} onClick={() => zipDir(dir)}>{Icon.download()}</button>
180
+ )}
181
+ </>;
182
+ return (
183
+ <div className="cli-files">
184
+ {compact && toolbarHost && createPortal(toolbar, toolbarHost)}
185
+ {!compact && <div className="cli-crumbs">
186
+ <div className="cli-crumb-path" role="navigation" aria-label="工作区路径" ref={crumbsRef}>
187
+ <button className={`cli-crumb ${segs.length ? "" : "on"}`} title="工作区" aria-current={segs.length ? undefined : "location"} onClick={() => onDir("")}>{Icon.folder()}<span className="cli-crumb-name">工作区</span></button>
188
+ {segs.map((s, i) => (
189
+ <span key={i} className="cli-crumb-w">
190
+ <span className="cli-crumb-sep">/</span>
191
+ <button className={`cli-crumb ${i === segs.length - 1 ? "on" : ""}`} title={segs.slice(0, i + 1).join("/")} aria-current={i === segs.length - 1 ? "location" : undefined} onClick={() => onDir(inInputs ? dir : segs.slice(0, i + 1).join("/"))}><span className="cli-crumb-name">{s}</span></button>
192
+ </span>
193
+ ))}
194
+ </div>
195
+ {toolbar}
196
+
197
+ </div>}
198
+ {dir && (
199
+ <button className="cli-frow" title="返回上一级目录" onClick={() => onDir(inInputs ? "" : segs.slice(0, -1).join("/"))}>
200
+ <span className="cli-frow-ic">{Icon.folder()}</span><span className="cli-frow-name">{inInputs ? "返回工作区" : "返回上一级"}{compact && <small> · {inInputs ? "输入文件" : dir}</small>}</span>
201
+ </button>
202
+ )}
203
+ {!dir && inputDirectory && <button className="cli-frow" onClick={() => onDir(inputDirectory)} title="当前会话上传的文件">
204
+ <span className="cli-frow-ic">{Icon.folder()}</span><span className="cli-frow-name">输入文件</span><span className="cli-frow-meta">{Icon.chevRight()}</span>
205
+ </button>}
206
+ {zip && (
207
+ <div className="cli-fv-trunc cli-zip-bar">
208
+ {zip.progress.phase === "listing" ? `正在列出「${zip.dir || "工作区"}」里的文件…`
209
+ : zip.progress.phase === "reading" ? `正在读取 ${zip.progress.files} / ${zip.progress.total} 个文件(${fmtSize(zip.progress.bytes)}${zip.progress.totalBytes ? ` / ${fmtSize(zip.progress.totalBytes)}` : ""})`
210
+ + (zip.progress.retrying ? ` · 网络不稳,正在重试 ${zip.progress.retrying.split("/").pop()}` : "")
211
+ + (zip.progress.failed ? ` · ${zip.progress.failed} 个没读到` : "") + "…"
212
+ : "正在打包…"}
213
+ {zip.progress.phase === "reading" && zip.progress.totalBytes ? (
214
+ <span className="cli-zip-meter"><span style={{ width: `${Math.min(100, (zip.progress.bytes / Math.max(1, zip.progress.totalBytes)) * 100)}%` }} /></span>
215
+ ) : null}
216
+ <button className="cli-fv-btn" onClick={() => zip.abort.abort()}>取消</button>
217
+ </div>
218
+ )}
219
+ {notice && (
220
+ <div className={`cli-fv-trunc ${notice.err ? "cli-files-err" : ""}`}>{notice.text}
221
+ <button className="cli-fv-btn" onClick={() => setNotice(null)}>知道了</button>
222
+ </div>
223
+ )}
224
+ {error && entries && <div className="cli-fv-trunc" role="alert">同步失败:{error}。已保留上次读到的列表。
225
+ <button className="cli-fv-btn" disabled={loading} onClick={() => setRetry(value => value + 1)}>{loading ? "重试中…" : "重新加载"}</button>
226
+ </div>}
227
+ {error && !entries ? (
228
+ <div className="cli-fv-note err" role="alert">{error}<br /><button className="cli-fv-btn" disabled={loading} onClick={() => setRetry(value => value + 1)}>{loading ? "重试中…" : "重新加载文件"}</button></div>
229
+ ) : !entries ? (
230
+ <div className="cli-fv-note">读取文件…</div>
231
+ ) : entries.length === 0 ? (
232
+ <div className="cli-fv-note">{inInputs ? "还没有上传文件。" : dir ? "这个目录是空的。" : inputDirectory ? "任务生成的文件会出现在这里。" : "工作区里还没有文件。任务生成的文件会出现在这里。"}</div>
233
+ ) : (
234
+ <div className="cli-files-list" role="list" ref={listRef} style={{ overflowAnchor: "none" }} onScroll={event => {
235
+ const el = event.currentTarget; setScrollTop(el.scrollTop);
236
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 300) void loadMore();
237
+ }}>
238
+ {first > 0 && <div aria-hidden="true" style={{ height: first * rowHeight }} />}
239
+ {entries.slice(first, end).map((e, index) => (
240
+ <div key={e.path} className={`cli-frow ${e.type} ${fresh.has(e.path) ? "fresh" : ""}`} role="listitem" aria-posinset={first + index + 1} aria-setsize={total ?? entries.length} style={{ height: rowHeight }}>
241
+ <button className="cli-frow-main" onClick={() => (e.type === "dir" ? onDir(e.path) : onOpen(e))} title={e.path}>
242
+ <span className="cli-frow-ic">{e.type === "dir" ? Icon.folder() : Icon.doc()}</span>
243
+ <span className="cli-frow-body">
244
+ <span className="cli-frow-title"><span className="cli-frow-name">{e.name}</span>{fresh.has(e.path) && <span className="cli-frow-new">有更新</span>}</span>
245
+ <span className="cli-frow-meta">{e.type === "file" ? `${fmtSize(e.size)} · ` : ""}{relTime(e.mtime)}</span>
246
+ </span>
247
+ </button>
248
+ {e.type === "file" ? (
249
+ <button className="cli-frow-dl" title="下载" aria-label={`下载 ${e.name}`} onClick={() => downloadOne(e.path, e.size)}>{Icon.download()}</button>
250
+ ) : (
251
+ <button className="cli-frow-dl" title="打包成 ZIP 下载" aria-label={`打包下载 ${e.name}`} disabled={!!zip} onClick={() => zipDir(e.path)}>{Icon.download()}</button>
252
+ )}
253
+ </div>
254
+ ))}
255
+ {end < entries.length && <div aria-hidden="true" style={{ height: (entries.length - end) * rowHeight }} />}
256
+ {nextCursor && <div className="cli-fv-trunc"><button className="cli-fv-btn" disabled={loading || moreLoading} onClick={() => void loadMore()}>{moreLoading ? "加载中…" : "加载更多"}</button><span>已加载 {entries.length.toLocaleString()}{total ? ` / ${total.toLocaleString()}` : ""} 项</span></div>}
257
+ {truncated && !nextCursor && <div className="cli-fv-trunc">文件太多{total ? `(共 ${total.toLocaleString()} 个)` : ""},只列出前 {entries.length.toLocaleString()} 个。</div>}
258
+ </div>
259
+ )}
260
+ </div>
261
+ );
262
+ }
@@ -0,0 +1,52 @@
1
+ import { useEffect, useState, type RefObject } from "react";
2
+ import { readHtmlSections, type HtmlReadingBlock, type HtmlReadingResult } from "./htmlReading";
3
+
4
+ function ReadingBlock({ block }: { block: HtmlReadingBlock }) {
5
+ if (block.kind === "heading") return block.level === 1 ? <h1>{block.text}</h1> : block.level === 2 ? <h2>{block.text}</h2> : <h3>{block.text}</h3>;
6
+ if (block.kind === "code") return <pre className="cli-fv-pre"><code>{block.text}</code></pre>;
7
+ if (block.kind === "table-row") return <p className="cli-html-row">{block.text}</p>;
8
+ if (block.kind === "image") return <p className="cli-html-image">{block.text || "图片"}</p>;
9
+ return <p>{block.text}</p>;
10
+ }
11
+
12
+ /** A bounded reading page: original markup, CSS, scripts and image URLs never enter the DOM. */
13
+ export function HtmlReadingPreview({ text, complete, version, onBusyChange, abortRef, scrollRef, onMore, onSource, onOriginal }: {
14
+ text: string; complete: boolean; version: string;
15
+ onBusyChange: (busy: boolean) => void;
16
+ abortRef: RefObject<AbortController | null>; scrollRef: RefObject<HTMLDivElement | null>;
17
+ onMore?: () => void; onSource: () => void; onOriginal: () => void;
18
+ }) {
19
+ const [state, setState] = useState<{ text: string; version: string; page: number; result: HtmlReadingResult } | null>(null);
20
+ const [error, setError] = useState<string | null>(null), [page, setPage] = useState(0);
21
+ useEffect(() => {
22
+ const ctl = new AbortController(); abortRef.current = ctl;
23
+ setError(null); onBusyChange(true);
24
+ const cancel = () => onBusyChange(false); ctl.signal.addEventListener("abort", cancel, { once: true });
25
+ void readHtmlSections(text, { signal: ctl.signal, eof: complete, page }).then(result => {
26
+ if (!ctl.signal.aborted) setState({ text, version, page, result });
27
+ }, (error: unknown) => { if (!ctl.signal.aborted) setError(error instanceof Error ? error.message : String(error)); })
28
+ .finally(() => { if (!ctl.signal.aborted) onBusyChange(false); });
29
+ return () => { ctl.signal.removeEventListener("abort", cancel); ctl.abort(); onBusyChange(false); };
30
+ }, [text, complete, version, page, onBusyChange, abortRef]);
31
+ const result = state?.text === text && state.version === version && state.page === page ? state.result : null;
32
+ const current = result?.pageOffset ?? 0;
33
+ const move = (next: number) => { setPage(next); if (scrollRef.current) scrollRef.current.scrollTop = 0; };
34
+ if (error) return <div className="cli-fv-note" role="alert">此文档暂时无法整理为阅读内容。<button className="cli-fv-btn" onClick={onSource}>查看源文件</button></div>;
35
+ if (!result) return null;
36
+ return <>
37
+ <div className="cli-fv-trunc cli-html-reading-note">分段阅读 · 已简化原件布局、图片及交互,文件内容不变。</div>
38
+ <div className="cli-fv-md md cli-html-reading">
39
+ {result.pages[0]?.blocks.map((block, index) => <ReadingBlock key={index} block={block} />)}
40
+ {!result.pages.length && <div className="cli-fv-note">这一部分尚无可显示的正文,可能包含样式、图片或脚本。</div>}
41
+ </div>
42
+ {result.limited && <div className="cli-fv-trunc" role="status">{result.reason === "depth" ? "文档嵌套层级过多" : "此部分含超长标记或密集空结构"},已显示可读取部分。<button className="cli-fv-btn" onClick={onSource}>查看源文件</button><button className="cli-fv-btn" onClick={onOriginal}>原样预览</button></div>}
43
+ <nav className="cli-fv-trunc cli-html-page-nav" aria-label="HTML阅读翻页">
44
+ <button className="cli-fv-btn" disabled={!current} onClick={() => move(current - 1)}>上一页</button>
45
+ <span>第 {current + 1} / {Math.max(1, result.pageCount)} 页{!complete ? "(已读取部分)" : ""}</span>
46
+ <button className="cli-fv-btn" disabled={current + 1 >= result.pageCount && (!onMore || result.limited)} onClick={() => {
47
+ if (current + 1 < result.pageCount) move(current + 1);
48
+ else { setPage(current + 1); onMore?.(); }
49
+ }}>{current + 1 < result.pageCount ? "下一页" : onMore && !result.limited ? "继续读取" : "已到结尾"}</button>
50
+ </nav>
51
+ </>;
52
+ }
@@ -0,0 +1,134 @@
1
+ import { Component, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
2
+ import { Markdown } from "../ui/Markdown";
3
+ import { splitMarkdownParts, type MarkdownPart } from "./fileKind";
4
+
5
+ /** Character and structure budgets both matter: a short table may contain thousands of cells. */
6
+ export const MARKDOWN_PREVIEW_CHARS = 16 * 1024;
7
+ type Schedule = (work: () => void) => () => void;
8
+
9
+ function useRenderQueue(): Schedule {
10
+ const pending = useRef(new Map<object, () => void>()), timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
11
+ const schedule = useCallback<Schedule>(work => {
12
+ const key = {}; pending.current.set(key, work);
13
+ const next = () => {
14
+ timer.current = undefined;
15
+ const first = pending.current.entries().next().value;
16
+ if (first) { pending.current.delete(first[0]); first[1](); }
17
+ // One render tree per turn, leaving input and painting a chance between trees.
18
+ if (pending.current.size) timer.current = setTimeout(next, 16);
19
+ };
20
+ if (timer.current === undefined) timer.current = setTimeout(next, 16);
21
+ return () => { pending.current.delete(key); if (!pending.current.size) { clearTimeout(timer.current); timer.current = undefined; } };
22
+ }, []);
23
+ useEffect(() => () => { clearTimeout(timer.current); pending.current.clear(); }, []);
24
+ return schedule;
25
+ }
26
+
27
+ class SegmentBoundary extends Component<{ source: string; children: ReactNode }, { failed: boolean }> {
28
+ state = { failed: false };
29
+ static getDerivedStateFromError() { return { failed: true }; }
30
+ componentDidUpdate(previous: { source: string }) { if (previous.source !== this.props.source && this.state.failed) this.setState({ failed: false }); }
31
+ render() {
32
+ return this.state.failed ? <>
33
+ <div className="cli-fv-trunc" role="status">此段格式无法渲染,已显示原文;其他内容可继续阅读。</div>
34
+ <pre className="cli-fv-pre">{this.props.source}</pre>
35
+ </> : this.props.children;
36
+ }
37
+ }
38
+
39
+ const RenderPart = memo(function RenderPart({ part }: { part: MarkdownPart }) {
40
+ if (part.plain) return <>
41
+ {part.simplified && <div className="cli-fv-trunc" role="status">{part.simplified}</div>}
42
+ <pre className="cli-fv-pre">{part.text}</pre>
43
+ </>;
44
+ if (part.code) return <>
45
+ <div className="cli-fv-trunc">{part.code.language || "代码"} · 分段显示</div>
46
+ <pre className="cli-fv-pre"><code>{part.text.slice(part.code.start - part.start, part.code.end - part.start)}</code></pre>
47
+ </>;
48
+ return <>
49
+ {part.prefix && <div className="cli-fv-trunc">表格续页 · 表头已重复</div>}
50
+ <Markdown highlight={false}>{(part.prefix ?? "") + part.text + (part.definitions ? `\n\n${part.definitions}` : "")}</Markdown>
51
+ </>;
52
+ });
53
+
54
+ function PreviewWindow({ part, index, scrollRef, schedule }: { part: MarkdownPart; index: number; scrollRef: RefObject<HTMLDivElement | null>; schedule: Schedule }) {
55
+ const host = useRef<HTMLDivElement>(null), height = useRef(Math.max(80, Math.ceil(part.text.length / 100) * 22));
56
+ const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined" || index === 0);
57
+ const token = useMemo(() => ({}), [part.text, part.plain, part.prefix, part.definitions, part.simplified, part.code?.start, part.code?.end, part.code?.language]);
58
+ const [ready, setReady] = useState<object | null>(null);
59
+ useEffect(() => {
60
+ const node = host.current;
61
+ if (!node || typeof IntersectionObserver === "undefined") return;
62
+ const observer = new IntersectionObserver(([entry]) => {
63
+ if (!entry.isIntersecting && node.firstElementChild) height.current = node.getBoundingClientRect().height || height.current;
64
+ setVisible(entry.isIntersecting);
65
+ }, { root: scrollRef.current, rootMargin: "600px 0px" });
66
+ observer.observe(node); return () => observer.disconnect();
67
+ }, [scrollRef]);
68
+ useEffect(() => {
69
+ if (!visible) { setReady(null); return; }
70
+ return schedule(() => setReady(token));
71
+ }, [visible, token, schedule]);
72
+ const shown = visible && ready === token;
73
+ useLayoutEffect(() => { if (shown && host.current) height.current = host.current.getBoundingClientRect().height || height.current; }, [shown, token]);
74
+ return <div ref={host} data-source-start={part.start} data-source-end={part.end} style={shown ? undefined : { height: height.current }} aria-hidden={!visible || undefined} aria-busy={visible && !shown || undefined}>
75
+ {shown ? <SegmentBoundary source={part.text}><RenderPart part={part} /></SegmentBoundary> : visible && <div className="cli-fv-note" role="status">正在排版…</div>}
76
+ </div>;
77
+ }
78
+
79
+ /** Distant groups need one placeholder, not an observer and React tree for every fragment. */
80
+ function PreviewGroup({ parts, index, scrollRef, schedule }: { parts: MarkdownPart[]; index: number; scrollRef: RefObject<HTMLDivElement | null>; schedule: Schedule }) {
81
+ const host = useRef<HTMLDivElement>(null), [visible, setVisible] = useState(index === 0);
82
+ const height = useRef(parts.reduce((total, part) => total + Math.max(80, Math.ceil(part.text.length / 100) * 22), 0));
83
+ useEffect(() => {
84
+ const node = host.current; if (!node) return;
85
+ const observer = new IntersectionObserver(([entry]) => {
86
+ if (!entry.isIntersecting && node.firstElementChild) height.current = node.getBoundingClientRect().height || height.current;
87
+ setVisible(entry.isIntersecting);
88
+ }, { root: scrollRef.current, rootMargin: "1000px 0px" });
89
+ observer.observe(node); return () => observer.disconnect();
90
+ }, [scrollRef]);
91
+ return <div ref={host} data-preview-group={index} style={visible ? undefined : { height: height.current }} aria-hidden={!visible || undefined}>
92
+ {visible && parts.map((part, index) => <PreviewWindow key={part.start} part={part} index={index} scrollRef={scrollRef} schedule={schedule} />)}
93
+ </div>;
94
+ }
95
+
96
+ function PreviewParts({ parts, scrollRef }: { parts: MarkdownPart[]; scrollRef: RefObject<HTMLDivElement | null> }) {
97
+ const schedule = useRenderQueue(), [page, setPage] = useState(0);
98
+ const paged = typeof IntersectionObserver === "undefined", currentPage = Math.min(page, Math.max(0, Math.ceil(parts.length / 2) - 1));
99
+ const groups = useMemo(() => { const result: MarkdownPart[][] = []; for (let index = 0; index < parts.length; index += 32) result.push(parts.slice(index, index + 32)); return result; }, [parts]);
100
+ return <>
101
+ {paged ? parts.slice(currentPage * 2, currentPage * 2 + 2).map((part, index) => <PreviewWindow key={part.start} part={part} index={index} scrollRef={scrollRef} schedule={schedule} />)
102
+ : groups.map((group, index) => <PreviewGroup key={group[0].start} parts={group} index={index} scrollRef={scrollRef} schedule={schedule} />)}
103
+ {paged && parts.length > 2 && <div className="cli-fv-trunc">
104
+ <button disabled={!currentPage} className="cli-fv-btn" onClick={() => setPage(currentPage - 1)}>上一部分</button>
105
+ <button disabled={(currentPage + 1) * 2 >= parts.length} className="cli-fv-btn" onClick={() => setPage(currentPage + 1)}>下一部分</button>
106
+ </div>}
107
+ </>;
108
+ }
109
+
110
+ export function MarkdownPreview({ text, scrollRef }: { text: string; scrollRef: RefObject<HTMLDivElement | null> }) {
111
+ const parts = useMemo(() => splitMarkdownParts(text, MARKDOWN_PREVIEW_CHARS, { preview: true }), [text]);
112
+ const first = parts[0];
113
+ // Ordinary small documents do not need scheduling or viewport placeholders.
114
+ const immediate = parts.length === 1 && !first.plain && !first.simplified
115
+ && text.length + (first.prefix?.length ?? 0) + (first.definitions?.length ?? 0) <= 4096
116
+ && (text.match(/[|*_\[\]<>`~]/g)?.length ?? 0) <= 400;
117
+ if (immediate) return <SegmentBoundary source={text}><RenderPart part={first} /></SegmentBoundary>;
118
+ return <PreviewParts parts={parts} scrollRef={scrollRef} />;
119
+ }
120
+
121
+ /** Source views use the same cancellable render queue and viewport bounds, without Markdown parsing. */
122
+ export function BoundedTextPreview({ text, scrollRef }: { text: string; scrollRef: RefObject<HTMLDivElement | null> }) {
123
+ const parts = useMemo(() => {
124
+ const result: MarkdownPart[] = [];
125
+ for (let start = 0; start < text.length;) {
126
+ let end = Math.min(text.length, start + MARKDOWN_PREVIEW_CHARS);
127
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
128
+ result.push({ text: text.slice(start, end), start, end, plain: true }); start = end;
129
+ }
130
+ return result;
131
+ }, [text]);
132
+ if (text.length <= MARKDOWN_PREVIEW_CHARS) return <pre className="cli-fv-pre">{text}</pre>;
133
+ return <PreviewParts parts={parts} scrollRef={scrollRef} />;
134
+ }
@@ -0,0 +1,40 @@
1
+ import { useState, type Ref } from 'react';
2
+ import { Icon } from '../ui/icons';
3
+ import { Popover } from '../ui/Popover';
4
+ /** The same board can place its tabs in the chat header without duplicating its content. */
5
+ export interface InlineBoardLayout {
6
+ tabsHost: HTMLElement | null;
7
+ active: boolean;
8
+ onActivate: () => void;
9
+ onChat: () => void;
10
+ }
11
+ export function PanelTab({ id, label, title, active, onSelect, onClose }: {
12
+ id: string; label: string; title?: string; active: boolean; onSelect: () => void; onClose: () => void;
13
+ }) {
14
+ return <span className={`rail-tab rail-ftab ${active ? 'on' : ''}`} title={title ?? label} onClick={onSelect}>
15
+ <button className="rail-ftab-l" role="tab" aria-selected={active} aria-label={label} data-panel-id={id} onClick={event => { event.stopPropagation(); onSelect(); }}><span className="rail-ftab-text">{label}</span></button>
16
+ <button className="rail-ftab-x" aria-label={id.startsWith("file:") ? `关闭 ${label}` : `关闭${label}面板`} title={id === 'browser' ? '隐藏浏览器面板,浏览器任务继续运行' : id.startsWith('file:') ? '关闭文件标签,可从工作区重新打开' : `关闭${label}面板,可从添加模块重新打开`}
17
+ onClick={event => { event.stopPropagation(); onClose(); }}>{Icon.x()}</button>
18
+ </span>;
19
+ }
20
+ export function PanelControls({ available, open, label, onShow, onClose, toolbarRef, upload }: {
21
+ toolbarRef?: Ref<HTMLSpanElement>;
22
+ upload?: { onChoose: () => void; disabled?: boolean; hint?: string };
23
+ available: readonly string[]; open: readonly string[]; label: (id: string) => string;
24
+ onShow: (id: string) => void; onClose: (id: string) => void;
25
+ }) {
26
+ const [expanded, setExpanded] = useState(false);
27
+ return <div className="cli-panel-controls">
28
+ {toolbarRef && <span className="cli-board-content-actions" ref={toolbarRef} />}
29
+ <button className="rail-collapse" aria-label="管理看板模块" title="添加或关闭看板模块" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>{Icon.plus()}</button>
30
+ {expanded && <Popover onClose={() => setExpanded(false)}>
31
+ {upload && <button className="pop-item" disabled={upload.disabled} title={upload.hint}
32
+ onClick={() => { setExpanded(false); upload.onChoose(); }}>{Icon.clip()}<span>上传文件</span></button>}
33
+ <div className="pop-sec">看板模块 · 可独立打开或关闭</div>
34
+ {available.map(id => <button key={id} className="pop-item" aria-label={`${open.includes(id) ? '关闭' : '打开'}${label(id)}面板`} aria-pressed={open.includes(id)}
35
+ onClick={() => { if (open.includes(id)) onClose(id); else onShow(id); setExpanded(false); }}>
36
+ <span>{label(id)}</span>{open.includes(id) ? Icon.check() : Icon.plus()}
37
+ </button>)}
38
+ </Popover>}
39
+ </div>;
40
+ }
@@ -0,0 +1,97 @@
1
+ // 看板的两个核心,各自是一块独立的积木,放到哪里都能用:
2
+ //
3
+ // <CliFilePreview jobId path /> 任务工作区里一个文件的预览 + 下载(Markdown / 代码 / CSV / 图片 / PDF / HTML / Word)
4
+ // <CliBrowserView jobId /> 任务浏览器的实时画面 + 接管 / 交还 / 打开 / 关闭
5
+ //
6
+ // 和别的组件一样:外面有 <CliCenterProvider> 就共用它(同一个对话只连一条流);没有就用自己的连接参数
7
+ // (baseUrl / token / line / headers)建一个。不给 jobId 时跟 Provider 的选中走。
8
+ // 它们只依赖中枢的看板接口(/api/jobs/:id/files、/browser…),不依赖 cli-chat / cli-board。
9
+ import { useState, type CSSProperties } from "react";
10
+ import { CliErrorBoundary } from "../ui/ErrorBoundary";
11
+ import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
12
+ import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider";
13
+ import { FileViewer, type FileAction } from "./FileViewer";
14
+ import { BrowserPane } from "./BrowserPane";
15
+
16
+ interface BlockProps extends CliStoreOptions {
17
+ /** 哪个对话(center 的 job_id)。不给就跟 Provider 的选中走。 */
18
+ jobId?: string | null;
19
+ theme?: "light" | "dark";
20
+ className?: string;
21
+ style?: CSSProperties;
22
+ store?: CliStore;
23
+ onEvent?: (event: CliEvent) => void;
24
+ }
25
+
26
+ export interface CliFilePreviewProps extends BlockProps {
27
+ /** 工作区里的相对路径(如 "outputs/报告.md"),或节点上的绝对路径。 */
28
+ path: string;
29
+ /** 预览里点了别的本地文件(Markdown 里的链接)。不给就在本组件里切换过去。 */
30
+ onOpenPath?: (path: string) => void;
31
+ /** 给了就显示关闭按钮。 */
32
+ onClose?: () => void;
33
+ /** 头部的自定义按钮。 */
34
+ actions?: FileAction[];
35
+ /** 变了就重读(宿主知道文件更新时递增)。 */
36
+ version?: number;
37
+ /** 不要头部(宿主自己做标题和按钮)。 */
38
+ bare?: boolean;
39
+ }
40
+
41
+ export function CliFilePreview(props: CliFilePreviewProps) {
42
+ const ctx = useCliStoreContext();
43
+ return <CliErrorBoundary component="cli-file-preview" theme={props.theme}>{withOwnProvider(props, <FilePreviewInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
44
+ }
45
+
46
+ function FilePreviewInner({ jobId: jobIdProp, path, onOpenPath, onClose, actions, version, bare, theme = "light", className, style, onEvent, inProvider }: CliFilePreviewProps & { inProvider: boolean }) {
47
+ useComponentEvents(onEvent, {}, inProvider);
48
+ const store = useCliStore();
49
+ const selected = useCliState((s) => s.selected);
50
+ const jobId = jobIdProp !== undefined ? jobIdProp : selected;
51
+ const [shown, setShown] = useShownPath(path);
52
+ const backend = store.client.board;
53
+ return (
54
+ <div className={`cli-ui cli-file-preview ${className ?? ""}`} data-theme={theme} style={style}>
55
+ {!backend ? <div className="cli-fv-note">当前后端不支持文件预览(需要任务中心的 /api/jobs/:id/files 接口)。</div>
56
+ : !jobId ? <div className="cli-fv-note">没有指定对话。</div>
57
+ : !shown ? <div className="cli-fv-note">没有指定文件。</div>
58
+ : <FileViewer key={`${jobId}:${shown}`} backend={backend} jobId={jobId} path={shown} version={version} actions={actions} onClose={onClose} bare={bare}
59
+ onOpenPath={onOpenPath ?? setShown} />}
60
+ </div>
61
+ );
62
+ }
63
+
64
+ export interface CliBrowserViewProps extends BlockProps {
65
+ /** 只看:不能打开 / 关闭 / 接管。 */
66
+ readOnly?: boolean;
67
+ }
68
+
69
+ export function CliBrowserView(props: CliBrowserViewProps) {
70
+ const ctx = useCliStoreContext();
71
+ return <CliErrorBoundary component="cli-browser" theme={props.theme}>{withOwnProvider(props, <BrowserViewInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
72
+ }
73
+
74
+ function BrowserViewInner({ jobId: jobIdProp, readOnly, theme = "light", className, style, onEvent, inProvider }: CliBrowserViewProps & { inProvider: boolean }) {
75
+ useComponentEvents(onEvent, {}, inProvider);
76
+ const store = useCliStore();
77
+ const selected = useCliState((s) => s.selected);
78
+ const jobId = jobIdProp !== undefined ? jobIdProp : selected;
79
+ const backend = store.client.board;
80
+ return (
81
+ <div className={`cli-ui cli-browser-view ${className ?? ""}`} data-theme={theme} style={style}>
82
+ {!backend ? <div className="cli-fv-note">当前后端不支持任务浏览器(需要任务中心的 /api/jobs/:id/browser 接口)。</div>
83
+ : !jobId ? <div className="cli-fv-note">没有指定对话。</div>
84
+ : <BrowserPane key={jobId} backend={backend} jobId={jobId} readOnly={readOnly} />}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ /** 显示的路径:跟着 prop 走;预览里点了别的文件时在组件内切换,prop 变了再以 prop 为准。 */
90
+ function useShownPath(path: string): [string, (p: string) => void] {
91
+ const [state, setState] = useState({ prop: path, shown: path });
92
+ if (state.prop !== path) {
93
+ setState({ prop: path, shown: path });
94
+ return [path, (p) => setState({ prop: path, shown: p })];
95
+ }
96
+ return [state.shown, (p) => setState((s) => ({ ...s, shown: p }))];
97
+ }