@spzxg/cli-ui 0.6.0 → 0.6.2

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.
package/dist/cli-ui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-CXdmrwo1.js";
1
+ import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-6IJ_jRVh.js";
2
2
  export {
3
3
  a as CliBoard,
4
4
  i as CliBrowserView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "任务中心(cli-task-center)对话任务的前端积木:cli-msglist 消息对话列表、cli-chat 对话,数据全部来自 center 服务。",
6
6
  "main": "src/index.ts",
@@ -44,6 +44,7 @@
44
44
  "vitest": "^2.1.8"
45
45
  },
46
46
  "dependencies": {
47
+ "jszip": "^3.10.2",
47
48
  "mammoth": "^1.13.0",
48
49
  "react-markdown": "^10.1.0",
49
50
  "rehype-highlight": "^7.0.2",
@@ -264,9 +264,9 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
264
264
  useEffect(() => {
265
265
  if (!jobId || !autoView) return;
266
266
  store.setViewing(jobId, viewingPath
267
- ? { id: `file:${viewingPath}`, kind: "file", label: viewingPath.split("/").pop() || viewingPath, text: `当前在看板里打开的文件 ${viewingPath}` }
267
+ ? { id: `file:${viewingPath}`, kind: "file", label: viewingPath.split("/").pop() || viewingPath, text: `用户当前在看板里打开着工作区文件 ${viewingPath}` }
268
268
  : viewingPage
269
- ? { id: "browser:page", kind: "browser", label: viewingPage.title || viewingPage.url, text: `当前在看板里看的任务浏览器页面:${viewingPage.title ? `${viewingPage.title} — ` : ""}${viewingPage.url}` }
269
+ ? { id: "browser:page", kind: "browser", label: viewingPage.title || viewingPage.url, text: `用户当前在看板里看着任务浏览器页面:${viewingPage.title ? `${viewingPage.title} — ` : ""}${viewingPage.url}` }
270
270
  : null, owner);
271
271
  }, [store, jobId, autoView, viewingPath, viewingPage?.url, viewingPage?.title, owner]); // eslint-disable-line react-hooks/exhaustive-deps
272
272
  useEffect(() => () => { if (jobId) store.setViewing(jobId, null, owner); }, [store, jobId, owner]);
@@ -6,14 +6,17 @@
6
6
  // 流式加载、支持断点,不把文件读进浏览器内存。中枢太旧发不了短期链接时才先 fetch 成 blob,这时超大的文件不预览、只给下载。
7
7
  // · 文本只读开头 512 KB(Range),超过的可以切到「看末尾」(日志);GBK 导出的文件按 GB18030 解。
8
8
  // · 文件在变(任务还在写):看板递增 version 时重读;读的时候保留滚动位置,原本在最底下的继续贴着底(像 tail -f)。
9
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
9
+ import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
10
10
  import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
11
11
  import { Icon } from "../ui/icons.js";
12
12
  import { Markdown } from "../ui/Markdown.js";
13
13
  import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
14
- import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, type PreviewKind } from "./fileKind.js";
14
+ import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, splitMarkdown, type PreviewKind } from "./fileKind.js";
15
+ import { loadText, refreshText, type LoadedText } from "./textLoad.js";
16
+ import { decodeText } from "../core/textDecode.js";
15
17
  import { relTime } from "../ui/format.js";
16
18
  import { downloadFile, mimeOf } from "./fileOps.js";
19
+ import { bridgeDoc, frameToken, type FrameSelMessage } from "./frameQuote.js";
17
20
 
18
21
  export { downloadFile };
19
22
 
@@ -26,14 +29,19 @@ export interface FileAction {
26
29
  onClick: (file: { path: string; name: string; url: string; downloadUrl: string }, jobId: string) => void;
27
30
  }
28
31
 
29
- const TEXT_MAX = 512 * 1024;
30
- const HIGHLIGHT_MAX = 120 * 1024;
31
- /** Markdown 超过这么多按纯文本显示(渲染几百 KB 的 Markdown 会让页面卡住)。 */
32
- const MARKDOWN_RENDER_MAX = 300 * 1024;
33
- /** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不预览、不在浏览器里拼下载。 */
34
- const BLOB_PREVIEW_MAX = 50 * 1024 * 1024;
32
+ // 预览能看多大(实测 Chromium:32 MB 纯文本排版约 3 秒,60 MB 的 HTML 报告约 12 秒显示出来):
33
+ /** 文本(代码、日志、Markdown、CSV、HTML 源文件)一次最多读这么多;更大的看开头 / 末尾。 */
34
+ export const TEXT_MAX = 32 * 1024 * 1024;
35
+ /** HTML 预览(能选中引用)最多读这么多进来;更大的直接按地址打开(能看,不能选中引用)。 */
36
+ export const HTML_QUOTE_MAX = 100 * 1024 * 1024;
37
+ /** 代码高亮到这么大(约 1 MB/秒);更大的按纯文本显示(照样能选中引用)。 */
38
+ const HIGHLIGHT_MAX = 512 * 1024;
39
+ /** 大 Markdown 分段渲染,每段这么多字(渲染耗时随长度超线性增长:300 KB 约 0.7 秒,3 MB 一次渲染要 30 秒)。 */
40
+ const MARKDOWN_SECTION = 128 * 1024;
41
+ /** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的图片 / PDF 不预览、不在浏览器里拼下载。 */
42
+ const BLOB_PREVIEW_MAX = 100 * 1024 * 1024;
35
43
  /** Word 在浏览器里转换,太大的只给下载。 */
