@xgspz/cli-ui 0.0.0-stage → 0.6.15
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 +165 -0
- package/README.md +517 -2
- package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
- package/dist/cli-ui-index-R24x28OX.js +11209 -0
- package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
- package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
- package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +960 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +581 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1109 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +507 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +619 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +485 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +335 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1952 -0
- package/src/operator.css +1952 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { decodeText } from "../core/textDecode";
|
|
2
|
+
import { sha256 } from "../core/sha256";
|
|
3
|
+
|
|
4
|
+
const aborted = () => new DOMException("文件同步已取消", "AbortError");
|
|
5
|
+
const integrity = (message: string) => Object.assign(new Error(message), { status: 422, code: "integrity" });
|
|
6
|
+
const digest = async (bytes: Uint8Array<ArrayBuffer>) => globalThis.crypto?.subtle ? [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("") : sha256(bytes);
|
|
7
|
+
type Processed = { bytes: Uint8Array<ArrayBuffer>; text?: string; encoding?: string };
|
|
8
|
+
|
|
9
|
+
/** One worker owns the assembled buffer; chunks are transferred, not copied into a second main-thread file. */
|
|
10
|
+
export class FileSyncProcessor {
|
|
11
|
+
private worker: Worker | null = null;
|
|
12
|
+
private url: string | null = null;
|
|
13
|
+
private sequence = 0;
|
|
14
|
+
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
15
|
+
private output: Uint8Array<ArrayBuffer> | null = null;
|
|
16
|
+
private offset = 0;
|
|
17
|
+
private size = 0;
|
|
18
|
+
private stopped = false;
|
|
19
|
+
private failed: Error | null = null;
|
|
20
|
+
constructor(private signal: AbortSignal) {
|
|
21
|
+
if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") try {
|
|
22
|
+
const script = `const decode=${decodeText.toString()},fallback=${sha256.toString()};let output=null,offset=0;
|
|
23
|
+
const hash=async b=>globalThis.crypto?.subtle?Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),v=>v.toString(16).padStart(2,'0')).join(''):fallback(b instanceof Uint8Array?b:new Uint8Array(b));
|
|
24
|
+
onmessage=async({data:m})=>{try{let value;
|
|
25
|
+
if(m.op==='start'){output=new Uint8Array(m.size);offset=0;value=true;}
|
|
26
|
+
if(m.op==='verify'){if(await hash(m.bytes)!==m.hash)throw Error('文件块校验失败');value={bytes:m.bytes};}
|
|
27
|
+
if(m.op==='append'){if(!output||offset+m.bytes.byteLength>output.length||await hash(m.bytes)!==m.hash)throw Error('文件块校验失败');output.set(new Uint8Array(m.bytes),offset);offset+=m.bytes.byteLength;value=true;}
|
|
28
|
+
if(m.op==='finish'){if(!output||offset!==output.length||(m.hash&&await hash(output)!==m.hash))throw Error('完整文件校验失败');const bytes=m.start===0&&m.length===output.length?output:output.slice(m.start,m.start+m.length);const decoded=m.decode?decode(bytes,{fromMiddle:m.fromMiddle}):{};value={bytes:bytes.buffer,...decoded};output=null;}
|
|
29
|
+
postMessage({id:m.id,value},value&&value.bytes?[value.bytes]:[]);}catch(error){postMessage({id:m.id,error:error.message});}};`;
|
|
30
|
+
this.url = URL.createObjectURL(new Blob([script], { type: "text/javascript" }));
|
|
31
|
+
this.worker = new Worker(this.url);
|
|
32
|
+
this.worker.onmessage = event => { const item = this.pending.get(event.data.id); if (!item) return; this.pending.delete(event.data.id); clearTimeout(item.timer); if (event.data.error) item.reject(integrity(event.data.error)); else item.resolve(event.data.value); };
|
|
33
|
+
this.worker.onerror = () => this.fail(new Error("文件校验 Worker 不可用"));
|
|
34
|
+
} catch { this.worker?.terminate(); this.worker = null; }
|
|
35
|
+
signal.addEventListener("abort", this.cancel, { once: true });
|
|
36
|
+
}
|
|
37
|
+
private fail(error: Error) { this.failed = error; this.worker?.terminate(); this.worker = null; for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(error); } this.pending.clear(); }
|
|
38
|
+
private cancel = () => { this.stopped = true; this.worker?.terminate(); for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(aborted()); } this.pending.clear(); this.output = null; };
|
|
39
|
+
private check() { if (this.stopped || this.signal.aborted) throw aborted(); if (this.failed) throw this.failed; }
|
|
40
|
+
private rpc(op: string, data: Record<string, unknown>, transfers: Transferable[] = []) {
|
|
41
|
+
this.check();
|
|
42
|
+
return new Promise<any>((resolve, reject) => { const id = ++this.sequence, timer = setTimeout(() => this.fail(new Error("文件校验 Worker 超时")), op === "start" ? 5000 : 120_000); this.pending.set(id, { resolve, reject, timer }); try { this.worker!.postMessage({ id, op, ...data }, transfers); } catch (error) { this.pending.delete(id); clearTimeout(timer); reject(error); } });
|
|
43
|
+
}
|
|
44
|
+
async start(size: number) {
|
|
45
|
+
this.check();
|
|
46
|
+
this.size = size;
|
|
47
|
+
if (this.worker) {
|
|
48
|
+
try { await this.rpc("start", { size }); return; }
|
|
49
|
+
catch (error) { if (this.signal.aborted || this.stopped) throw aborted(); this.worker?.terminate(); this.worker = null; this.failed = null; if (error instanceof DOMException && error.name === "AbortError") throw error; }
|
|
50
|
+
}
|
|
51
|
+
this.output = new Uint8Array(size); this.offset = 0;
|
|
52
|
+
}
|
|
53
|
+
async verify(bytes: Uint8Array<ArrayBuffer>, hash: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
54
|
+
this.check();
|
|
55
|
+
if (this.worker) { const result = await this.rpc("verify", { bytes: bytes.buffer, hash }, [bytes.buffer]); this.check(); return new Uint8Array(result.bytes); }
|
|
56
|
+
if (await digest(bytes) !== hash) throw integrity("文件块校验失败"); this.check(); return bytes;
|
|
57
|
+
}
|
|
58
|
+
async append(bytes: Uint8Array<ArrayBuffer>, hash: string) {
|
|
59
|
+
this.check();
|
|
60
|
+
if (this.worker) { await this.rpc("append", { bytes: bytes.buffer, hash }, [bytes.buffer]); this.check(); return; }
|
|
61
|
+
if (!this.output || this.offset + bytes.length > this.output.length || await digest(bytes) !== hash) throw integrity("文件块校验失败");
|
|
62
|
+
this.check(); this.output.set(bytes, this.offset); this.offset += bytes.length;
|
|
63
|
+
}
|
|
64
|
+
async finish(hash: string | null, decode: boolean, range = { start: 0, length: this.size, fromMiddle: false }): Promise<Processed> {
|
|
65
|
+
this.check();
|
|
66
|
+
if (this.worker) { const result = await this.rpc("finish", { hash, decode, ...range }); this.check(); return { ...result, bytes: new Uint8Array(result.bytes) }; }
|
|
67
|
+
if (!this.output || this.offset !== this.output.length || (hash && await digest(this.output) !== hash)) throw integrity("完整文件校验失败");
|
|
68
|
+
this.check(); const bytes = range.start === 0 && range.length === this.output.length ? this.output : this.output.slice(range.start, range.start + range.length);
|
|
69
|
+
return { bytes, ...(decode ? decodeText(bytes, { fromMiddle: range.fromMiddle }) : {}) };
|
|
70
|
+
}
|
|
71
|
+
dispose() { this.signal.removeEventListener("abort", this.cancel); this.cancel(); if (this.url) URL.revokeObjectURL(this.url); }
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** A finite FIFO for one transport. Waiting work never owns a buffer or request. */
|
|
2
|
+
export function createFileWorkQueue(concurrency: number, maxQueued = 128, waitMs = 180_000) {
|
|
3
|
+
let active = 0;
|
|
4
|
+
type Waiting = { start: () => void; cancel: () => void };
|
|
5
|
+
const waiting: Waiting[] = [];
|
|
6
|
+
const drain = () => { while (active < concurrency && waiting.length) waiting.shift()!.start(); };
|
|
7
|
+
return {
|
|
8
|
+
run<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
9
|
+
return new Promise<T>((resolve, reject) => {
|
|
10
|
+
const abort = () => new DOMException("文件同步已取消", "AbortError");
|
|
11
|
+
if (signal?.aborted) { reject(abort()); return; }
|
|
12
|
+
if (active >= concurrency && waiting.length >= maxQueued) {
|
|
13
|
+
reject(Object.assign(new Error("文件同步队列繁忙,请稍后重试"), { status: 429, code: "client_queue_full", retryAfterMs: 1000 })); return;
|
|
14
|
+
}
|
|
15
|
+
let started = false, running = false, settled = false, released = false;
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
17
|
+
const settle = (ok: boolean, result: unknown) => {
|
|
18
|
+
if (settled) return;
|
|
19
|
+
settled = true; signal?.removeEventListener("abort", item.cancel);
|
|
20
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
21
|
+
if (ok) resolve(result as T); else reject(result);
|
|
22
|
+
};
|
|
23
|
+
const release = () => {
|
|
24
|
+
if (released) return;
|
|
25
|
+
released = true;
|
|
26
|
+
if (started) active--;
|
|
27
|
+
else { const index = waiting.indexOf(item); if (index !== -1) waiting.splice(index, 1); }
|
|
28
|
+
drain();
|
|
29
|
+
};
|
|
30
|
+
const finish = (ok: boolean, result: unknown) => { settle(ok, result); release(); };
|
|
31
|
+
const item: Waiting = {
|
|
32
|
+
start: () => {
|
|
33
|
+
if (settled) return;
|
|
34
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
35
|
+
started = true; active++;
|
|
36
|
+
// A caller can cancel before this microtask starts; do not execute retired work.
|
|
37
|
+
void Promise.resolve().then(() => { if (settled) throw abort(); running = true; return work(); }).then(value => finish(true, value), error => finish(false, error));
|
|
38
|
+
},
|
|
39
|
+
cancel: () => {
|
|
40
|
+
settle(false, abort());
|
|
41
|
+
// Uncooperative legacy hosts may ignore cancellation. Keep their actual
|
|
42
|
+
// request/buffer counted until work settles, while rejecting the caller now.
|
|
43
|
+
if (!running) release();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
signal?.addEventListener("abort", item.cancel, { once: true });
|
|
47
|
+
if (active < concurrency) item.start();
|
|
48
|
+
else {
|
|
49
|
+
waiting.push(item);
|
|
50
|
+
timer = setTimeout(() => finish(false, Object.assign(new Error("文件同步等待超时,请稍后重试"), { status: 429, code: "client_queue_timeout", retryAfterMs: 1000 })), waitMs);
|
|
51
|
+
}
|
|
52
|
+
if (signal?.aborted) item.cancel();
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { FileSyncError, validateFileSyncManifest, type FileSyncManifest, type FileSyncTransport } from "./fileSync";
|
|
2
|
+
|
|
3
|
+
const MANIFEST_MAX = 2 * 1024 * 1024;
|
|
4
|
+
const BATCH_MAX = 1024 * 1024;
|
|
5
|
+
const abortError = () => new DOMException("文件同步已取消", "AbortError");
|
|
6
|
+
|
|
7
|
+
/** Short, bounded requests only; a 304 is reusable only in the same authenticated namespace. */
|
|
8
|
+
export function createFileSyncTransport(fetcher: typeof fetch, pathBuilder: (jobId: string, operation: "manifest" | "chunks") => string,
|
|
9
|
+
headers: () => Record<string, string> = () => ({}), options: { requestTimeoutMs?: number } = {}): FileSyncTransport {
|
|
10
|
+
const namespaces = new Map<string, string>();
|
|
11
|
+
const call = async (url: string, init: RequestInit, limit: number, signal?: AbortSignal) => {
|
|
12
|
+
if (signal?.aborted) throw abortError();
|
|
13
|
+
const controller = new AbortController(); let timedOut = false;
|
|
14
|
+
const cancel = () => controller.abort(); signal?.addEventListener("abort", cancel, { once: true });
|
|
15
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.requestTimeoutMs ?? 30_000);
|
|
16
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
17
|
+
const stopped = new Promise<never>((_resolve, reject) => controller.signal.addEventListener("abort", () => reject(signal?.aborted ? abortError() : new FileSyncError("文件同步请求超时", 0, "timeout")), { once: true }));
|
|
18
|
+
try {
|
|
19
|
+
const fetching = fetcher(url, { ...init, headers: { ...headers(), ...init.headers }, signal: controller.signal, cache: "no-store" });
|
|
20
|
+
void fetching.then(response => { if (controller.signal.aborted) void response.body?.cancel().catch(() => undefined); }, () => undefined);
|
|
21
|
+
const response = await Promise.race([fetching, stopped]);
|
|
22
|
+
if (response.status === 304) { void response.body?.cancel().catch(() => undefined); return { response, bytes: new Uint8Array(0) }; }
|
|
23
|
+
const maximum = response.ok && response.status !== 202 ? limit : 8192;
|
|
24
|
+
const declared = Number(response.headers.get("Content-Length") ?? 0);
|
|
25
|
+
if (declared > maximum) { void response.body?.cancel().catch(() => undefined); throw new FileSyncError("文件同步响应超过协议上限", response.status, "response_too_large"); }
|
|
26
|
+
reader = response.body?.getReader();
|
|
27
|
+
const parts: Uint8Array[] = []; let size = 0;
|
|
28
|
+
if (reader) {
|
|
29
|
+
while (true) {
|
|
30
|
+
const next = await Promise.race([reader.read(), stopped]);
|
|
31
|
+
if (next.done) break;
|
|
32
|
+
size += next.value.byteLength;
|
|
33
|
+
if (size > maximum) throw new FileSyncError("文件同步响应超过协议上限", response.status, "response_too_large");
|
|
34
|
+
parts.push(next.value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const bytes = new Uint8Array(size); let offset = 0;
|
|
38
|
+
for (const part of parts) { bytes.set(part, offset); offset += part.byteLength; }
|
|
39
|
+
if (!response.ok || response.status === 202) {
|
|
40
|
+
let error: { error?: string; detail?: string } = {};
|
|
41
|
+
try { error = JSON.parse(new TextDecoder().decode(bytes)); } catch { /* Status remains authoritative. */ }
|
|
42
|
+
const retry = response.headers.get("Retry-After");
|
|
43
|
+
const retryAfterMs = retry ? (/^\d+(\.\d+)?$/.test(retry) ? Number(retry) * 1000 : Math.max(0, Date.parse(retry) - Date.now())) : undefined;
|
|
44
|
+
const missingRoute = response.status === 501 || (response.status === 404 && !["file_not_found", "task_not_found", "job_not_found"].includes(error.error ?? ""));
|
|
45
|
+
const code = missingRoute ? "unsupported" : error.error ?? "http_error";
|
|
46
|
+
const common: Record<string, string> = { node_unreachable: "执行节点暂时连不上,请稍后重试", unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_sync_unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_not_found: "文件不存在(可能已删除或改名)", file_forbidden: error.detail || "文件不在当前任务允许访问的目录中,或没有访问权限", file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
|
|
47
|
+
const message = response.status === 401 ? "登录已失效,请重新登录" : common[code] ?? (response.status === 403 ? error.detail || "没有读取该文件的权限,请检查任务访问权限" : error.detail ?? `文件同步失败(HTTP ${response.status})`);
|
|
48
|
+
throw new FileSyncError(message, response.status, code, retryAfterMs);
|
|
49
|
+
}
|
|
50
|
+
return { response, bytes };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (signal?.aborted) throw abortError();
|
|
53
|
+
if (timedOut) throw new FileSyncError("文件同步请求超时", 0, "timeout");
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer); signal?.removeEventListener("abort", cancel);
|
|
57
|
+
if (reader) { void reader.cancel().catch(() => undefined); try { reader.releaseLock(); } catch { /* A cancelled read can still be retiring. */ } }
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
async manifest(jobId, path, settings = {}) {
|
|
62
|
+
const key = JSON.stringify([jobId, path]);
|
|
63
|
+
const read = async (revision?: string): Promise<FileSyncManifest | null> => {
|
|
64
|
+
const endpoint = pathBuilder(jobId, "manifest"), url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}path=${encodeURIComponent(path)}`;
|
|
65
|
+
const { response, bytes } = await call(url, { headers: revision ? { "If-None-Match": `"${revision}"` } : {} }, MANIFEST_MAX, settings.signal);
|
|
66
|
+
if (response.status === 304) {
|
|
67
|
+
const namespace = response.headers.get("X-File-Sync-Namespace");
|
|
68
|
+
if (revision && namespace && namespace === namespaces.get(key)) return null;
|
|
69
|
+
if (revision) return read();
|
|
70
|
+
throw new FileSyncError("文件修订清单缺失", 502, "invalid_manifest");
|
|
71
|
+
}
|
|
72
|
+
let manifest: FileSyncManifest;
|
|
73
|
+
try { manifest = validateFileSyncManifest(JSON.parse(new TextDecoder().decode(bytes))); }
|
|
74
|
+
catch (error) { throw error instanceof FileSyncError ? error : new FileSyncError("文件修订清单格式错误", 502, "invalid_manifest"); }
|
|
75
|
+
namespaces.set(key, manifest.namespace); return manifest;
|
|
76
|
+
};
|
|
77
|
+
return read(settings.revision);
|
|
78
|
+
},
|
|
79
|
+
async chunks(jobId, input, settings = {}) {
|
|
80
|
+
if (!input.ids.length) return new Uint8Array(0);
|
|
81
|
+
const { bytes } = await call(pathBuilder(jobId, "chunks"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input) }, BATCH_MAX, settings.signal);
|
|
82
|
+
return bytes;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { type BoardBackend } from '../core/client';
|
|
2
|
+
import { notifyCallback } from '../core/callbacks';
|
|
3
|
+
import type { FileEntry, WorkspaceChangeEvent, WorkspaceFileBatch, WorkspaceFileChange, WorkspaceWatchStatus } from '../core/types';
|
|
4
|
+
import { denyFileScope, onFileScope, readFileScope, retainFileScope } from './fileScope';
|
|
5
|
+
import { onFilePreviewScope, refreshFilePreviews } from './fileSync';
|
|
6
|
+
import { createFileWorkQueue } from './fileSyncQueue';
|
|
7
|
+
export interface WorkspaceWatchOptions { /** @deprecated Retained for compatibility; checks are event-driven, never periodic. */ intervalMs?:number; paths?:readonly string[]; dirs?:readonly string[]; onStatus?:(status:WorkspaceWatchStatus)=>void }
|
|
8
|
+
type Listener={change:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']};
|
|
9
|
+
export interface WorkspaceRefreshOptions { manual?:boolean }
|
|
10
|
+
type Entry={listeners:Set<Listener>;stop:()=>void;retry:(manual?:boolean)=>void;status:WorkspaceWatchStatus};
|
|
11
|
+
export function cloneWorkspaceChange(event:WorkspaceChangeEvent):WorkspaceChangeEvent {return {...event,changes:event.changes.map(change=>({...change,file:change.file?{...change.file}:undefined,previous:change.previous?{...change.previous}:undefined}))};}
|
|
12
|
+
const registries=new WeakMap<BoardBackend,Map<string,Entry>>();
|
|
13
|
+
const metadataQueues=new WeakMap<BoardBackend,ReturnType<typeof createFileWorkQueue>>();
|
|
14
|
+
function metadataQueue(backend:BoardBackend){
|
|
15
|
+
let queue=metadataQueues.get(backend);if(!queue){queue=createFileWorkQueue(4,128);metadataQueues.set(backend,queue);}return queue;
|
|
16
|
+
}
|
|
17
|
+
export function retryWorkspaceFiles(backend:BoardBackend,jobId:string,options:WorkspaceRefreshOptions={}):void {registries.get(backend)?.get(jobId)?.retry(options.manual);}
|
|
18
|
+
const fingerprint=(file:FileEntry)=>`${file.type}:${file.size}:${file.mtime}:${file.changeToken??''}`;
|
|
19
|
+
const validFile=(file:FileEntry)=>!!file&&typeof file.path==='string'&&typeof file.name==='string'&&['file','dir'].includes(file.type)&&Number.isFinite(file.size)&&Number.isFinite(file.mtime);
|
|
20
|
+
const invalidResponse=(message:string)=>Object.assign(new Error(message),{status:502,code:'invalid_response'});
|
|
21
|
+
/** Shared event-driven HTTP checks. No recurring file timer or file event connection. */
|
|
22
|
+
export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listener:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}):()=>void {
|
|
23
|
+
const releaseOwnedScope=options.paths?.length||options.dirs?.length?retainFileScope(backend,jobId,{paths:[...options.paths??[]],dirs:[...options.dirs??[]]}):()=>{};
|
|
24
|
+
let jobs=registries.get(backend);if(!jobs){jobs=new Map();registries.set(backend,jobs);}
|
|
25
|
+
let entry=jobs.get(jobId);
|
|
26
|
+
if(!entry){
|
|
27
|
+
// Keep the public transport value for compatibility; it denotes bounded HTTP, not a timer.
|
|
28
|
+
const statusBase={type:'files_watch' as const,jobId,mode:'poll' as const,coverage:'listed-files' as const,complete:false};
|
|
29
|
+
entry={listeners:new Set(),stop:()=>{},retry:()=>{},status:{...statusBase,state:'connecting'}};jobs.set(jobId,entry);
|
|
30
|
+
const owned=entry;
|
|
31
|
+
let alive=true,controller:AbortController|null=null,timer:ReturnType<typeof setTimeout>|undefined,failed=0,generation=0,dirty=false;
|
|
32
|
+
let scopeKey=JSON.stringify(readFileScope(backend,jobId));
|
|
33
|
+
const retryDelays=[1000,2000,4000];
|
|
34
|
+
let serverNotBefore=0,retryAt=0,exhaustedUntil=0;
|
|
35
|
+
const requests=metadataQueue(backend);
|
|
36
|
+
let baseline=new Map<string,FileEntry>(),directories=new Map<string,string|null>(),cursors=new Map<string,string>();
|
|
37
|
+
let known=new Set<string>();
|
|
38
|
+
type ScanState={baseline:Map<string,FileEntry>;known:Set<string>};
|
|
39
|
+
const status=(state:WorkspaceWatchStatus['state'],error?:string)=>{owned.status={...statusBase,state,...(error?{error}:{})};for(const item of [...owned.listeners])notifyCallback(item.status,{...owned.status});};
|
|
40
|
+
const publish=(changes:WorkspaceFileChange[],revision?:string)=>{
|
|
41
|
+
if(!alive||!changes.length)return;
|
|
42
|
+
const event:WorkspaceChangeEvent={type:'files_changed',jobId,mode:'poll',coverage:'listed-files',complete:false,changes,revision};
|
|
43
|
+
for(const item of [...owned.listeners])notifyCallback(item.change,cloneWorkspaceChange(event));
|
|
44
|
+
};
|
|
45
|
+
const snapshot=(files:FileEntry[],paths:string[],state:ScanState):WorkspaceFileChange[]=>{
|
|
46
|
+
if(!Array.isArray(files)||!files.every(validFile))throw invalidResponse('文件变化响应不完整,请重试');
|
|
47
|
+
const current=new Map(files.map(file=>[file.path,file])),changes:WorkspaceFileChange[]=[];
|
|
48
|
+
for(const path of paths){
|
|
49
|
+
const file=current.get(path),previous=state.baseline.get(path);
|
|
50
|
+
if(state.known.has(path)){
|
|
51
|
+
if(file&&!previous)changes.push({kind:'created',path,file});
|
|
52
|
+
else if(!file&&previous)changes.push({kind:'deleted',path,previous});
|
|
53
|
+
else if(file&&previous&&fingerprint(file)!==fingerprint(previous))changes.push({kind:'modified',path,file,previous});
|
|
54
|
+
}
|
|
55
|
+
state.known.add(path);if(file)state.baseline.set(path,{...file});else state.baseline.delete(path);
|
|
56
|
+
}
|
|
57
|
+
return changes;
|
|
58
|
+
};
|
|
59
|
+
const accept=(batch:WorkspaceFileBatch,paths:string[],state:ScanState)=>{
|
|
60
|
+
if(batch.type==='resync')return [];
|
|
61
|
+
if(batch.type==='snapshot')return snapshot(batch.files,paths,state);
|
|
62
|
+
if(batch.type!=='changes'||!Array.isArray(batch.changes)||batch.changes.some(change=>!change||!paths.includes(change.path)||!['created','modified','deleted'].includes(change.kind)||(change.file&&!validFile(change.file))))throw invalidResponse('文件变化响应不完整,请重试');
|
|
63
|
+
for(const change of batch.changes){state.known.add(change.path);if(change.kind==='deleted')state.baseline.delete(change.path);else if(change.file)state.baseline.set(change.path,{...change.file});}
|
|
64
|
+
return batch.changes;
|
|
65
|
+
};
|
|
66
|
+
const hidden=()=>typeof document!=='undefined'&&document.visibilityState==='hidden';
|
|
67
|
+
const scan=async()=>{
|
|
68
|
+
if(!alive||controller||hidden())return;
|
|
69
|
+
// A real event retained during cooldown starts one new bounded attempt wave.
|
|
70
|
+
if(dirty&&exhaustedUntil&&Date.now()>=exhaustedUntil){failed=0;exhaustedUntil=0;}
|
|
71
|
+
clearTimer();dirty=false;
|
|
72
|
+
const scope=readFileScope(backend,jobId);
|
|
73
|
+
// No open document/list: no idle root scan and no network subscription.
|
|
74
|
+
if(!scope.paths.length&&!scope.dirs.length){status('ready');return;}
|
|
75
|
+
const ctl=new AbortController();controller=ctl;const token=++generation;
|
|
76
|
+
let retryable=false;
|
|
77
|
+
const current=()=>alive&&!ctl.signal.aborted&&token===generation;
|
|
78
|
+
const state={baseline:new Map(baseline),known:new Set(known)},nextDirectories=new Map(directories),nextCursors=new Map(cursors);
|
|
79
|
+
try{
|
|
80
|
+
const changes:WorkspaceFileChange[]=[];
|
|
81
|
+
let accessError:string|undefined;
|
|
82
|
+
const forbidden=(error:unknown)=>(error as {status?:number;code?:string})?.status===403&&(error as {code?:string}).code==='file_forbidden';
|
|
83
|
+
const deny=(error:unknown,paths:string[],dirs:string[])=>{
|
|
84
|
+
accessError=error instanceof Error?error.message:String(error);
|
|
85
|
+
denyFileScope(backend,jobId,accessError,{paths,dirs});
|
|
86
|
+
};
|
|
87
|
+
// A rejected path in a batch is not evidence that the whole task lost access.
|
|
88
|
+
// Inspect only that batch's metadata; healthy files and directories remain usable.
|
|
89
|
+
const inspect=async(paths:string[],dirs:string[])=>{
|
|
90
|
+
const listed=new Map<string,FileEntry>(),deniedPaths=new Set<string>();
|
|
91
|
+
for(const dir of dirs){
|
|
92
|
+
try{
|
|
93
|
+
const result=await requests.run(()=>backend.stat(jobId,dir||'.'),ctl.signal);if(!current())return;
|
|
94
|
+
if(result.type!=='dir')continue;
|
|
95
|
+
for(const file of result.entries)listed.set(file.path,file);
|
|
96
|
+
const revision=result.revision??JSON.stringify(result.entries.map(file=>[file.path,fingerprint(file)]));
|
|
97
|
+
if(nextDirectories.has(dir)&&nextDirectories.get(dir)!==revision)changes.push({kind:'modified',path:dir});
|
|
98
|
+
nextDirectories.set(dir,revision);
|
|
99
|
+
}catch(error){if(!current())return;if(!forbidden(error))throw error;deny(error,[],[dir||'.']);}
|
|
100
|
+
}
|
|
101
|
+
const files:FileEntry[]=[];
|
|
102
|
+
for(const path of paths){
|
|
103
|
+
const listedFile=listed.get(path);if(listedFile){files.push(listedFile);continue;}
|
|
104
|
+
try{const result=await requests.run(()=>backend.stat(jobId,path),ctl.signal);if(result.type==='file')files.push(result);}
|
|
105
|
+
catch(error){if(!current())return;if(forbidden(error)){deniedPaths.add(path);state.baseline.delete(path);state.known.delete(path);deny(error,[path],[]);}else if((error as {status?:number}).status!==404)throw error;}
|
|
106
|
+
if(!current())return;
|
|
107
|
+
}
|
|
108
|
+
changes.push(...snapshot(files,paths.filter(path=>!deniedPaths.has(path)),state));
|
|
109
|
+
};
|
|
110
|
+
if(backend.pollFiles){
|
|
111
|
+
const activeKeys=new Set<string>();
|
|
112
|
+
const count=Math.max(Math.ceil(scope.paths.length/200),Math.ceil(scope.dirs.length/32));
|
|
113
|
+
for(let index=0;index<count;index++){
|
|
114
|
+
const paths=scope.paths.slice(index*200,index*200+200),dirs=scope.dirs.slice(index*32,index*32+32),key=JSON.stringify([paths,dirs]);activeKeys.add(key);
|
|
115
|
+
let batch:Awaited<ReturnType<NonNullable<BoardBackend['pollFiles']>>>;
|
|
116
|
+
try{batch=await requests.run(()=>backend.pollFiles!(jobId,{paths,dirs,cursor:nextCursors.get(key)},ctl.signal),ctl.signal);}
|
|
117
|
+
catch(error){if(!forbidden(error))throw error;nextCursors.delete(key);await inspect(paths,dirs);if(!current())return;continue;}
|
|
118
|
+
if(!current())return;
|
|
119
|
+
if(!batch||typeof batch!=='object')throw invalidResponse('文件变化响应不完整,请重试');
|
|
120
|
+
if(batch.type==='resync'){cursors.delete(key);throw new Error('文件修订已变化,请重新核对');}
|
|
121
|
+
if(batch.cursor)nextCursors.set(key,batch.cursor);
|
|
122
|
+
changes.push(...accept(batch,paths,state));
|
|
123
|
+
if(!Array.isArray(batch.directories))throw invalidResponse('目录变化响应不完整,请重试');
|
|
124
|
+
for(const dir of batch.directories){
|
|
125
|
+
if(!dir||!dirs.includes(dir.path)||(dir.revision!==null&&typeof dir.revision!=='string'))throw invalidResponse('目录变化响应范围错误');
|
|
126
|
+
if(nextDirectories.has(dir.path)&&nextDirectories.get(dir.path)!==dir.revision)changes.push({kind:dir.revision===null?'deleted':'modified',path:dir.path});
|
|
127
|
+
nextDirectories.set(dir.path,dir.revision);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for(const key of nextCursors.keys())if(!activeKeys.has(key))nextCursors.delete(key);
|
|
131
|
+
}else{
|
|
132
|
+
// Compatibility hosts: inspect requested entries, never recurse a workspace.
|
|
133
|
+
await inspect(scope.paths,scope.dirs);
|
|
134
|
+
}
|
|
135
|
+
if(!current())return;
|
|
136
|
+
const previews=backend.fileSync?await refreshFilePreviews(backend.fileSync,jobId,scope.paths,ctl.signal):undefined;
|
|
137
|
+
if(!current())return;
|
|
138
|
+
for(const file of previews?.denied??[])deny(new Error(file.message),[file.path],[]);
|
|
139
|
+
const changedPaths=new Set(changes.map(change=>change.path));
|
|
140
|
+
for(const path of previews?.changed??[])if(!changedPaths.has(path)){changes.push({kind:'modified',path});changedPaths.add(path);}
|
|
141
|
+
const active=new Set(scope.paths);for(const path of state.known)if(!active.has(path)){state.known.delete(path);state.baseline.delete(path);}
|
|
142
|
+
for(const dir of nextDirectories.keys())if(!scope.dirs.includes(dir))nextDirectories.delete(dir);
|
|
143
|
+
baseline=state.baseline;known=state.known;directories=nextDirectories;cursors=nextCursors;
|
|
144
|
+
publish(changes);
|
|
145
|
+
if(previews?.error)throw previews.error;
|
|
146
|
+
failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status(accessError?'error':'ready',accessError);
|
|
147
|
+
}catch(error){if(current()){
|
|
148
|
+
failed++;const code=(error as {status?:number}).status??0;
|
|
149
|
+
const failureCode=(error as {code?:string}).code;
|
|
150
|
+
retryable=!['invalid_manifest','invalid_response','integrity','response_too_large','client_queue_timeout'].includes(failureCode??'')&&(!code||[408,429,500,502,503,504].includes(code));
|
|
151
|
+
if(retryable){
|
|
152
|
+
const hint=Number((error as {retryAfterMs?:number}).retryAfterMs);
|
|
153
|
+
if(Number.isFinite(hint)&&hint>0)serverNotBefore=Math.max(serverNotBefore,Date.now()+hint);
|
|
154
|
+
const base=retryDelays[Math.min(failed-1,retryDelays.length-1)];
|
|
155
|
+
retryAt=Math.max(Date.now()+base,serverNotBefore)+Math.floor(Math.random()*base/2);
|
|
156
|
+
if(failed>retryDelays.length)exhaustedUntil=Math.max(Date.now()+30000,serverNotBefore);
|
|
157
|
+
}
|
|
158
|
+
if([401,403].includes(code))denyFileScope(backend,jobId,error instanceof Error?error.message:undefined);
|
|
159
|
+
status('error',error instanceof Error?error.message:String(error));
|
|
160
|
+
}}
|
|
161
|
+
finally{if(controller===ctl){
|
|
162
|
+
controller=null;
|
|
163
|
+
if(alive&&!hidden()){
|
|
164
|
+
if(retryable){
|
|
165
|
+
// A new round during an outage cannot skip backoff or replenish this retry budget.
|
|
166
|
+
if(failed<=retryDelays.length){const pending=dirty;dirty=false;schedule(Math.max(0,retryAt-Date.now()),pending);}
|
|
167
|
+
else if(dirty)schedule(Math.max(0,exhaustedUntil-Date.now(),serverNotBefore-Date.now()),true);
|
|
168
|
+
}else if(dirty)schedule(Math.max(100,serverNotBefore-Date.now()),true);
|
|
169
|
+
}
|
|
170
|
+
}}
|
|
171
|
+
};
|
|
172
|
+
function clearTimer(){if(timer!==undefined)clearTimeout(timer);timer=undefined;}
|
|
173
|
+
function schedule(delay:number,pending=false){
|
|
174
|
+
clearTimer();
|
|
175
|
+
// Long server cooldowns defer a real event once, never extend idle retries.
|
|
176
|
+
// Do not let setTimeout overflow and retry before the server's deadline.
|
|
177
|
+
if(delay>2147483647||(!pending&&delay>180000))return;
|
|
178
|
+
timer=setTimeout(()=>{timer=undefined;void scan();},delay);
|
|
179
|
+
}
|
|
180
|
+
const wake=(manual=false)=>{
|
|
181
|
+
if(!alive)return;
|
|
182
|
+
const now=Date.now();
|
|
183
|
+
if(manual){failed=0;exhaustedUntil=0;clearTimer();}
|
|
184
|
+
else if(exhaustedUntil&&now>=exhaustedUntil){
|
|
185
|
+
// Exhaustion alone never restarts work; a later event can restore its budget.
|
|
186
|
+
failed=0;exhaustedUntil=0;
|
|
187
|
+
}
|
|
188
|
+
dirty=true;
|
|
189
|
+
// Pending events coalesce, and automatic wakes cannot bypass an existing retry delay.
|
|
190
|
+
if(!controller&&!hidden()&&timer===undefined)schedule(Math.max(100,serverNotBefore-now,manual?0:Math.max(retryAt,exhaustedUntil)-now),true);
|
|
191
|
+
};
|
|
192
|
+
const recover=()=>wake();
|
|
193
|
+
const scopeChanged=()=>{
|
|
194
|
+
if(!alive)return;
|
|
195
|
+
const next=JSON.stringify(readFileScope(backend,jobId));if(next===scopeKey)return;scopeKey=next;
|
|
196
|
+
generation++;const old=controller;controller=null;old?.abort();wake(true);
|
|
197
|
+
};
|
|
198
|
+
const visibility=()=>{
|
|
199
|
+
if(hidden()){generation++;const old=controller;controller=null;old?.abort();clearTimer();dirty=true;}
|
|
200
|
+
else recover();
|
|
201
|
+
};
|
|
202
|
+
const releaseScope=onFileScope(backend,jobId,scopeChanged);
|
|
203
|
+
const releasePreviews=backend.fileSync?onFilePreviewScope(backend.fileSync,jobId,()=>wake()):()=>{};
|
|
204
|
+
if(typeof window!=='undefined'){window.addEventListener('online',recover);document.addEventListener('visibilitychange',visibility);}
|
|
205
|
+
owned.retry=wake;
|
|
206
|
+
owned.stop=()=>{alive=false;clearTimer();controller?.abort();releaseScope();releasePreviews();if(typeof window!=='undefined'){window.removeEventListener('online',recover);document.removeEventListener('visibilitychange',visibility);}};
|
|
207
|
+
queueMicrotask(()=>{if(alive)void scan();});
|
|
208
|
+
}
|
|
209
|
+
const owned=entry,subscription={change:listener,status:options.onStatus};owned.listeners.add(subscription);notifyCallback(subscription.status,{...owned.status});
|
|
210
|
+
let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(subscription);if(!owned.listeners.size){if(jobs!.get(jobId)===owned)jobs!.delete(jobId);owned.stop();}releaseOwnedScope();};
|
|
211
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// 工作区「下载整个文件夹」:逐级列目录、逐个读文件(经中枢),在浏览器里打成 ZIP 下载。
|
|
2
|
+
// 有上限:文件数、总大小(读进内存再打包);超了直接报出来,让用户进到更小的目录或单个下载,不会把页面撑爆。
|
|
3
|
+
// 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
|
|
4
|
+
// 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
|
|
5
|
+
import { CenterError, type BoardBackend } from "../core/client";
|
|
6
|
+
import type { DirListing, FileEntry } from "../core/types";
|
|
7
|
+
import { fmtSize } from "./fileKind";
|
|
8
|
+
import { saveBlob, readBoundedBuffer, FileReadLimitError } from "./fileOps";
|
|
9
|
+
import { buildZip, type ZipEntry } from "./zip";
|
|
10
|
+
|
|
11
|
+
export const FOLDER_MAX_FILES = 2000;
|
|
12
|
+
export const FOLDER_MAX_BYTES = 300 * 1024 * 1024;
|
|
13
|
+
/** 同时读几个文件:中枢 / 节点不被一个人的下载占满,浏览器连接也留给别的请求。 */
|
|
14
|
+
const PARALLEL = 3;
|
|
15
|
+
/** 每个请求最多试几次,两次之间等多久(毫秒;带 ±30% 浮动)。 */
|
|
16
|
+
const ATTEMPTS = 3;
|
|
17
|
+
const BACKOFF_MS = [1_000, 3_000];
|
|
18
|
+
export const FAILED_LIST_NAME = "_下载失败的文件.txt";
|
|
19
|
+
|
|
20
|
+
export type FolderProgress = { phase: "listing" | "reading" | "zipping"; files: number; total: number; bytes: number; totalBytes?: number; failed?: number; retrying?: string };
|
|
21
|
+
export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }>; /** ZIP failure report path, when generated; may differ to preserve a user file. */ failedListName?:string };
|
|
22
|
+
export type FolderZip = FolderResult & { blob: Blob; name: string };
|
|
23
|
+
export type ListOptions = { signal?: AbortSignal; backoff?: number[]; maxFiles?: number; maxBytes?: number };
|
|
24
|
+
|
|
25
|
+
const abortError = () => new DOMException("已取消", "AbortError");
|
|
26
|
+
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
|
|
27
|
+
|
|
28
|
+
/** 值得再试的失败:网络(超时、断线)、限流、中枢 / 节点临时出错。404、403 之类再试也一样。 */
|
|
29
|
+
export function isTransient(error: unknown): boolean {
|
|
30
|
+
if (!(error instanceof CenterError)) return !isAbort(error);
|
|
31
|
+
return error.code === "NETWORK" || error.status === 429 || error.status === 408 || error.status >= 500 || error.code === "node_unreachable";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sleep(ms: number, signal?: AbortSignal) {
|
|
35
|
+
return new Promise<void>((resolve, reject) => {
|
|
36
|
+
if (signal?.aborted) return reject(abortError());
|
|
37
|
+
const t = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
|
|
38
|
+
const stop = () => { clearTimeout(t); reject(abortError()); };
|
|
39
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 失败了按退避再试(只试临时性的失败)。onRetry 告诉界面「正在重试」。 */
|
|
44
|
+
export async function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, onRetry?: (attempt: number, error: unknown) => void, backoff = BACKOFF_MS): Promise<T> {
|
|
45
|
+
for (let attempt = 1; ; attempt++) {
|
|
46
|
+
if (signal?.aborted) throw abortError();
|
|
47
|
+
let stop:(()=>void)|undefined;
|
|
48
|
+
try {
|
|
49
|
+
const cancelled=new Promise<never>((_resolve,reject)=>{stop=()=>reject(abortError());signal?.addEventListener("abort",stop,{once:true});});
|
|
50
|
+
const value=await Promise.race([fn(),cancelled]);
|
|
51
|
+
if(signal?.aborted)throw abortError();
|
|
52
|
+
return value;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (isAbort(error) || signal?.aborted) throw abortError();
|
|
55
|
+
if (attempt >= ATTEMPTS || error instanceof FileReadLimitError || !isTransient(error)) throw error;
|
|
56
|
+
onRetry?.(attempt, error);
|
|
57
|
+
const base = backoff[Math.min(attempt - 1, backoff.length - 1)] ?? 0;
|
|
58
|
+
await sleep(Math.round(base * (0.7 + Math.random() * 0.6)), signal);
|
|
59
|
+
} finally {if(stop)signal?.removeEventListener("abort",stop);}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 列出 dir 下所有文件(递归)。超过上限(缺省 FOLDER_MAX_FILES 个、FOLDER_MAX_BYTES)抛错。 */
|
|
64
|
+
export async function listFolder(backend: BoardBackend, jobId: string, dir: string, opts: ListOptions = {}): Promise<Array<{ path: string; size: number; mtime: number }>> {
|
|
65
|
+
const { signal, backoff, maxFiles = FOLDER_MAX_FILES, maxBytes = FOLDER_MAX_BYTES } = opts;
|
|
66
|
+
const out: Array<{ path: string; size: number; mtime: number }> = [];
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
const queue = [dir || "."];
|
|
69
|
+
while (queue.length) {
|
|
70
|
+
if (signal?.aborted) throw abortError();
|
|
71
|
+
const cur = queue.shift()!;
|
|
72
|
+
const entries = new Map<string, FileEntry>();
|
|
73
|
+
const cursors = new Set<string>();
|
|
74
|
+
let cursor: string | undefined, revision: string | undefined;
|
|
75
|
+
let directoryBytes = 0, directoryFiles = 0;
|
|
76
|
+
try {
|
|
77
|
+
do {
|
|
78
|
+
const res = await withRetry(async () => {
|
|
79
|
+
try { return backend.listFiles ? await backend.listFiles(jobId, cur, { cursor, limit: 200, signal }) : await backend.stat(jobId, cur); }
|
|
80
|
+
catch (error) {
|
|
81
|
+
// New short-HTTP clients preserve status on ordinary Errors too.
|
|
82
|
+
const http = error as { status?: number; code?: string; message?: string };
|
|
83
|
+
if (!(error instanceof CenterError) && typeof http.status === "number") throw new CenterError(http.message ?? "列出目录失败", http.code ?? `HTTP_${http.status}`, http.status);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}, signal, undefined, backoff);
|
|
87
|
+
if (res.type !== "dir") {
|
|
88
|
+
if (cursor) throw new Error("目录在读取时发生变化,请重新下载");
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const page: DirListing = res;
|
|
92
|
+
if (cursor && (!revision || page.revision !== revision)) throw new Error("目录在读取时发生变化,请重新下载");
|
|
93
|
+
revision ??= page.revision;
|
|
94
|
+
for (const entry of page.entries) {
|
|
95
|
+
if (entries.has(entry.path)) throw new Error("目录分页数据重复,请重新下载");
|
|
96
|
+
entries.set(entry.path, entry);
|
|
97
|
+
if (entry.type === "file") {
|
|
98
|
+
directoryFiles++; directoryBytes += entry.size;
|
|
99
|
+
if (out.length + directoryFiles > maxFiles) throw new Error(`文件超过 ${maxFiles} 个,请进到更小的目录再下载`);
|
|
100
|
+
if (bytes + directoryBytes > maxBytes) throw new Error(`总大小超过 ${fmtSize(maxBytes)},请进到更小的目录或单个下载`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cursor = page.nextCursor;
|
|
104
|
+
if (page.truncated && (!cursor || !backend.listFiles)) throw new Error(`「${cur === "." ? "工作区" : cur}」里文件太多,节点只列出了一部分;请进到更小的目录再下载`);
|
|
105
|
+
if (cursor && (!revision || cursors.has(cursor))) throw new Error("目录分页游标无效,请重新下载");
|
|
106
|
+
if (cursor) cursors.add(cursor);
|
|
107
|
+
} while (cursor);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// 子目录在列的时候被任务删了:跳过它;要下载的目录本身没了照常报错。
|
|
110
|
+
if (cur !== (dir || ".") && error instanceof CenterError && error.status === 404) continue;
|
|
111
|
+
if (error instanceof CenterError && error.status === 409) throw new Error("目录在读取时发生变化,请重新下载");
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
// Commit a directory only after every page has the same revision.
|
|
115
|
+
for (const e of entries.values()) {
|
|
116
|
+
if (e.type === "dir") { queue.push(e.path); continue; }
|
|
117
|
+
out.push({ path: e.path, size: e.size, mtime: e.mtime });
|
|
118
|
+
bytes += e.size;
|
|
119
|
+
if (out.length > maxFiles) throw new Error(`文件超过 ${maxFiles} 个,请进到更小的目录再下载`);
|
|
120
|
+
if (bytes > maxBytes) throw new Error(`总大小超过 ${fmtSize(maxBytes)},请进到更小的目录或单个下载`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type FolderOptions = { onProgress?: (p: FolderProgress) => void; signal?: AbortSignal; zipName?: string; backoff?: number[] };
|
|
127
|
+
|
|
128
|
+
/** 打包下载 dir("" = 整个工作区)到本地。返回打了几个文件、哪些没读到。 */
|
|
129
|
+
export async function downloadFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderResult> {
|
|
130
|
+
const { blob, name, files, failed, failedListName } = await zipFolder(backend, jobId, dir, opts);
|
|
131
|
+
if(opts.signal?.aborted)throw abortError();
|
|
132
|
+
saveBlob(blob, name);
|
|
133
|
+
return { files, failed, ...(failedListName?{failedListName}:{}) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 把 dir("" = 整个工作区)打成 ZIP,返回 Blob(不下载;业务方可以自己上传到服务器)。 */
|
|
137
|
+
export async function zipFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderZip> {
|
|
138
|
+
const ctl=new AbortController(),abort=()=>ctl.abort();let limit:FileReadLimitError|undefined;
|
|
139
|
+
opts.signal?.addEventListener("abort",abort,{once:true});if(opts.signal?.aborted)ctl.abort();
|
|
140
|
+
try{return await zipFolderRun(backend,jobId,dir,{...opts,signal:ctl.signal},()=>{limit=new FileReadLimitError(FOLDER_MAX_BYTES);ctl.abort();});}
|
|
141
|
+
catch(error){throw limit??error;}
|
|
142
|
+
finally{opts.signal?.removeEventListener("abort",abort);ctl.abort();}
|
|
143
|
+
}
|
|
144
|
+
async function zipFolderRun(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions,exceeded:()=>void): Promise<FolderZip> {
|
|
145
|
+
const { onProgress, signal } = opts;
|
|
146
|
+
onProgress?.({ phase: "listing", files: 0, total: 0, bytes: 0 });
|
|
147
|
+
const files = await listFolder(backend, jobId, dir, { signal, backoff: opts.backoff });
|
|
148
|
+
if (!files.length) throw new Error("这个目录里没有文件");
|
|
149
|
+
const totalBytes = files.reduce((n, f) => n + f.size, 0);
|
|
150
|
+
const base = dir ? `${dir.replace(/\/+$/, "")}/` : "";
|
|
151
|
+
const entries: Array<ZipEntry | null> = new Array(files.length).fill(null);
|
|
152
|
+
const failed: FolderResult["failed"] = [];
|
|
153
|
+
// 每个文件已收到的字节(含正在传的),进度条按它走:一个大文件慢慢传时也看得到在动。
|
|
154
|
+
const got = new Array<number>(files.length).fill(0);
|
|
155
|
+
let done = 0;
|
|
156
|
+
let next = 0;
|
|
157
|
+
let retrying: string | undefined;
|
|
158
|
+
let lastReport = 0;
|
|
159
|
+
const report = (force = false) => {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
if (!force && now - lastReport < 200) return;
|
|
162
|
+
lastReport = now;
|
|
163
|
+
onProgress?.({ phase: "reading", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length, retrying });
|
|
164
|
+
};
|
|
165
|
+
const worker = async () => {
|
|
166
|
+
while (next < files.length) {
|
|
167
|
+
const i = next++;
|
|
168
|
+
if (signal?.aborted) throw abortError();
|
|
169
|
+
const f = files[i];
|
|
170
|
+
try {
|
|
171
|
+
const buffer = await withRetry(
|
|
172
|
+
() => { got[i] = 0; return readBoundedBuffer(backend,jobId,f.path,FOLDER_MAX_BYTES,{ signal, onBytes: (n) => { got[i] = n; if(got.reduce((a,b)=>a+b,0)>FOLDER_MAX_BYTES){exceeded();return;} report(); } }); },
|
|
173
|
+
signal,
|
|
174
|
+
() => { retrying = f.path; report(true); },
|
|
175
|
+
opts.backoff,
|
|
176
|
+
);
|
|
177
|
+
const data = new Uint8Array(buffer);
|
|
178
|
+
got[i] = data.length;
|
|
179
|
+
// 任务在写、文件比列的时候大了很多:总量还是要守住上限。
|
|
180
|
+
if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
|
|
181
|
+
// ZIP 里的路径相对下载的目录(文件夹名本身作为 ZIP 文件名)。
|
|
182
|
+
entries[i] = { path: f.path.startsWith(base) ? f.path.slice(base.length) : f.path, data, mtime: f.mtime };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (isAbort(error) || signal?.aborted) throw abortError();
|
|
185
|
+
if (!(error instanceof CenterError)) throw error;
|
|
186
|
+
got[i] = 0;
|
|
187
|
+
failed.push({ path: f.path, reason: error.message });
|
|
188
|
+
}
|
|
189
|
+
if (retrying === f.path) retrying = undefined;
|
|
190
|
+
done++;
|
|
191
|
+
report(true);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
// 列完马上报「读取 0 / N」:网络慢时第一个文件要好一会儿才有字节,不能一直停在「正在列出」。
|
|
195
|
+
report(true);
|
|
196
|
+
await Promise.all(Array.from({ length: Math.min(PARALLEL, files.length) }, worker));
|
|
197
|
+
const ok = entries.filter((e): e is ZipEntry => !!e);
|
|
198
|
+
if (!ok.length) throw new Error(`${failed.length} 个文件都没读到:${failed[0]?.reason ?? "未知原因"}`);
|
|
199
|
+
onProgress?.({ phase: "zipping", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length });
|
|
200
|
+
if(signal?.aborted)throw abortError();
|
|
201
|
+
let failedListName:string|undefined;
|
|
202
|
+
if (failed.length) {
|
|
203
|
+
const names=new Set(ok.map(entry=>entry.path));failedListName=FAILED_LIST_NAME;
|
|
204
|
+
for(let suffix=1;names.has(failedListName);suffix++)failedListName=`_下载失败的文件 (${suffix}).txt`;
|
|
205
|
+
const list = ["下面这些文件没有下载下来(其余文件都在压缩包里):", "", ...failed.map((f) => `${f.path}\t${f.reason}`), ""].join("\n");
|
|
206
|
+
ok.push({ path: failedListName, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
|
|
207
|
+
}
|
|
208
|
+
const blob = buildZip(ok);
|
|
209
|
+
const name = opts.zipName ?? `${(dir.split("/").filter(Boolean).pop() || "工作区")}.zip`;
|
|
210
|
+
return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed, ...(failedListName?{failedListName}:{}) };
|
|
211
|
+
}
|