@xgspz/cli-ui 0.0.0-stage → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/README.md +512 -2
- package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
- package/dist/cli-ui-index-C34qFb4u.js +11209 -0
- package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
- package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
- package/dist/cli-ui-xlsx-JVLtx0qR.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 +932 -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 +577 -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 +1103 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +505 -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 +1949 -0
- package/src/operator.css +1949 -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,52 @@
|
|
|
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; top?: number; left?: number; ready?: 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 ready(){requestAnimationFrame(function(){requestAnimationFrame(function(){post({ready:true})})})}if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',ready,{once:true});else ready();
|
|
20
|
+
function send(){var s=getSelection(),text=s&&!s.isCollapsed?String(s).trim():"",rect=null;
|
|
21
|
+
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})}
|
|
22
|
+
var timer;function later(){clearTimeout(timer);timer=setTimeout(send,0)}
|
|
23
|
+
document.addEventListener("mouseup",later);document.addEventListener("keyup",later);
|
|
24
|
+
document.addEventListener("selectionchange",later);
|
|
25
|
+
addEventListener("scroll",function(){post({scroll:true,top:scrollY,left:scrollX})},true);
|
|
26
|
+
addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiClear===t){var s=getSelection();s&&s.removeAllRanges()}});
|
|
27
|
+
addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiRestore===t)scrollTo(Number(e.data.left)||0,Number(e.data.top)||0)});
|
|
28
|
+
document.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href^="#"]');if(!a)return;e.preventDefault();
|
|
29
|
+
var id=decodeURIComponent(a.getAttribute("href").slice(1)),el=id?document.getElementById(id)||document.getElementsByName(id)[0]:document.body;el&&el.scrollIntoView()});})();`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const escAttr = (s: string) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 给 HTML 文档插上选区桥:CSP(页面脚本不跑、只能从文件所在地址取资源)、<base>、桥脚本。
|
|
36
|
+
* base = 文件的绝对地址(相对资源按它解析);不给(Word 转出来的,图片都内嵌)时只允许 data: 资源。
|
|
37
|
+
*/
|
|
38
|
+
export function bridgeDoc(html: string, opts: { token: string; base?: string }) {
|
|
39
|
+
let origin = "";
|
|
40
|
+
if (opts.base) { try { origin = new URL(opts.base).origin; } catch { origin = ""; } }
|
|
41
|
+
const src = origin ? `${origin} ` : "";
|
|
42
|
+
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:`;
|
|
43
|
+
const head = `<meta http-equiv="Content-Security-Policy" content="${escAttr(csp)}">`
|
|
44
|
+
+ (opts.base ? `<base href="${escAttr(opts.base)}">` : "")
|
|
45
|
+
+ `<script nonce="${opts.token}">${bridgeScript(opts.token)}</script>`;
|
|
46
|
+
// 只保留首部完整、常见的 doctype;其他原件内容必须全部位于 CSP 和桥之后。
|
|
47
|
+
// 不搜索原件中的 <head>:它可能在注释 / 脚本字符串中,未闭合标签也会令全文正则反复回溯。
|
|
48
|
+
// 有界首部匹配同时避开带引号的异常 doctype 把策略吞进声明里的情况。
|
|
49
|
+
const doctype = /^(?:\uFEFF)?[ \t\r\n\f]*(<!doctype[ \t\r\n\f]+html(?:[ \t\r\n\f]+(?:public[ \t\r\n\f]+(?:"[^"<>\0]*"|'[^'<>\0]*')(?:[ \t\r\n\f]+(?:"[^"<>\0]*"|'[^'<>\0]*'))?|system[ \t\r\n\f]+(?:"[^"<>\0]*"|'[^'<>\0]*')))?[ \t\r\n\f]*>)/i.exec(html.slice(0, 4096));
|
|
50
|
+
// 解析器会自动建立 head,再读取原件的 html/head/body,保留文档内容及 html 属性。
|
|
51
|
+
return doctype ? doctype[1] + head + html.slice(doctype[0].length) : "<!doctype html>" + head + html;
|
|
52
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Parser } from "htmlparser2";
|
|
2
|
+
|
|
3
|
+
export interface HtmlReadingBlock {
|
|
4
|
+
kind: "heading" | "paragraph" | "list-item" | "code" | "table-row" | "image";
|
|
5
|
+
text: string;
|
|
6
|
+
level?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface HtmlReadingPage { blocks: HtmlReadingBlock[]; textLength: number }
|
|
9
|
+
export interface HtmlReadingResult {
|
|
10
|
+
pages: HtmlReadingPage[];
|
|
11
|
+
pageCount: number;
|
|
12
|
+
pageOffset: number;
|
|
13
|
+
limited: boolean;
|
|
14
|
+
reason?: "depth" | "markup";
|
|
15
|
+
complete: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BATCH = 8 * 1024, BLOCK_TEXT = 8 * 1024, PAGE_TEXT = 16 * 1024;
|
|
19
|
+
const TOKEN_GAP = 64 * 1024, EMPTY_TAGS = 100_000;
|
|
20
|
+
const ignored = new Set(["head", "script", "style", "iframe", "object", "embed", "svg", "math", "canvas", "template", "video", "audio"]);
|
|
21
|
+
const boundaries = new Set(["p", "div", "section", "article", "main", "header", "footer", "nav", "aside", "blockquote", "figure", "figcaption", "address", "details", "summary", "dl", "dt", "dd", "ul", "ol", "li", "pre", "table", "tr", "hr", "h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
22
|
+
interface Frame { tag: string; skipped: boolean; kind?: HtmlReadingBlock["kind"]; level?: number; count?: number; marker?: string; alt?: string }
|
|
23
|
+
|
|
24
|
+
/** Static reader only: outputs text, never markup, attributes, CSS or resource URLs.
|
|
25
|
+
* Reparse each verified prefix. Only call end() at actual EOF, so partial tags/entities
|
|
26
|
+
* remain pending instead of being mistaken for completed source content.
|
|
27
|
+
*/
|
|
28
|
+
export async function readHtmlSections(text: string, options: { signal?: AbortSignal; eof?: boolean; page?: number } = {}): Promise<HtmlReadingResult> {
|
|
29
|
+
const pages: HtmlReadingPage[] = [], stack: Frame[] = [];
|
|
30
|
+
const target = options.page === undefined ? undefined : Math.max(0, Number.isFinite(options.page) ? Math.floor(options.page) : 0);
|
|
31
|
+
let currentPage: HtmlReadingPage | undefined, selectedPage: HtmlReadingPage | undefined, pageCount = 0;
|
|
32
|
+
let active: HtmlReadingBlock | undefined, reason: HtmlReadingResult["reason"], emptyTags = 0;
|
|
33
|
+
const check = () => { if (options.signal?.aborted) throw new DOMException("已取消", "AbortError"); };
|
|
34
|
+
const emit = () => {
|
|
35
|
+
if (!active) return;
|
|
36
|
+
const block = active; active = undefined;
|
|
37
|
+
if (block.kind !== "code") block.text = block.text.trim();
|
|
38
|
+
if (!block.text.trim()) return;
|
|
39
|
+
if (!currentPage || currentPage.blocks.length >= 64 || currentPage.textLength + block.text.length > PAGE_TEXT) {
|
|
40
|
+
currentPage = { blocks: [], textLength: 0 }; pageCount++;
|
|
41
|
+
if (target === undefined) pages.push(currentPage);
|
|
42
|
+
else if (pageCount - 1 === target) selectedPage = currentPage;
|
|
43
|
+
}
|
|
44
|
+
// With a requested page, all other completed pages are released immediately.
|
|
45
|
+
// Keep the last page too, so a shortened revision can fall back without another scan.
|
|
46
|
+
currentPage.blocks.push(block); currentPage.textLength += block.text.length;
|
|
47
|
+
};
|
|
48
|
+
const context = (): Frame | undefined => {
|
|
49
|
+
// A paragraph inside a cell/code block must not break its enclosing row/code.
|
|
50
|
+
const fixed = stack.find(frame => frame.kind === "code" || frame.kind === "table-row");
|
|
51
|
+
const closest = [...stack].reverse();
|
|
52
|
+
return fixed ?? closest.find(frame => frame.kind && frame.kind !== "paragraph") ?? closest.find(frame => frame.kind);
|
|
53
|
+
};
|
|
54
|
+
const append = (data: string) => {
|
|
55
|
+
if (!data || stack[stack.length - 1]?.skipped || reason) return;
|
|
56
|
+
const frame = context(), kind = frame?.kind ?? "paragraph", level = frame?.level;
|
|
57
|
+
if (kind !== "code") data = data.replace(/[ \t\r\n\f]+/g, " ");
|
|
58
|
+
if (kind !== "code" && !data.trim() && !active) return;
|
|
59
|
+
if (data.trim()) emptyTags = 0;
|
|
60
|
+
if (active && (active.kind !== kind || active.level !== level)) emit();
|
|
61
|
+
if (frame?.marker && data.trim()) { data = frame.marker + data.trimStart(); frame.marker = undefined; }
|
|
62
|
+
let offset = 0;
|
|
63
|
+
while (offset < data.length) {
|
|
64
|
+
active ??= { kind, text: "", ...(level === undefined ? {} : { level }) };
|
|
65
|
+
if (kind !== "code" && data[offset] === " " && (!active.text || active.text.endsWith(" "))) offset++;
|
|
66
|
+
if (offset >= data.length) break;
|
|
67
|
+
let take = Math.min(BLOCK_TEXT - active.text.length, data.length - offset);
|
|
68
|
+
// Do not cut an emoji/supplementary character into two React text blocks.
|
|
69
|
+
const last = data.charCodeAt(offset + take - 1), next = data.charCodeAt(offset + take);
|
|
70
|
+
if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) take--;
|
|
71
|
+
if (!take) { emit(); continue; }
|
|
72
|
+
active.text += data.slice(offset, offset + take); offset += take;
|
|
73
|
+
if (active.text.length >= BLOCK_TEXT) emit();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const stop = (why: NonNullable<HtmlReadingResult["reason"]>) => { reason = why; parser.pause(); };
|
|
77
|
+
const parser = new Parser({
|
|
78
|
+
onopentagname(tag) {
|
|
79
|
+
if (reason) return;
|
|
80
|
+
if (stack.length >= 32) { stop("depth"); return; }
|
|
81
|
+
if (++emptyTags > EMPTY_TAGS) { stop("markup"); return; }
|
|
82
|
+
const skipped = !!stack[stack.length - 1]?.skipped || ignored.has(tag);
|
|
83
|
+
const fixed = context();
|
|
84
|
+
if (!skipped && (boundaries.has(tag) || tag === "img") && fixed?.kind !== "code" && fixed?.kind !== "table-row") emit();
|
|
85
|
+
const frame: Frame = { tag, skipped };
|
|
86
|
+
if (tag.length === 2 && tag[0] === "h" && tag[1] >= "1" && tag[1] <= "6") { frame.kind = "heading"; frame.level = Number(tag[1]); }
|
|
87
|
+
else if (tag === "pre") frame.kind = "code";
|
|
88
|
+
else if (tag === "tr") { frame.kind = "table-row"; frame.count = 0; }
|
|
89
|
+
else if (tag === "li") {
|
|
90
|
+
const list = [...stack].reverse().find(parent => parent.tag === "ol" || parent.tag === "ul");
|
|
91
|
+
frame.kind = "list-item";
|
|
92
|
+
frame.marker = list?.tag === "ol" ? `${list.count = (list.count ?? 0) + 1}. ` : "• ";
|
|
93
|
+
}
|
|
94
|
+
else if (boundaries.has(tag)) frame.kind = "paragraph";
|
|
95
|
+
if (!skipped && (tag === "td" || tag === "th")) {
|
|
96
|
+
const row = stack.find(parent => parent.tag === "tr");
|
|
97
|
+
if (row && (row.count = (row.count ?? 0) + 1) > 1) append(" | ");
|
|
98
|
+
}
|
|
99
|
+
stack.push(frame);
|
|
100
|
+
if (!skipped && tag === "br") append(fixed?.kind === "code" ? "\n" : " ");
|
|
101
|
+
},
|
|
102
|
+
// No onopentag callback: htmlparser2 does not build an attributes object.
|
|
103
|
+
// The one permitted attribute is copied as bounded, inert text.
|
|
104
|
+
onattribute(name, value) {
|
|
105
|
+
const frame = stack[stack.length - 1];
|
|
106
|
+
if (!reason && frame?.tag === "img" && !frame.skipped && name === "alt") frame.alt = value.slice(0, BLOCK_TEXT - 6);
|
|
107
|
+
},
|
|
108
|
+
ontext: append,
|
|
109
|
+
onclosetag(tag) {
|
|
110
|
+
if (reason) return;
|
|
111
|
+
const frame = stack[stack.length - 1];
|
|
112
|
+
if (!frame || frame.tag !== tag) return;
|
|
113
|
+
if (!frame.skipped) {
|
|
114
|
+
if (tag === "img") {
|
|
115
|
+
emit(); active = { kind: "image", text: frame.alt?.trim() ? `[图片:${frame.alt.trim()}]` : "[图片]" }; emit();
|
|
116
|
+
}
|
|
117
|
+
const fixed = context();
|
|
118
|
+
if (boundaries.has(tag) && (fixed?.kind !== "code" && fixed?.kind !== "table-row" || fixed === frame)) emit();
|
|
119
|
+
}
|
|
120
|
+
stack.pop();
|
|
121
|
+
},
|
|
122
|
+
}, { decodeEntities: true });
|
|
123
|
+
check();
|
|
124
|
+
let yieldedAt = performance.now();
|
|
125
|
+
try {
|
|
126
|
+
for (let offset = 0; offset < text.length && !reason;) {
|
|
127
|
+
check(); let end = Math.min(text.length, offset + BATCH);
|
|
128
|
+
const last = text.charCodeAt(end - 1), next = text.charCodeAt(end);
|
|
129
|
+
if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--;
|
|
130
|
+
parser.write(text.slice(offset, end));
|
|
131
|
+
offset = end;
|
|
132
|
+
// Bound parser retention for a single unfinished comment/tag/attribute.
|
|
133
|
+
if (!reason && end - Math.max(parser.startIndex, parser.endIndex) > TOKEN_GAP) stop("markup");
|
|
134
|
+
if (performance.now() - yieldedAt >= 8) {
|
|
135
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0)); check(); yieldedAt = performance.now();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
check();
|
|
139
|
+
if (options.eof && !reason) parser.end();
|
|
140
|
+
emit();
|
|
141
|
+
const selected = selectedPage ?? currentPage;
|
|
142
|
+
return {
|
|
143
|
+
pages: target === undefined ? pages : selected ? [selected] : [], pageCount,
|
|
144
|
+
pageOffset: target === undefined ? 0 : selectedPage ? target : Math.max(0, pageCount - 1),
|
|
145
|
+
limited: !!reason, ...(reason ? { reason } : {}), complete: !!options.eof && !reason,
|
|
146
|
+
};
|
|
147
|
+
} finally { parser.reset(); }
|
|
148
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
|
2
|
+
const useLayout = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
3
|
+
/** Initial visibility is separate from capability. Closing a panel never disables its backend. */
|
|
4
|
+
export function useBoardPanels(scope: string | null, available: readonly string[], initial: readonly string[]) {
|
|
5
|
+
const allowed = [...new Set(available)];
|
|
6
|
+
const defaults = [...new Set(initial)].filter(id => allowed.includes(id));
|
|
7
|
+
const signature = JSON.stringify([allowed, defaults]);
|
|
8
|
+
const key = scope ? `cli-ui:panels:${scope}:${signature}` : null;
|
|
9
|
+
const [state, setState] = useState<{ key: string | null; open: string[] }>({ key, open: defaults });
|
|
10
|
+
const current = state.key === key ? state.open.filter(id => allowed.includes(id)) : defaults;
|
|
11
|
+
useLayout(() => {
|
|
12
|
+
let open = defaults;
|
|
13
|
+
try {
|
|
14
|
+
const saved = key ? JSON.parse(sessionStorage.getItem(key) ?? 'null') : null;
|
|
15
|
+
if (Array.isArray(saved)) open = [...new Set(saved.filter((id): id is string => typeof id === 'string' && allowed.includes(id)))];
|
|
16
|
+
} catch { /* Restricted storage does not disable panel controls. */ }
|
|
17
|
+
setState({ key, open });
|
|
18
|
+
}, [key, signature]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (key && state.key === key) try { sessionStorage.setItem(key, JSON.stringify(state.open)); } catch { /* In-memory only. */ }
|
|
21
|
+
}, [key, state]);
|
|
22
|
+
const set = useCallback((id: string, open: boolean) => {
|
|
23
|
+
if (!allowed.includes(id)) return false;
|
|
24
|
+
setState(previous => {
|
|
25
|
+
const list = previous.key === key ? previous.open : defaults;
|
|
26
|
+
return { key, open: open ? [...new Set([...list, id])] : list.filter(item => item !== id) };
|
|
27
|
+
});
|
|
28
|
+
return true;
|
|
29
|
+
}, [key, signature]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
30
|
+
const show=useCallback((id:string)=>set(id,true),[set]);
|
|
31
|
+
const close=useCallback((id:string)=>set(id,false),[set]);
|
|
32
|
+
return { open: current, available: allowed, show, close };
|
|
33
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// 预览里文本文件(代码、日志、Markdown、CSV)的读取。
|
|
2
|
+
//
|
|
3
|
+
// 能读多大:一次最多 max 字节(预览里是 32 MB),更大的只看开头或末尾。
|
|
4
|
+
// 新协议以不可变修订和内容块进行同步;旧后端不能通过尾部重叠推断整份文件未变化。
|
|
5
|
+
|
|
6
|
+
import type { BoardBackend } from "../core/client";
|
|
7
|
+
import { decodeText } from "../core/textDecode";
|
|
8
|
+
import { syncFile } from "./fileSync";
|
|
9
|
+
|
|
10
|
+
/** 往前多取这么多字节,用来确认文件只是追加。 */
|
|
11
|
+
export const OVERLAP = 256;
|
|
12
|
+
/** 追加的段落多了合成一段(每段是 <pre> 里的一个文字节点)。 */
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export interface LoadedText {
|
|
16
|
+
text: string;
|
|
17
|
+
/** 分段的文字(追加的每次一段):<pre> 里按段渲染,追加时前面的不用重排。 */
|
|
18
|
+
parts: string[];
|
|
19
|
+
/** 文件比读到的大(只读了开头或末尾)。 */
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
/** 文件总大小。 */
|
|
22
|
+
size: number;
|
|
23
|
+
/** 读的是末尾。 */
|
|
24
|
+
tail: boolean;
|
|
25
|
+
encoding?: string;
|
|
26
|
+
revision?: 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
|
+
|
|
48
|
+
|
|
49
|
+
/** 第一次读:开头(或末尾)最多 max 字节。 */
|
|
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
|
+
}
|
|
57
|
+
if (!backend.readBytes) {
|
|
58
|
+
const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
|
|
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 };
|
|
60
|
+
}
|
|
61
|
+
const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
|
|
62
|
+
const { text, encoding } = decodeText(chunk.bytes, { fromMiddle: chunk.start > 0 });
|
|
63
|
+
const k = encoding === "utf-8" ? incompleteUtf8Tail(chunk.bytes) : 0;
|
|
64
|
+
const end = chunk.start + chunk.bytes.length - k;
|
|
65
|
+
const tailBytes = encoding === "utf-8" ? chunk.bytes.slice(Math.max(0, chunk.bytes.length - k - OVERLAP), chunk.bytes.length - k) : null;
|
|
66
|
+
return {
|
|
67
|
+
text, parts: [text], truncated: chunk.start + chunk.bytes.length < chunk.size || chunk.start > 0, size: chunk.size,
|
|
68
|
+
tail: chunk.start > 0, encoding, start: chunk.start, end, tailBytes,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
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;
|
|
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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 给业务方的工作区接口:不显示看板也能拿到任务产出的文件。
|
|
3
|
+
*
|
|
4
|
+
* const ws = store.workspace("job_xxx"); // 不传 = 当前选中的对话;Web Component:el.workspace()
|
|
5
|
+
* await ws.listAll(); // 工作区里所有文件(递归)
|
|
6
|
+
* await ws.readText("reports/报告.md"); // 读成文字
|
|
7
|
+
* await ws.readFile("reports/图.png"); // 读成 Blob(可以自己上传)
|
|
8
|
+
* await ws.zip("reports"); // 打成 ZIP 的 Blob
|
|
9
|
+
* await ws.download("reports/报告.md"); // 下载到用户本地;ws.downloadFolder("reports") 打包下载
|
|
10
|
+
* ws.openFile(); // 看板上正在看的文件(没有看板 / 没打开文件时为 null)
|
|
11
|
+
*
|
|
12
|
+
* 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
|
|
13
|
+
*/
|
|
14
|
+
import { retryWorkspaceFiles, subscribeWorkspaceFiles, type WorkspaceWatchOptions, type WorkspaceRefreshOptions } from "./fileWatch";
|
|
15
|
+
import type { BoardBackend } from "../core/client";
|
|
16
|
+
import type { BoardInfo, FileEntry, DirListing, WorkspaceChangeEvent } from "../core/types";
|
|
17
|
+
import { directFileUrl, downloadFile, mimeOf } from "./fileOps";
|
|
18
|
+
import { fmtSize } from "./fileKind";
|
|
19
|
+
import { syncFile, FILE_SYNC_MAX } from "./fileSync";
|
|
20
|
+
import { isOriginalDocument } from "../core/documentUpload";
|
|
21
|
+
import { loadText } from "./textLoad";
|
|
22
|
+
import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload";
|
|
23
|
+
|
|
24
|
+
/** readText 最多读这么大(更大的用 readFile)。 */
|
|
25
|
+
export const WORKSPACE_TEXT_MAX = 20 * 1024 * 1024;
|
|
26
|
+
/** readFile 最多读这么大进内存(更大的用 fileUrl / download,浏览器流式下载)。 */
|
|
27
|
+
export const WORKSPACE_FILE_MAX = 500 * 1024 * 1024;
|
|
28
|
+
/** listAll 最多列这么多个文件。 */
|
|
29
|
+
export const WORKSPACE_LIST_MAX = 10_000;
|
|
30
|
+
|
|
31
|
+
export interface BoardFile { path: string; sourceId?: string }
|
|
32
|
+
|
|
33
|
+
/** 看板现在的样子(看板组件报告给仓库;没有看板时没有)。 */
|
|
34
|
+
export interface BoardView {
|
|
35
|
+
/** 当前页签:files / changes / browser、"file:<路径>"(打开的文件)或自己加的页签 id。 */
|
|
36
|
+
tab: string;
|
|
37
|
+
/** 文件列表所在目录("" = 工作区根目录)。 */
|
|
38
|
+
dir: string;
|
|
39
|
+
/** 正在看的文件(当前页签是文件时),否则 null。 */
|
|
40
|
+
file: string | null;
|
|
41
|
+
/** 开着的文件页签。 */
|
|
42
|
+
files: string[];
|
|
43
|
+
/** 当前文件身份;切到目录/浏览器时为 null。sourceId 保留来源任务。 */
|
|
44
|
+
current?: BoardFile | null;
|
|
45
|
+
opened?: BoardFile[];
|
|
46
|
+
/** 最近真正访问过的文件(关闭页签后保留,最多40条)。 */
|
|
47
|
+
history?: BoardFile[];
|
|
48
|
+
browser?: { url: string; title: string } | null;
|
|
49
|
+
visible?: boolean;
|
|
50
|
+
autoContext?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CliWorkspace {
|
|
54
|
+
/** 这个工作区属于哪个对话(对话键)。 */
|
|
55
|
+
readonly jobId: string;
|
|
56
|
+
/** 看板概况:派发到节点没有、节点是否在线、有没有浏览器。 */
|
|
57
|
+
info(): Promise<BoardInfo>;
|
|
58
|
+
stat(path: string): ReturnType<BoardBackend["stat"]>;
|
|
59
|
+
/** 订阅文件变更。初始快照只建立基线;返回取消函数。 */
|
|
60
|
+
watch(listener: (event: WorkspaceChangeEvent) => void, options?: WorkspaceWatchOptions): () => void;
|
|
61
|
+
/** 请求一次增量检查。轮末回调用无参;用户明确重试可传 manual:true(仍遵守服务端冷却)。不创建连接或周期轮询。 */
|
|
62
|
+
refresh(options?: WorkspaceRefreshOptions): void;
|
|
63
|
+
/** 包括当前文件、打开页签、最近浏览和显示状态的背景快照。 */
|
|
64
|
+
context(): BoardView | null;
|
|
65
|
+
/** 按需读取单层目录的一页;nextCursor 非空时还有后续页。 */
|
|
66
|
+
listPage(dir?: string, opts?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
|
|
67
|
+
/** 显式列出一个目录(不递归,最多10000项);界面首屏推荐 listPage。 */
|
|
68
|
+
list(dir?: string): Promise<FileEntry[]>;
|
|
69
|
+
/** 列 dir 下所有文件(递归,只含文件)。 */
|
|
70
|
+
listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
|
|
71
|
+
/** 读成文字(GBK 导出的文件会自动转码)。 */
|
|
72
|
+
readText(path: string, opts?: { signal?: AbortSignal }): Promise<string>;
|
|
73
|
+
/** 读成 Blob。onProgress 报已收到的字节数;超过 maxBytes(缺省 500 MB)报错,改用 fileUrl / download。 */
|
|
74
|
+
readFile(path: string, opts?: { signal?: AbortSignal; onProgress?: (bytes: number) => void; maxBytes?: number }): Promise<Blob>;
|
|
75
|
+
/** 浏览器能直接打开 / 下载的地址(没有同源代理时是 30 分钟有效的短期链接);接入方式不支持时为 null,改用 readFile。 */
|
|
76
|
+
fileUrl(path: string, opts?: { download?: boolean }): Promise<string | null>;
|
|
77
|
+
/** 下载一个文件到用户本地。 */
|
|
78
|
+
download(path: string): Promise<void>;
|
|
79
|
+
/** 把目录打包成 ZIP 下载到用户本地("" = 整个工作区)。 */
|
|
80
|
+
downloadFolder(dir?: string, opts?: FolderOptions): Promise<FolderResult>;
|
|
81
|
+
/** 把目录打成 ZIP,返回 Blob,不下载。 */
|
|
82
|
+
zip(dir?: string, opts?: FolderOptions): Promise<FolderZip>;
|
|
83
|
+
/** 看板上正在看的文件;没有看板或没打开文件时为 null。 */
|
|
84
|
+
openFile(): string | null;
|
|
85
|
+
/** 看板上开着的所有文件页签。 */
|
|
86
|
+
openFiles(): string[];
|
|
87
|
+
/** 在看板里打开这个文件(没有看板接时返回 false)。 */
|
|
88
|
+
open(path: string): boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createWorkspace(deps: {
|
|
92
|
+
backend: BoardBackend | undefined;
|
|
93
|
+
key: string;
|
|
94
|
+
view: () => BoardView | undefined;
|
|
95
|
+
open: (path: string) => boolean;
|
|
96
|
+
context?: () => BoardView | null;
|
|
97
|
+
watch?: (listener:(event:WorkspaceChangeEvent)=>void,options?:WorkspaceWatchOptions)=>()=>void;
|
|
98
|
+
}): CliWorkspace {
|
|
99
|
+
const { key } = deps;
|
|
100
|
+
const backend = () => {
|
|
101
|
+
if (!deps.backend) throw new Error("当前后端不支持读取工作区文件(需要任务中心的看板接口)");
|
|
102
|
+
return deps.backend;
|
|
103
|
+
};
|
|
104
|
+
const clean = (path: string) => path.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
105
|
+
return {
|
|
106
|
+
jobId: key,
|
|
107
|
+
info: () => backend().info(key),
|
|
108
|
+
stat: path => backend().stat(key, clean(path) || "."),
|
|
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),
|
|
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
|
+
},
|
|
118
|
+
async list(dir = "") {
|
|
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;
|
|
131
|
+
},
|
|
132
|
+
async listAll(dir = "", opts = {}) {
|
|
133
|
+
const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
|
|
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 }));
|
|
135
|
+
},
|
|
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); }
|
|
154
|
+
},
|
|
155
|
+
async readFile(path, opts = {}) {
|
|
156
|
+
const p = clean(path);
|
|
157
|
+
const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
|
|
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
|
+
}
|
|
164
|
+
// 太大的读到一半就停:整个读进内存会把页面撑爆。
|
|
165
|
+
const ctl = new AbortController();
|
|
166
|
+
const stop = () => ctl.abort();
|
|
167
|
+
opts.signal?.addEventListener("abort", stop, { once: true });
|
|
168
|
+
if(opts.signal?.aborted)ctl.abort();
|
|
169
|
+
let tooBig = false;
|
|
170
|
+
let rejectAbort:(()=>void)|undefined;
|
|
171
|
+
try {
|
|
172
|
+
if(ctl.signal.aborted)throw new DOMException("已取消", "AbortError");
|
|
173
|
+
const aborted=new Promise<never>((_resolve,reject)=>{rejectAbort=()=>reject(new DOMException("已取消", "AbortError"));ctl.signal.addEventListener("abort",rejectAbort,{once:true});});
|
|
174
|
+
const buffer = await Promise.race([backend().readBuffer(key, p, {
|
|
175
|
+
signal: ctl.signal,
|
|
176
|
+
onBytes: (n) => { if(ctl.signal.aborted)return; if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
|
|
177
|
+
}),aborted]);
|
|
178
|
+
if(buffer.byteLength>max){tooBig=true;throw new Error("文件超过读取上限");}
|
|
179
|
+
if(ctl.signal.aborted)throw new DOMException("已取消", "AbortError");
|
|
180
|
+
return new Blob([buffer], { type: mimeOf(p) });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (tooBig) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download(浏览器流式下载,不占内存)`);
|
|
183
|
+
throw error;
|
|
184
|
+
} finally {
|
|
185
|
+
opts.signal?.removeEventListener("abort", stop);
|
|
186
|
+
if(rejectAbort)ctl.signal.removeEventListener("abort",rejectAbort);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
async fileUrl(path, opts = {}) {
|
|
190
|
+
const url = await directFileUrl(backend(), key, clean(path), opts.download);
|
|
191
|
+
return url && typeof location !== "undefined" ? new URL(url, location.href).href : url;
|
|
192
|
+
},
|
|
193
|
+
download: (path) => downloadFile(backend(), key, clean(path)),
|
|
194
|
+
downloadFolder: (dir = "", opts) => downloadFolder(backend(), key, clean(dir), opts),
|
|
195
|
+
zip: (dir = "", opts) => zipFolder(backend(), key, clean(dir), opts),
|
|
196
|
+
openFile: () => deps.view()?.file ?? null,
|
|
197
|
+
openFiles: () => [...(deps.view()?.files ?? [])],
|
|
198
|
+
open: (path) => deps.open(clean(path)),
|
|
199
|
+
};
|
|
200
|
+
}
|