@spzxg/cli-ui 0.6.5 → 0.6.7
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/CHANGELOG.md +55 -0
- package/README.md +109 -30
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +11 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +276 -136
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -70
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +12 -10
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +18 -41
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +53 -11
- package/src/chat/CliChat.tsx +105 -55
- package/src/chat/Composer.tsx +55 -21
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +17 -5
- package/src/core/client.ts +88 -19
- package/src/core/conversation.ts +1 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +134 -36
- package/src/core/types.ts +31 -10
- package/src/index.ts +3 -0
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +18 -5
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- package/src/operator/node/Transcript.tsx +28 -11
- package/src/operator/node/boardBackend.ts +6 -23
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +8 -5
- package/src/operator.base.css +148 -37
- package/src/operator.css +148 -37
- package/src/styles.css +274 -32
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +32 -14
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +27 -12
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
package/src/board/textLoad.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// 预览里文本文件(代码、日志、Markdown、CSV)的读取。
|
|
2
2
|
//
|
|
3
3
|
// 能读多大:一次最多 max 字节(预览里是 32 MB),更大的只看开头或末尾。
|
|
4
|
-
//
|
|
5
|
-
// 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
|
|
6
|
-
// 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
|
|
4
|
+
// 新协议以不可变修订和内容块进行同步;旧后端不能通过尾部重叠推断整份文件未变化。
|
|
7
5
|
|
|
8
6
|
import type { BoardBackend } from "../core/client";
|
|
9
7
|
import { decodeText } from "../core/textDecode";
|
|
8
|
+
import { syncFile } from "./fileSync";
|
|
10
9
|
|
|
11
10
|
/** 往前多取这么多字节,用来确认文件只是追加。 */
|
|
12
11
|
export const OVERLAP = 256;
|
|
13
12
|
/** 追加的段落多了合成一段(每段是 <pre> 里的一个文字节点)。 */
|
|
14
|
-
|
|
13
|
+
|
|
15
14
|
|
|
16
15
|
export interface LoadedText {
|
|
17
16
|
text: string;
|
|
@@ -24,6 +23,7 @@ export interface LoadedText {
|
|
|
24
23
|
/** 读的是末尾。 */
|
|
25
24
|
tail: boolean;
|
|
26
25
|
encoding?: string;
|
|
26
|
+
revision?: string;
|
|
27
27
|
/** 读到的内容在文件里的字节范围 [start, end)。 */
|
|
28
28
|
start: number;
|
|
29
29
|
end: number;
|
|
@@ -44,15 +44,21 @@ export function incompleteUtf8Tail(bytes: Uint8Array) {
|
|
|
44
44
|
return 0;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
|
|
48
48
|
|
|
49
49
|
/** 第一次读:开头(或末尾)最多 max 字节。 */
|
|
50
|
-
export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<LoadedText> {
|
|
50
|
+
export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void; signal?: AbortSignal; preview?: boolean; fresh?: boolean }): Promise<LoadedText> {
|
|
51
|
+
if (opts.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
|
|
52
|
+
if (backend.fileSync) {
|
|
53
|
+
const result = await syncFile(backend.fileSync, jobId, path, { signal: opts.signal, preview: opts.preview, fresh: opts.fresh, range: { maxBytes: opts.max, ...(opts.tail ? { tail: true } : {}) }, onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
|
|
54
|
+
const text = result.text ?? decodeText(result.bytes, { fromMiddle: result.start > 0 }).text;
|
|
55
|
+
return { text, parts: [text], truncated: result.truncated, size: result.size, tail: result.start > 0, encoding: result.encoding, revision: result.revision, start: result.start, end: result.start + result.bytes.length, tailBytes: null };
|
|
56
|
+
}
|
|
51
57
|
if (!backend.readBytes) {
|
|
52
|
-
const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes });
|
|
58
|
+
const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
|
|
53
59
|
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
60
|
}
|
|
55
|
-
const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes });
|
|
61
|
+
const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
|
|
56
62
|
const { text, encoding } = decodeText(chunk.bytes, { fromMiddle: chunk.start > 0 });
|
|
57
63
|
const k = encoding === "utf-8" ? incompleteUtf8Tail(chunk.bytes) : 0;
|
|
58
64
|
const end = chunk.start + chunk.bytes.length - k;
|
|
@@ -63,37 +69,8 @@ export async function loadText(backend: BoardBackend, jobId: string, path: strin
|
|
|
63
69
|
};
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
};
|
|
72
|
+
/** Every refresh verifies a revision. A matching tail cannot prove the rest stayed unchanged. */
|
|
73
|
+
export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number, options: { signal?: AbortSignal; preview?: boolean; fresh?: boolean } = {}): Promise<LoadedText | null> {
|
|
74
|
+
if (options.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
|
|
75
|
+
return backend.fileSync ? loadText(backend, jobId, path, { max, tail: prev.tail, signal: options.signal, preview: options.preview, fresh: options.fresh }) : null;
|
|
99
76
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { clientId } from "../core/id";
|
|
3
|
+
import type { DocumentReceipt, UploadDocument, UploadPhase } from "../core/documentUpload";
|
|
4
|
+
import { FILE_UPLOAD_ACCEPT } from "../core/documentUpload";
|
|
5
|
+
import { UploadProgress } from "../ui/UploadProgress";
|
|
6
|
+
|
|
7
|
+
type PendingUpload = { file: File; phase: UploadPhase | "ready" | "error"; bytes: number; error?: string };
|
|
8
|
+
|
|
9
|
+
/** Uploading a file only changes the current workspace. It never submits a message. */
|
|
10
|
+
export function useBoardUpload(scope: string | null, upload: UploadDocument | undefined, onUploaded: (receipt: DocumentReceipt) => void) {
|
|
11
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
12
|
+
const active = useRef<AbortController | null>(null);
|
|
13
|
+
const committedScope = useRef(scope);
|
|
14
|
+
const [pending, setPending] = useState<PendingUpload | null>(null);
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
committedScope.current = scope;
|
|
17
|
+
setPending(null);
|
|
18
|
+
return () => { active.current?.abort(); active.current = null; };
|
|
19
|
+
}, [scope, upload]);
|
|
20
|
+
|
|
21
|
+
const start = (file: File) => {
|
|
22
|
+
if (!scope || !upload || active.current) return;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
active.current = controller;
|
|
25
|
+
const current = () => active.current === controller && committedScope.current === scope && !controller.signal.aborted;
|
|
26
|
+
setPending({ file, phase: "queued", bytes: 0 });
|
|
27
|
+
void Promise.resolve().then(() => upload(file, {
|
|
28
|
+
draftId: clientId(), signal: controller.signal,
|
|
29
|
+
onPhase: phase => { if (current()) setPending(previous => previous ? { ...previous, phase } : previous); },
|
|
30
|
+
onBytes: bytes => { if (current()) setPending(previous => previous ? { ...previous, bytes } : previous); },
|
|
31
|
+
})).then(receipt => {
|
|
32
|
+
if (!current()) return;
|
|
33
|
+
if (!receipt.path) throw new Error("文件尚未进入当前工作区,请稍后重试");
|
|
34
|
+
setPending({ file, phase: "ready", bytes: file.size });
|
|
35
|
+
onUploaded(receipt);
|
|
36
|
+
}).catch(error => {
|
|
37
|
+
if (current()) setPending(previous => previous ? { ...previous, phase: "error", error: error instanceof Error ? error.message : "上传失败,请重试" } : previous);
|
|
38
|
+
}).finally(() => { if (active.current === controller) active.current = null; });
|
|
39
|
+
};
|
|
40
|
+
const dismiss = () => { active.current?.abort(); active.current = null; setPending(null); };
|
|
41
|
+
const busy = !!pending && pending.phase !== "ready" && pending.phase !== "error";
|
|
42
|
+
return {
|
|
43
|
+
busy,
|
|
44
|
+
choose: () => { if (!busy) inputRef.current?.click(); },
|
|
45
|
+
input: <input ref={inputRef} type="file" hidden accept={FILE_UPLOAD_ACCEPT} aria-label="上传文件到工作区"
|
|
46
|
+
onChange={event => { const file = event.target.files?.[0]; event.target.value = ""; if (file) start(file); }} />,
|
|
47
|
+
progress: pending && <div className="cli-board-upload"><UploadProgress name={pending.file.name} status={pending.phase}
|
|
48
|
+
bytes={pending.bytes} total={pending.file.size} error={pending.error} onCancel={dismiss} onRemove={dismiss}
|
|
49
|
+
onRetry={() => start(pending.file)} /></div>,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
const useLayout = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
4
|
+
|
|
5
|
+
/** Observe this board's ancestor chain, including host CSS that hides a pane. */
|
|
6
|
+
export function useBoardVisibility(ref: RefObject<HTMLElement | null>, enabled: boolean) {
|
|
7
|
+
const [visible, setVisible] = useState(true);
|
|
8
|
+
useLayout(() => {
|
|
9
|
+
if (!enabled || !ref.current) return;
|
|
10
|
+
const ancestors: HTMLElement[] = [];
|
|
11
|
+
for (let node: HTMLElement | null = ref.current; node; node = node.parentElement) ancestors.push(node);
|
|
12
|
+
let pending = false, alive = true;
|
|
13
|
+
const read = () => {
|
|
14
|
+
pending = false;
|
|
15
|
+
if (!alive) return;
|
|
16
|
+
setVisible(ancestors.every(node => {
|
|
17
|
+
const style = getComputedStyle(node);
|
|
18
|
+
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && style.contentVisibility !== "hidden";
|
|
19
|
+
}));
|
|
20
|
+
};
|
|
21
|
+
const schedule = () => {
|
|
22
|
+
if (pending) return;
|
|
23
|
+
pending = true;
|
|
24
|
+
queueMicrotask(read);
|
|
25
|
+
};
|
|
26
|
+
const mutations = new MutationObserver(schedule);
|
|
27
|
+
for (const node of ancestors) mutations.observe(node, { attributes: true, attributeFilter: ["class", "style", "hidden"] });
|
|
28
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
|
|
29
|
+
resize?.observe(ref.current);
|
|
30
|
+
window.addEventListener("resize", schedule);
|
|
31
|
+
read();
|
|
32
|
+
return () => { alive = false; mutations.disconnect(); resize?.disconnect(); window.removeEventListener("resize", schedule); };
|
|
33
|
+
}, [ref, enabled]);
|
|
34
|
+
return visible;
|
|
35
|
+
}
|
package/src/board/workspace.ts
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
|
|
13
13
|
*/
|
|
14
|
-
import { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./fileWatch";
|
|
14
|
+
import { retryWorkspaceFiles, subscribeWorkspaceFiles, type WorkspaceWatchOptions, type WorkspaceRefreshOptions } from "./fileWatch";
|
|
15
15
|
import type { BoardBackend } from "../core/client";
|
|
16
|
-
import type { BoardInfo, FileEntry, WorkspaceChangeEvent } from "../core/types";
|
|
16
|
+
import type { BoardInfo, FileEntry, DirListing, WorkspaceChangeEvent } from "../core/types";
|
|
17
17
|
import { directFileUrl, downloadFile, mimeOf } from "./fileOps";
|
|
18
18
|
import { fmtSize } from "./fileKind";
|
|
19
|
+
import { syncFile, FILE_SYNC_MAX } from "./fileSync";
|
|
20
|
+
import { isOriginalDocument } from "../core/documentUpload";
|
|
21
|
+
import { loadText } from "./textLoad";
|
|
19
22
|
import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload";
|
|
20
23
|
|
|
21
24
|
/** readText 最多读这么大(更大的用 readFile)。 */
|
|
@@ -55,14 +58,18 @@ export interface CliWorkspace {
|
|
|
55
58
|
stat(path: string): ReturnType<BoardBackend["stat"]>;
|
|
56
59
|
/** 订阅文件变更。初始快照只建立基线;返回取消函数。 */
|
|
57
60
|
watch(listener: (event: WorkspaceChangeEvent) => void, options?: WorkspaceWatchOptions): () => void;
|
|
61
|
+
/** 请求一次增量检查。轮末回调用无参;用户明确重试可传 manual:true(仍遵守服务端冷却)。不创建连接或周期轮询。 */
|
|
62
|
+
refresh(options?: WorkspaceRefreshOptions): void;
|
|
58
63
|
/** 包括当前文件、打开页签、最近浏览和显示状态的背景快照。 */
|
|
59
64
|
context(): BoardView | null;
|
|
60
|
-
/**
|
|
65
|
+
/** 按需读取单层目录的一页;nextCursor 非空时还有后续页。 */
|
|
66
|
+
listPage(dir?: string, opts?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
|
|
67
|
+
/** 显式列出一个目录(不递归,最多10000项);界面首屏推荐 listPage。 */
|
|
61
68
|
list(dir?: string): Promise<FileEntry[]>;
|
|
62
69
|
/** 列 dir 下所有文件(递归,只含文件)。 */
|
|
63
70
|
listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
|
|
64
71
|
/** 读成文字(GBK 导出的文件会自动转码)。 */
|
|
65
|
-
readText(path: string): Promise<string>;
|
|
72
|
+
readText(path: string, opts?: { signal?: AbortSignal }): Promise<string>;
|
|
66
73
|
/** 读成 Blob。onProgress 报已收到的字节数;超过 maxBytes(缺省 500 MB)报错,改用 fileUrl / download。 */
|
|
67
74
|
readFile(path: string, opts?: { signal?: AbortSignal; onProgress?: (bytes: number) => void; maxBytes?: number }): Promise<Blob>;
|
|
68
75
|
/** 浏览器能直接打开 / 下载的地址(没有同源代理时是 30 分钟有效的短期链接);接入方式不支持时为 null,改用 readFile。 */
|
|
@@ -100,25 +107,60 @@ export function createWorkspace(deps: {
|
|
|
100
107
|
info: () => backend().info(key),
|
|
101
108
|
stat: path => backend().stat(key, clean(path) || "."),
|
|
102
109
|
watch: (listener, options) => deps.watch ? deps.watch(listener,options) : subscribeWorkspaceFiles(backend(), key, listener, options),
|
|
110
|
+
refresh: options => options ? retryWorkspaceFiles(backend(), key, options) : retryWorkspaceFiles(backend(), key),
|
|
103
111
|
context: () => deps.context?.() ?? deps.view() ?? null,
|
|
112
|
+
async listPage(dir = "", opts = {}) {
|
|
113
|
+
const api = backend();
|
|
114
|
+
const result = api.listFiles ? await api.listFiles(key, clean(dir) || ".", opts) : await api.stat(key, clean(dir) || ".");
|
|
115
|
+
if (result.type !== "dir") throw new Error(`「${dir}」不是目录`);
|
|
116
|
+
return result;
|
|
117
|
+
},
|
|
104
118
|
async list(dir = "") {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
const api = backend(), entries: FileEntry[] = [], seen = new Set<string>();
|
|
120
|
+
let cursor: string | undefined;
|
|
121
|
+
do {
|
|
122
|
+
const result = api.listFiles ? await api.listFiles(key, clean(dir) || ".", { cursor, limit: 200 }) : await api.stat(key, clean(dir) || ".");
|
|
123
|
+
if (result.type !== "dir") throw new Error(`「${dir}」不是目录`);
|
|
124
|
+
entries.push(...result.entries);
|
|
125
|
+
if (entries.length > WORKSPACE_LIST_MAX) throw new Error("目录超过10000项,请用 listPage 按需读取");
|
|
126
|
+
cursor = result.nextCursor;
|
|
127
|
+
if ((result.truncated && (!cursor || !api.listFiles)) || (cursor && seen.has(cursor))) throw new Error("目录列表不完整,请使用支持分页的服务端");
|
|
128
|
+
if (cursor) seen.add(cursor);
|
|
129
|
+
} while (cursor);
|
|
130
|
+
return entries;
|
|
108
131
|
},
|
|
109
132
|
async listAll(dir = "", opts = {}) {
|
|
110
133
|
const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
|
|
111
134
|
return files.map((f) => ({ name: f.path.split("/").pop() || f.path, path: f.path, type: "file" as const, size: f.size, mtime: f.mtime }));
|
|
112
135
|
},
|
|
113
|
-
async readText(path) {
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
|
|
136
|
+
async readText(path, opts = {}) {
|
|
137
|
+
const signal = opts.signal;
|
|
138
|
+
if (signal?.aborted) throw new DOMException("已取消", "AbortError");
|
|
139
|
+
let rejectAbort: (() => void) | undefined;
|
|
140
|
+
try {
|
|
141
|
+
if (backend().fileSync && isOriginalDocument(path)) {
|
|
142
|
+
const result = await loadText(backend(), key, clean(path), { max: FILE_SYNC_MAX, signal });
|
|
143
|
+
return result.text;
|
|
144
|
+
}
|
|
145
|
+
const reading = signal ? backend().readText(key, clean(path), WORKSPACE_TEXT_MAX, { signal }) : backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
|
|
146
|
+
const r = signal ? await Promise.race([reading, new Promise<never>((_resolve, reject) => {
|
|
147
|
+
rejectAbort = () => reject(new DOMException("已取消", "AbortError"));
|
|
148
|
+
signal.addEventListener("abort", rejectAbort, { once: true });
|
|
149
|
+
if (signal.aborted) rejectAbort();
|
|
150
|
+
})]) : await reading;
|
|
151
|
+
if (r.truncated) throw new Error(`文件较大(超过 ${WORKSPACE_TEXT_MAX / 1024 / 1024} MB),请用 readFile 读成 Blob`);
|
|
152
|
+
return r.text;
|
|
153
|
+
} finally { if (rejectAbort) signal?.removeEventListener("abort", rejectAbort); }
|
|
117
154
|
},
|
|
118
155
|
async readFile(path, opts = {}) {
|
|
119
156
|
const p = clean(path);
|
|
120
157
|
const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
|
|
121
158
|
if(!Number.isFinite(max)||max<0)throw new RangeError("maxBytes 必须是非负有限字节数");
|
|
159
|
+
if (backend().fileSync && isOriginalDocument(p)) {
|
|
160
|
+
const result = await syncFile(backend().fileSync!, key, p, { signal: opts.signal, decode: false, range: { maxBytes: Math.min(max, FILE_SYNC_MAX) }, onProgress: progress => opts.onProgress?.(progress.loadedBytes) });
|
|
161
|
+
if (result.truncated) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download`);
|
|
162
|
+
return new Blob([result.bytes], { type: mimeOf(p) });
|
|
163
|
+
}
|
|
122
164
|
// 太大的读到一半就停:整个读进内存会把页面撑爆。
|
|
123
165
|
const ctl = new AbortController();
|
|
124
166
|
const stop = () => ctl.abort();
|