36
- const DOCX_PREVIEW_MAX = 30 * 1024 * 1024;
44
+ const DOCX_PREVIEW_MAX = 50 * 1024 * 1024;
37
45
  /** 短期链接 30 分钟有效:每 5 分钟问一次(客户端缓存着,快到期才真的换),长时间开着的预览不会失效。 */
38
46
  const LINK_RECHECK_MS = 5 * 60_000;
39
47
 
@@ -97,7 +105,7 @@ function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled:
97
105
  }
98
106
 
99
107
  type Loaded =
100
- | { kind: "text"; text: string; truncated: boolean; size: number; tail: boolean; encoding?: string }
108
+ | ({ kind: "text" } & LoadedText)
101
109
  | { kind: "docx"; html: string }
102
110
  | { kind: "none" };
103
111
 
@@ -118,7 +126,7 @@ export interface FileViewerProps {
118
126
  actions?: FileAction[];
119
127
  /** 隐藏头部(宿主自己做)。 */
120
128
  bare?: boolean;
121
- /** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 / 表格)。 */
129
+ /** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 / 表格 / HTML / Word,及各自的源文件)。 */
122
130
  onQuoteText?: (text: string) => void;
123
131
  }
124
132
 
@@ -149,23 +157,79 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
149
157
  return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
150
158
  }, [big]);
151
159
  const needsSrc = kind === "image" || kind === "pdf" || kind === "html";
160
+ // 这个预览的口令:iframe 里的选区桥报上来的消息只认它(见 frameQuote.ts)。
161
+ const token = useMemo(frameToken, []);
152
162
  const links = useFileLinks(backend, jobId);
153
163
  const knownSize = size ?? (loaded?.kind === "text" ? loaded.size : undefined);
154
- const src = useFileSrc(backend, jobId, path, needsSrc, version, knownSize, links, attempt);
164
+ // 读大文件时的进度(已收到的字节数)。
165
+ const [progress, setProgress] = useState(0);
166
+ // HTML 要能选中引用:整个读进来(最多 HTML_QUOTE_MAX)、插上选区桥,用 srcdoc 显示(见 frameQuote.ts)。
167
+ // 更大的、读失败的退回原来的 src 方式(能看,不能选中引用)。相对资源按文件地址解析;中枢太旧拿不到地址时相对资源显示不了(和以前一样)。
168
+ // 文件地址在读到的那一刻定下来:短期链接定时换新时不重载页面。
169
+ const linksRef = useRef(links);
170
+ linksRef.current = links;
171
+ const htmlTooBig = size !== undefined && size > HTML_QUOTE_MAX;
172
+ const htmlBridge = !!onQuoteText && kind === "html" && mode === "preview" && !links.pending && !htmlTooBig;
173
+ const [htmlDoc, setHtmlDoc] = useState<{ srcDoc: string } | "fallback" | "toobig" | null>(null);
174
+ // 走 srcdoc 时不用再按地址取(中枢太旧时那是又整个读一遍)。
175
+ const bridged = htmlBridge && htmlDoc !== "fallback" && htmlDoc !== "toobig";
176
+ const src = useFileSrc(backend, jobId, path, needsSrc && !bridged, version, knownSize, links, attempt);
177
+ useEffect(() => {
178
+ if (!htmlBridge) return;
179
+ const ctl = new AbortController();
180
+ let over = false;
181
+ setHtmlDoc(null);
182
+ setProgress(0);
183
+ backend.readBuffer(jobId, path, {
184
+ signal: ctl.signal,
185
+ // 大小事先不知道(从对话里打开的):读着读着超过上限就停下,改成直接打开。
186
+ onBytes: (n) => { setProgress(n); if (n > HTML_QUOTE_MAX && !over) { over = true; ctl.abort(); } },
187
+ }).then((buffer) => {
188
+ if (ctl.signal.aborted) return;
189
+ const url = linksRef.current.url?.(path);
190
+ const base = url ? withVersion(new URL(url, window.location.href).href, version) : undefined;
191
+ setHtmlDoc({ srcDoc: bridgeDoc(decodeText(new Uint8Array(buffer)).text, { token, base }) });
192
+ }, () => { if (over) setHtmlDoc("toobig"); else if (!ctl.signal.aborted) setHtmlDoc("fallback"); });
193
+ return () => { if (!over) ctl.abort(); };
194
+ }, [htmlBridge, backend, jobId, path, version, attempt, token]);
155
195
 
156
196
  // 重读时保留滚动位置;原本贴着底(或在看末尾)的继续贴着底——任务在往文件里写时像 tail -f。
157
197
  const bodyRef = useRef<HTMLDivElement | null>(null);
198
+ const loadedRef = useRef(loaded);
199
+ loadedRef.current = loaded;
200
+ const textKey = useRef("");
158
201
  const rootRef = useRef<HTMLDivElement | null>(null);
159
202
  const stick = useRef(false);
160
203
  // 选中文字 → 浮出「引用到对话」。按钮放在选区上方;滚动、点别处、选区清空时收起。
161
- const [sel, setSel] = useState<{ text: string; x: number; y: number } | null>(null);
204
+ // frame = 选区在 HTML / Word 预览的 iframe 里(由插进去的小脚本报上来,见 frameQuote.ts)。
205
+ const [sel, setSel] = useState<{ text: string; x: number; y: number; frame?: boolean } | null>(null);
206
+ const frameRef = useRef<HTMLIFrameElement | null>(null);
207
+ useEffect(() => {
208
+ if (!onQuoteText) return;
209
+ const onMessage = (e: MessageEvent) => {
210
+ const frame = frameRef.current;
211
+ const root = rootRef.current;
212
+ const data = e.data as FrameSelMessage | null;
213
+ if (!frame || !root || e.source !== frame.contentWindow || !data || data.cliUiSel !== token) return;
214
+ const text = data.scroll ? "" : (data.text ?? "").trim();
215
+ if (!text) { setSel((cur) => (cur?.frame ? null : cur)); return; }
216
+ const box = root.getBoundingClientRect();
217
+ const fr = frame.getBoundingClientRect();
218
+ const r = data.rect ?? { left: fr.width / 2, top: 48, width: 0 };
219
+ setSel({ text, frame: true, x: Math.min(Math.max(8, fr.left - box.left + r.left + r.width / 2), box.width - 8), y: Math.max(40, fr.top - box.top + r.top) });
220
+ };
221
+ window.addEventListener("message", onMessage);
222
+ return () => window.removeEventListener("message", onMessage);
223
+ }, [onQuoteText, token]);
162
224
  useEffect(() => {
163
225
  if (!onQuoteText) return;
164
226
  const read = () => {
165
227
  const s = window.getSelection();
166
228
  const body = bodyRef.current;
167
229
  const root = rootRef.current;
168
- if (!s || s.isCollapsed || !s.rangeCount || !body || !root || !body.contains(s.anchorNode) || !body.contains(s.focusNode)) { setSel(null); return; }
230
+ // 宿主页面上没有选区:iframe 里的选区(另一个文档)不在这里,交给上面的消息处理,不清掉。
231
+ if (!s || s.isCollapsed || !s.rangeCount) { setSel((cur) => (cur?.frame ? cur : null)); return; }
232
+ if (!body || !root || !body.contains(s.anchorNode) || !body.contains(s.focusNode)) { setSel(null); return; }
169
233
  const text = s.toString().trim();
170
234
  if (!text) { setSel(null); return; }
171
235
  const box = root.getBoundingClientRect();
@@ -175,14 +239,18 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
175
239
  setSel({ text, x: Math.min(Math.max(8, r.left - box.left + r.width / 2), box.width - 8), y: Math.max(40, r.top - box.top) });
176
240
  };
177
241
  const onUp = () => setTimeout(read, 0);
178
- const onChange = () => { const s = window.getSelection(); if (!s || s.isCollapsed) setSel(null); };
242
+ const onChange = () => { const s = window.getSelection(); if (!s || s.isCollapsed) setSel((cur) => (cur?.frame ? cur : null)); };
179
243
  const body = bodyRef.current;
180
244
  document.addEventListener("mouseup", onUp);
181
245
  document.addEventListener("keyup", onUp);
182
246
  document.addEventListener("selectionchange", onChange);
183
247
  const onScroll = () => setSel(null);
248
+ // 选区在 iframe 里时,点宿主页面别处(不是这个按钮)就收起。
249
+ const onDown = (e: MouseEvent) => { if (!(e.target instanceof Element && e.target.closest(".cli-fv-selq"))) setSel((cur) => (cur?.frame ? null : cur)); };
184
250
  body?.addEventListener("scroll", onScroll, { passive: true });
251
+ document.addEventListener("mousedown", onDown);
185
252
  return () => {
253
+ document.removeEventListener("mousedown", onDown);
186
254
  document.removeEventListener("mouseup", onUp);
187
255
  document.removeEventListener("keyup", onUp);
188
256
  document.removeEventListener("selectionchange", onChange);
@@ -193,7 +261,8 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
193
261
  if (!sel || !onQuoteText) return;
194
262
  const text = sel.text.length > QUOTE_SELECTION_MAX ? `${sel.text.slice(0, QUOTE_SELECTION_MAX)}…` : sel.text;
195
263
  onQuoteText(text);
196
- window.getSelection()?.removeAllRanges();
264
+ if (sel.frame) frameRef.current?.contentWindow?.postMessage({ cliUiClear: token }, "*");
265
+ else window.getSelection()?.removeAllRanges();
197
266
  setSel(null);
198
267
  };
199
268
  useEffect(() => {
@@ -202,7 +271,8 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
202
271
  if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
203
272
  if (kind === "docx") {
204
273
  if (size !== undefined && size > DOCX_PREVIEW_MAX) { setError(`文件较大(${fmtSize(size)}),请下载后查看`); return; }
205
- backend.readBuffer(jobId, path).then(async (buffer) => {
274
+ setProgress(0);
275
+ backend.readBuffer(jobId, path, { onBytes: (n) => { if (alive) setProgress(n); } }).then(async (buffer) => {
206
276
  const mammoth = await import("mammoth");
207
277
  const convert = (mammoth as unknown as { convertToHtml: typeof import("mammoth").convertToHtml }).convertToHtml
208
278
  ?? (mammoth as unknown as { default: typeof import("mammoth") }).default.convertToHtml;
@@ -213,12 +283,18 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
213
283
  if (alive) setLoaded({ kind: "docx", html: out.value });
214
284
  }).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
215
285
  } else {
216
- backend.readText(jobId, path, TEXT_MAX, { tail }).then(
286
+ // 只是文件变了(同一个文件、同样看开头 / 末尾):能接上就只取新增的部分(见 textLoad.ts),接不上再整个重读。
287
+ const key = `${path}|${tail}|${attempt}`;
288
+ const prev = loadedRef.current?.kind === "text" && textKey.current === key ? loadedRef.current : null;
289
+ textKey.current = key;
290
+ if (!prev) setProgress(0);
291
+ const full = () => loadText(backend, jobId, path, { max: TEXT_MAX, tail, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
292
+ (prev ? refreshText(backend, jobId, path, prev, TEXT_MAX).then((r) => r ?? full(), full) : full()).then(
217
293
  (r) => {
218
294
  if (!alive) return;
219
295
  const box = bodyRef.current;
220
296
  stick.current = tail || (!!box && box.scrollHeight > box.clientHeight && box.scrollHeight - box.scrollTop - box.clientHeight < 40);
221
- setLoaded({ kind: "text", text: r.text, truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding });
297
+ setLoaded({ kind: "text", ...r });
222
298
  },
223
299
  (e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); },
224
300
  );
@@ -251,11 +327,13 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
251
327
  const canSource = kind === "markdown" || kind === "html" || kind === "csv";
252
328
  const shownSize = knownSize;
253
329
  const text = loaded?.kind === "text" ? loaded : null;
254
- // 太大的 Markdown 按纯文本显示(渲染会卡住页面)。
255
- const mdTooBig = !!text && kind === "markdown" && text.text.length > MARKDOWN_RENDER_MAX;
256
- const loading = !error && !src.error && (needsSrc ? !src.url : !loaded);
330
+ const loading = !error && !src.error && (needsSrc ? (bridged ? htmlDoc === null : !src.url) : !loaded);
257
331
  const slow = useSlow(loading);
258
- const slowNote = slow ? <div className="cli-fv-slow">网络较慢或执行节点忙,仍在读取{shownSize ? `(${fmtSize(shownSize)})` : ""}…</div> : null;
332
+ // 大文件显示读到哪了;很久没读完提示网络慢。
333
+ const progressText = progress > 0 ? `已读取 ${fmtSize(progress)}${shownSize ? ` / ${fmtSize(shownSize)}` : ""}` : "";
334
+ const slowNote = progressText || slow
335
+ ? <div className="cli-fv-slow">{slow ? `网络较慢或执行节点忙,仍在读取${progressText ? `(${progressText})` : shownSize ? `(${fmtSize(shownSize)})` : ""}…` : progressText}</div>
336
+ : null;
259
337
 
260
338
  return (
261
339
  <>
@@ -303,16 +381,25 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
303
381
  <button className="btn btn-accent btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
304
382
  </div>
305
383
  ) : needsSrc ? (
306
- !src.url ? <div className="cli-fv-note">加载中…{slowNote}</div>
384
+ bridged && htmlDoc === null ? <div className="cli-fv-note">加载中…{slowNote}</div>
385
+ // 可以选中引用:srcdoc + 选区桥。沙箱同样不给同源,页面自己的脚本被插入的 CSP 挡住,只有桥脚本能跑。
386
+ : bridged && htmlDoc ? <iframe ref={frameRef} className="cli-fv-frame" srcDoc={htmlDoc.srcDoc} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
387
+ : !src.url ? <div className="cli-fv-note">加载中…{slowNote}</div>
307
388
  : kind === "image" ? <ImageView key={src.url} src={src.url} name={name} onRetry={retry} />
308
389
  : kind === "pdf" ? <iframe className="cli-fv-frame" src={src.url} title={name} />
309
390
  : mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={version} />
310
391
  // HTML:节点按沙箱 CSP 返回;这里再加 iframe 沙箱(不给同源),页面脚本碰不到宿主。
311
- : <iframe className="cli-fv-frame" src={src.url} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
392
+ : <>
393
+ {onQuoteText && (htmlTooBig || htmlDoc === "toobig") && <div className="cli-fv-trunc">文件超过 {fmtSize(HTML_QUOTE_MAX)},直接打开显示,不能选中文字引用(可以用「引用到对话」引用整个文件)。</div>}
394
+ <iframe className="cli-fv-frame" src={src.url} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
395
+ </>
312
396
  ) : !loaded ? (
313
397
  <div className="cli-fv-note">加载中…{slowNote}</div>
314
398
  ) : loaded.kind === "docx" ? (
315
- <iframe className="cli-fv-frame doc" title={name} sandbox="" srcDoc={docxPage(loaded.html)} />
399
+ // Word:转出来的 HTML 放进沙箱。能选中引用时插上选区桥(只放行桥脚本,文档里不会有别的脚本能跑)。
400
+ onQuoteText
401
+ ? <iframe ref={frameRef} className="cli-fv-frame doc" title={name} sandbox="allow-scripts" srcDoc={bridgeDoc(docxPage(loaded.html), { token })} />
402
+ : <iframe className="cli-fv-frame doc" title={name} sandbox="" srcDoc={docxPage(loaded.html)} />
316
403
  ) : text ? (
317
404
  <>
318
405
  {(text.truncated || tail) && (
@@ -322,11 +409,10 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
322
409
  <button className="cli-fv-btn" onClick={() => setTail(!tail)}>{tail ? "看开头" : "看末尾"}</button>
323
410
  </div>
324
411
  )}
325
- {mdTooBig && <div className="cli-fv-trunc">Markdown 较大,按纯文本显示。</div>}
326
- {kind === "markdown" && mode === "preview" && !mdTooBig ? (
412
+ {kind === "markdown" && mode === "preview" ? (
327
413
  <FileLinkContext.Provider value={mdResolve}>
328
414
  <FileOpenContext.Provider value={mdOpen}>
329
- <div className="cli-fv-md"><Markdown>{text.text}</Markdown></div>
415
+ <div className="cli-fv-md"><MarkdownSections text={text.text} scrollRef={bodyRef} /></div>
330
416
  </FileOpenContext.Provider>
331
417
  </FileLinkContext.Provider>
332
418
  ) : kind === "csv" && mode === "preview" ? (
@@ -334,7 +420,8 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
334
420
  ) : kind === "code" && text.text.length <= HIGHLIGHT_MAX ? (
335
421
  <div className="cli-fv-code"><Markdown>{fenced(text.text, codeLang(path))}</Markdown></div>
336
422
  ) : (
337
- <pre className="cli-fv-pre">{text.text}</pre>
423
+ // 追加的内容各是一个文字节点:文件往后长的时候前面的不用重新排版。
424
+ <pre className="cli-fv-pre">{text.parts.map((p, i) => <Fragment key={i}>{p}</Fragment>)}</pre>
338
425
  )}
339
426
  </>
340
427
  ) : null}
@@ -398,17 +485,58 @@ function ImageView({ src, name, onRetry }: { src: string; name: string; onRetry?
398
485
  }
399
486
 
400
487
  function SourceView({ backend, jobId, path, version }: { backend: BoardBackend; jobId: string; path: string; version: number }) {
401
- const [text, setText] = useState<string | null>(null);
488
+ const [state, setState] = useState<{ text: string; size?: number; truncated?: boolean } | null>(null);
402
489
  useEffect(() => {
403
490
  let alive = true;
404
- backend.readText(jobId, path, TEXT_MAX).then((r) => { if (alive) setText(r.text); }, (e: unknown) => { if (alive) setText(`读取失败:${e instanceof Error ? e.message : String(e)}`); });
491
+ backend.readText(jobId, path, TEXT_MAX).then((r) => { if (alive) setState({ text: r.text, size: r.size, truncated: r.truncated }); }, (e: unknown) => { if (alive) setState({ text: `读取失败:${e instanceof Error ? e.message : String(e)}` }); });
405
492
  return () => { alive = false; };
406
493
  }, [backend, jobId, path, version]);
407
- return text === null ? <div className="cli-fv-note">加载中…</div> : <pre className="cli-fv-pre">{text}</pre>;
494
+ if (!state) return <div className="cli-fv-note">加载中…</div>;
495
+ return (
496
+ <>
497
+ {state.truncated && <div className="cli-fv-trunc">文件较大({fmtSize(state.size ?? 0)}),源文件只显示前 {fmtSize(TEXT_MAX)};完整内容请下载。</div>}
498
+ <pre className="cli-fv-pre">{state.text}</pre>
499
+ </>
500
+ );
408
501
  }
409
502
 
503
+ /** 一段 Markdown:内容没变就不重新渲染(文件追加时只有最后一段在变)。 */
504
+ const MarkdownSection = memo(function MarkdownSection({ text }: { text: string }) {
505
+ return <Markdown>{text}</Markdown>;
506
+ });
507
+
508
+ /**
509
+ * 大 Markdown 分段渲染:先渲染前几段,滚到快看完的地方再接着渲染后面的(每段约 MARKDOWN_SECTION 字)。
510
+ * 小文件就是一段,和整篇渲染一样。没有 IntersectionObserver 的环境(老浏览器、服务端)一次全部渲染。
511
+ */
512
+ function MarkdownSections({ text, scrollRef }: { text: string; scrollRef: RefObject<HTMLDivElement | null> }) {
513
+ const sections = useMemo(() => splitMarkdown(text, MARKDOWN_SECTION), [text]);
514
+ const lazy = typeof IntersectionObserver !== "undefined";
515
+ const [shown, setShown] = useState(2);
516
+ const sentinel = useRef<HTMLDivElement | null>(null);
517
+ const more = shown < sections.length && lazy;
518
+ useEffect(() => {
519
+ const el = sentinel.current;
520
+ if (!more || !el) return;
521
+ const io = new IntersectionObserver((entries) => { if (entries.some((e) => e.isIntersecting)) setShown((n) => n + 2); }, { root: scrollRef.current, rootMargin: "0px 0px 2000px 0px" });
522
+ io.observe(el);
523
+ return () => io.disconnect();
524
+ }, [more, shown, scrollRef]);
525
+ const visible = lazy ? sections.slice(0, shown) : sections;
526
+ return (
527
+ <>
528
+ {visible.map((s, i) => <MarkdownSection key={i} text={s} />)}
529
+ {more && <div ref={sentinel} className="cli-fv-slow">还有 {sections.length - shown} 段,继续往下滚动显示…</div>}
530
+ </>
531
+ );
532
+ }
533
+
534
+ /** 表格先显示这么多行,「再显示」每次多这么多(几十万行一次画出来页面会卡住)。 */
535
+ const CSV_PAGE = 2_000;
536
+
410
537
  function CsvTable({ text }: { text: string }) {
411
- const { rows, more } = useMemo(() => parseCsv(text), [text]);
538
+ const [limit, setLimit] = useState(CSV_PAGE);
539
+ const { rows, more } = useMemo(() => parseCsv(text, limit + 1), [text, limit]);
412
540
  if (!rows.length) return <div className="cli-fv-note">空文件。</div>;
413
541
  const [head, ...body] = rows;
414
542
  return (
@@ -417,7 +545,12 @@ function CsvTable({ text }: { text: string }) {
417
545
  <thead><tr>{head.map((c, i) => <th key={i}>{c}</th>)}</tr></thead>
418
546
  <tbody>{body.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody>
419
547
  </table>
420
- {more && <div className="cli-fv-trunc">只显示前 500 行;完整内容请下载。</div>}
548
+ {more && (
549
+ <div className="cli-fv-trunc">已显示前 {body.length.toLocaleString()} 行。
550
+ <button className="cli-fv-btn" onClick={() => setLimit((n) => n + CSV_PAGE * 5)}>再显示 {(CSV_PAGE * 5).toLocaleString()} 行</button>
551
+ (也可以切到「源文件」看全部文字)
552
+ </div>
553
+ )}
421
554
  </div>
422
555
  );
423
556
  }
@@ -79,3 +79,34 @@ export function parseCsv(text: string, maxRows = 500): { rows: string[][]; more:
79
79
  if (cell || row.length) { row.push(cell); rows.push(row); }
80
80
  return { rows: rows.slice(0, maxRows), more: rows.length > maxRows };
81
81
  }
82
+
83
+ /**
84
+ * 大 Markdown 分段(每段约 size 字):在代码块外的空行处切,各段单独渲染(整篇几 MB 一次渲染会卡住页面几十秒)。
85
+ * 一直没有空行的超长内容到 4 倍大小也切(在代码块里切时补上收尾、下一段重新开头)。
86
+ */
87
+ export function splitMarkdown(text: string, size: number): string[] {
88
+ if (text.length <= size) return [text];
89
+ const out: string[] = [];
90
+ const lines = text.split("\n");
91
+ let cur: string[] = [];
92
+ let len = 0;
93
+ let fence: string | null = null;
94
+ for (const line of lines) {
95
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
96
+ if (m) {
97
+ if (!fence) fence = line.trim();
98
+ else if (line.trim().startsWith(fence.slice(0, 3)) && line.trim().replace(/[`~]/g, "") === "") fence = null;
99
+ }
100
+ cur.push(line);
101
+ len += line.length + 1;
102
+ const atBreak = !fence && line.trim() === "" && len >= size;
103
+ if (atBreak || len >= size * 4) {
104
+ if (fence) cur.push(fence.replace(/[^`~].*$/, ""));
105
+ out.push(cur.join("\n"));
106
+ cur = fence ? [fence] : [];
107
+ len = 0;
108
+ }
109
+ }
110
+ if (cur.length && cur.some((l) => l !== "")) out.push(cur.join("\n"));
111
+ return out;
112
+ }
@@ -0,0 +1,50 @@
1
+ // HTML / Word 预览里「选中文字 → 引用到对话」。
2
+ //
3
+ // 这两种预览放在沙箱 iframe 里(不给同源,页面碰不到宿主),宿主反过来也读不到 iframe 里的选区。
4
+ // 所以预览改成 srcdoc:在文档最前面插一段小脚本,选区变了用 postMessage 报给宿主(带口令,宿主只认自己这个 iframe)。
5
+ // · 页面自己的脚本照旧不让跑(和执行节点返回 HTML 时的 CSP 一致):插入的 CSP 只放行带 nonce 的这一段;
6
+ // · 相对路径的样式 / 图片按文件原地址解析(<base>),文档里的 #锚点 在页内滚动(不然会按 <base> 整页跳走)。
7
+
8
+ /** iframe → 宿主:选区(text 为空 = 取消了选择)或滚动了(收起按钮)。 */
9
+ export interface FrameSelMessage { cliUiSel: string; text?: string; rect?: { left: number; top: number; width: number } | null; scroll?: boolean }
10
+
11
+ const randomId = () => Math.random().toString(36).slice(2, 12) + Math.random().toString(36).slice(2, 12);
12
+
13
+ /** 每个预览一个口令 / nonce。 */
14
+ export const frameToken = randomId;
15
+
16
+ function bridgeScript(token: string) {
17
+ const t = JSON.stringify(token);
18
+ return `(function(){var t=${t};function post(m){m.cliUiSel=t;try{parent.postMessage(m,"*")}catch(e){}}
19
+ function send(){var s=getSelection(),text=s&&!s.isCollapsed?String(s).trim():"",rect=null;
20
+ if(text&&s.rangeCount){var b=s.getRangeAt(0).getBoundingClientRect();rect={left:b.left,top:b.top,width:b.width}}post({text:text,rect:rect})}
21
+ function later(){setTimeout(send,0)}
22
+ document.addEventListener("mouseup",later);document.addEventListener("keyup",later);
23
+ document.addEventListener("selectionchange",function(){var s=getSelection();if(!s||s.isCollapsed)post({text:""})});
24
+ addEventListener("scroll",function(){post({scroll:true})},true);
25
+ addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiClear===t){var s=getSelection();s&&s.removeAllRanges()}});
26
+ document.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href^="#"]');if(!a)return;e.preventDefault();
27
+ var id=decodeURIComponent(a.getAttribute("href").slice(1)),el=id?document.getElementById(id)||document.getElementsByName(id)[0]:document.body;el&&el.scrollIntoView()});})();`;
28
+ }
29
+
30
+ const escAttr = (s: string) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
31
+
32
+ /**
33
+ * 给 HTML 文档插上选区桥:CSP(页面脚本不跑、只能从文件所在地址取资源)、<base>、桥脚本。
34
+ * base = 文件的绝对地址(相对资源按它解析);不给(Word 转出来的,图片都内嵌)时只允许 data: 资源。
35
+ */
36
+ export function bridgeDoc(html: string, opts: { token: string; base?: string }) {
37
+ let origin = "";
38
+ if (opts.base) { try { origin = new URL(opts.base).origin; } catch { origin = ""; } }
39
+ const src = origin ? `${origin} ` : "";
40
+ const csp = `default-src 'none'; script-src 'nonce-${opts.token}'; img-src ${src}data: blob:; style-src ${src}'unsafe-inline'; font-src ${src}data:; media-src ${src}data: blob:`;
41
+ const head = `<meta http-equiv="Content-Security-Policy" content="${escAttr(csp)}">`
42
+ + (opts.base ? `<base href="${escAttr(opts.base)}">` : "")
43
+ + `<script nonce="${opts.token}">${bridgeScript(opts.token)}</script>`;
44
+ // 插在 <head> 里最前面;没有 <head> 就插在 doctype 后面(解析器会自动补出 head),不放到 doctype 前面免得变成怪异模式。
45
+ const headTag = /<head(\s[^>]*)?>/i.exec(html);
46
+ if (headTag) { const at = headTag.index + headTag[0].length; return html.slice(0, at) + head + html.slice(at); }
47
+ const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
48
+ if (doctype) return doctype[0] + head + html.slice(doctype[0].length);
49
+ return head + html;
50
+ }
@@ -0,0 +1,99 @@
1
+ // 预览里文本文件(代码、日志、Markdown、CSV)的读取。
2
+ //
3
+ // 能读多大:一次最多 max 字节(预览里是 32 MB),更大的只看开头或末尾。
4
+ // 文件在变(任务在写日志、在改报告)时不整个重读:后端有 readBytes 时,从上次读到的地方往前多取一小段(OVERLAP),
5
+ // 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
6
+ // 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
7
+
8
+ import type { BoardBackend } from "../core/client.js";
9
+ import { decodeText } from "../core/textDecode.js";
10
+
11
+ /** 往前多取这么多字节,用来确认文件只是追加。 */
12
+ export const OVERLAP = 256;
13
+ /** 追加的段落多了合成一段(每段是 <pre> 里的一个文字节点)。 */
14
+ const MAX_PARTS = 200;
15
+
16
+ export interface LoadedText {
17
+ text: string;
18
+ /** 分段的文字(追加的每次一段):<pre> 里按段渲染,追加时前面的不用重排。 */
19
+ parts: string[];
20
+ /** 文件比读到的大(只读了开头或末尾)。 */
21
+ truncated: boolean;
22
+ /** 文件总大小。 */
23
+ size: number;
24
+ /** 读的是末尾。 */
25
+ tail: boolean;
26
+ encoding?: string;
27
+ /** 读到的内容在文件里的字节范围 [start, end)。 */
28
+ start: number;
29
+ end: number;
30
+ /** end 之前的最后 OVERLAP 个字节(只有 UTF-8、后端有 readBytes 时有),用来确认文件只是追加。 */
31
+ tailBytes: Uint8Array | null;
32
+ }
33
+
34
+ /** 末尾有几个字节是没写完的 UTF-8 字(0 = 完整)。 */
35
+ export function incompleteUtf8Tail(bytes: Uint8Array) {
36
+ const len = bytes.length;
37
+ for (let i = len - 1; i >= 0 && i >= len - 4; i--) {
38
+ const b = bytes[i];
39
+ if ((b & 0xc0) === 0x80) continue;
40
+ const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
41
+ const have = len - i;
42
+ return have < need ? have : 0;
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((x, i) => x === b[i]);
48
+
49
+ /** 第一次读:开头(或末尾)最多 max 字节。 */
50
+ export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<LoadedText> {
51
+ if (!backend.readBytes) {
52
+ const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes });
53
+ return { text: r.text, parts: [r.text], truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding, start: 0, end: 0, tailBytes: null };
54
+ }
55
+ const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes });
56
+ const { text, encoding } = decodeText(chunk.bytes, { fromMiddle: chunk.start > 0 });
57
+ const k = encoding === "utf-8" ? incompleteUtf8Tail(chunk.bytes) : 0;
58
+ const end = chunk.start + chunk.bytes.length - k;
59
+ const tailBytes = encoding === "utf-8" ? chunk.bytes.slice(Math.max(0, chunk.bytes.length - k - OVERLAP), chunk.bytes.length - k) : null;
60
+ return {
61
+ text, parts: [text], truncated: chunk.start + chunk.bytes.length < chunk.size || chunk.start > 0, size: chunk.size,
62
+ tail: chunk.start > 0, encoding, start: chunk.start, end, tailBytes,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * 文件变了:能接上就只取新增的部分,返回新的内容;返回 null = 接不上(被改写 / 变短 / 不是 UTF-8 / 后端不支持),调用方整个重读。
68
+ * 只看开头且文件比 max 大时,开头没变就不用重读(追加的在后面,本来就不显示)。
69
+ */
70
+ export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number): Promise<LoadedText | null> {
71
+ if (!backend.readBytes || !prev.tailBytes) return null;
72
+ const overlap = prev.tailBytes.length;
73
+ const from = prev.end - overlap;
74
+ const headOnly = !prev.tail && prev.truncated;
75
+ // 末尾模式攒多了(超过 1.5 倍):整个重读末尾,免得越攒越大。
76
+ if (prev.tail && prev.end - prev.start > max * 1.5) return null;
77
+ const room = headOnly ? 0 : prev.tail ? max : Math.max(0, max - (prev.end - prev.start));
78
+ const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room });
79
+ if (chunk.size < prev.end || chunk.start !== from || chunk.bytes.length < overlap) return null;
80
+ if (!sameBytes(chunk.bytes.subarray(0, overlap), prev.tailBytes)) return null;
81
+ const readEnd = from + chunk.bytes.length;
82
+ if (headOnly) return { ...prev, size: chunk.size };
83
+ const added = chunk.bytes.subarray(overlap);
84
+ const k = incompleteUtf8Tail(added);
85
+ const delta = added.subarray(0, added.length - k);
86
+ let addedText: string;
87
+ try { addedText = new TextDecoder("utf-8", { fatal: true }).decode(delta); } catch { return null; }
88
+ const end = prev.end + delta.length;
89
+ const tailBytes = new Uint8Array(Math.min(OVERLAP, overlap + delta.length));
90
+ const joined = new Uint8Array(overlap + delta.length);
91
+ joined.set(prev.tailBytes, 0);
92
+ joined.set(delta, overlap);
93
+ tailBytes.set(joined.subarray(joined.length - tailBytes.length));
94
+ const parts = addedText ? (prev.parts.length >= MAX_PARTS ? [prev.text, addedText] : [...prev.parts, addedText]) : prev.parts;
95
+ return {
96
+ ...prev, text: addedText ? prev.text + addedText : prev.text, parts, size: chunk.size, end, tailBytes,
97
+ truncated: prev.tail || chunk.size > readEnd,
98
+ };
99
+ }