@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,65 @@
|
|
|
1
|
+
import type { BoardFile, BoardView } from './workspace';
|
|
2
|
+
import { serializeBlock, splitMessage, VIEWING_NAME } from '../core/blocks';
|
|
3
|
+
export const BOARD_HISTORY_MAX = 40;
|
|
4
|
+
const identity=(file:BoardFile)=>JSON.stringify([file.sourceId??'',file.path]);
|
|
5
|
+
export function rememberBoardFile(history:readonly BoardFile[],file:BoardFile):BoardFile[]{
|
|
6
|
+
return [...history.filter(item=>identity(item)!==identity(file)),{...file}].slice(-BOARD_HISTORY_MAX);
|
|
7
|
+
}
|
|
8
|
+
export function cloneBoardView(view:BoardView):BoardView {
|
|
9
|
+
return {...view,files:[...view.files],current:view.current?{...view.current}:view.current,opened:view.opened?.map(file=>({...file})),history:view.history?.map(file=>({...file})),browser:view.browser?{title:view.browser.title,url:browserUrl(view.browser.url)}:view.browser};
|
|
10
|
+
}
|
|
11
|
+
function browserUrl(raw:string):string {
|
|
12
|
+
try{const url=new URL(raw);return url.protocol==='http:'||url.protocol==='https:'?`${url.origin}${url.pathname}`:url.protocol==='about:'?`${url.protocol}${url.pathname}`:url.protocol;}catch{return '';}
|
|
13
|
+
}
|
|
14
|
+
function describe(file:BoardFile,currentSourceId?:string){
|
|
15
|
+
return file.sourceId&&file.sourceId!==currentSourceId?{path:file.path,sourceTask:file.sourceId,note:'来源任务的工作区,非当前执行分支'}:{path:file.path};
|
|
16
|
+
}
|
|
17
|
+
/** Metadata only; do not silently read/upload file bodies, URLs with auth tokens or arbitrary DOM. */
|
|
18
|
+
export function formatBoardContext(view:BoardView,currentSourceId?:string):string {
|
|
19
|
+
if(view.autoContext===false)return '';
|
|
20
|
+
const current=view.current===undefined?(view.file?{path:view.file}:null):view.current;
|
|
21
|
+
const opened=view.opened??view.files.map(path=>({path}));
|
|
22
|
+
const history=view.history??[];
|
|
23
|
+
if(!current&&!opened.length&&!history.length&&!view.browser)return '';
|
|
24
|
+
const data={panelVisible:view.visible!==false,activeTab:view.tab,currentFile:current?describe(current,currentSourceId):null,
|
|
25
|
+
openFiles:opened.slice(-8).map(file=>describe(file,currentSourceId)),recentlyVisited:history.slice(-BOARD_HISTORY_MAX).map(file=>describe(file,currentSourceId)),
|
|
26
|
+
...(view.browser?{browser:{url:browserUrl(view.browser.url),title:view.browser.title}}:{})};
|
|
27
|
+
const text=JSON.stringify(data,null,2);
|
|
28
|
+
// Bound context without truncating a path into a different, plausible filename.
|
|
29
|
+
if(text.length>16000){data.recentlyVisited=[];const compact=JSON.stringify(data,null,2);if(compact.length>16000)return '看板浏览背景较大,本次未自动附带;请用户明确引用需要的文件。';return `用户工作区浏览背景(仅元信息;较早浏览记录已省略):\n${compact}`;}
|
|
30
|
+
return `用户工作区浏览背景(自动附带,仅元信息;最近访问不代表仍在查看;panelVisible=false 时 currentFile 是收起前选中的文件):\n${text}`;
|
|
31
|
+
}
|
|
32
|
+
export function appendBoardContext(text:string,view:BoardView|null,currentSourceId?:string):string {
|
|
33
|
+
const context=view?formatBoardContext(view,currentSourceId):'';
|
|
34
|
+
return context?[text,serializeBlock({name:VIEWING_NAME,kind:'viewing',text:context})].filter(Boolean).join('\n\n'):text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function withoutBoardContext(text:string):string {
|
|
38
|
+
return splitMessage(text).filter(part=>part.kind!=='block'||part.block.kind!=='viewing').map(part=>part.kind==='text'?part.text:serializeBlock(part.block)).join('\n\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Local selection changes may select an owner; shared history and remote page
|
|
42
|
+
* metadata updates must not steal the user's active board. */
|
|
43
|
+
const localBoardView=(view:BoardView)=>JSON.stringify({tab:view.tab,dir:view.dir,file:view.file,files:view.files,current:view.current,opened:view.opened,visible:view.visible!==false});
|
|
44
|
+
export function selectedBoardOwner(sources:Map<string,BoardView>):[string,BoardView]|undefined {
|
|
45
|
+
const entries=[...sources.entries()];return entries.reverse().find(([,view])=>view.visible!==false)??entries[0];
|
|
46
|
+
}
|
|
47
|
+
export function reportBoardOwner(sources:Map<string,BoardView>,owner:string,view:BoardView):[string,BoardView]|undefined {
|
|
48
|
+
const previous=sources.get(owner),active=selectedBoardOwner(sources);
|
|
49
|
+
if(previous){if(localBoardView(previous)!==localBoardView(view)){sources.delete(owner);sources.set(owner,view);}else sources.set(owner,view);}
|
|
50
|
+
else if(active&&active[1].visible!==false){const existing=[...sources];sources.clear();sources.set(owner,view);for(const [id,value]of existing)sources.set(id,value);}
|
|
51
|
+
else sources.set(owner,view);
|
|
52
|
+
return selectedBoardOwner(sources);
|
|
53
|
+
}
|
|
54
|
+
export function activateBoardOwner(sources:Map<string,BoardView>,owner:string):[string,BoardView]|undefined {
|
|
55
|
+
const view=sources.get(owner);if(view){sources.delete(owner);sources.set(owner,view);}return selectedBoardOwner(sources);
|
|
56
|
+
}
|
|
57
|
+
export function mergeBoardHistory(existing:readonly BoardFile[],previous:BoardView|undefined,next:BoardView):BoardFile[] {
|
|
58
|
+
const old=previous?.history??[],incoming=next.history??[];let history=[...existing];
|
|
59
|
+
const known=new Set(old.map(identity));
|
|
60
|
+
// Newly mounted boards may restore old history; only adopt previously unknown
|
|
61
|
+
// records so a late restore cannot reorder the live workspace's recent visits.
|
|
62
|
+
const additions=incoming.filter(file=>!known.has(identity(file))&&(!previous?!history.some(item=>identity(item)===identity(file)):true));
|
|
63
|
+
if(previous&&incoming.length&&identity(incoming[incoming.length-1])!==identity(old[old.length-1]??{path:''}))additions.push(incoming[incoming.length-1]);
|
|
64
|
+
for(const file of additions)history=rememberBoardFile(history,file);return history;
|
|
65
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// diff 正文的分行与着色:中枢看板的「改动」(ChangesPane)与节点的 TaskChanges 共用。
|
|
2
|
+
//
|
|
3
|
+
// 只按位置判断:文件头(`diff --git ` 到下一个 `@@` 之间的 index / --- / +++ / mode / rename…)不着色;
|
|
4
|
+
// hunk 里由第一个字符决定:`+` 新增、`-` 删除、空格是上下文、`\` 是「No newline at end of file」。
|
|
5
|
+
// 不能用「以 +++ / --- 开头就不是改动」来认文件头:被删掉的 `-- 注释` 显示为 `--- 注释`,
|
|
6
|
+
// Markdown 的分隔线 `---` 被增删时是 `+---` / `----`,新增的 `++x` 显示为 `+++x`——它们都是真实改动。
|
|
7
|
+
|
|
8
|
+
export type DiffLineKind = "hunk" | "add" | "del" | "";
|
|
9
|
+
|
|
10
|
+
/** 去掉开头的文件头,从第一个 `@@` 起;没有 hunk(二进制、只改权限 / 重命名)时返回空。 */
|
|
11
|
+
export function hunkLines(patch: string): string[] {
|
|
12
|
+
const lines = patch.split("\n");
|
|
13
|
+
const i = lines.findIndex((l) => l.startsWith("@@"));
|
|
14
|
+
return i >= 0 ? lines.slice(i) : [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 每一行的种类。同一个文件的 patch 里可能再出现一段文件头(类型变化:一段删除 + 一段新增,
|
|
19
|
+
* 节点把两段拼在一起),那段文件头同样不着色。
|
|
20
|
+
*/
|
|
21
|
+
export function diffLineKinds(lines: string[]): DiffLineKind[] {
|
|
22
|
+
let header = false;
|
|
23
|
+
return lines.map((line) => {
|
|
24
|
+
if (line.startsWith("diff --git ")) {
|
|
25
|
+
header = true;
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
if (line.startsWith("@@")) {
|
|
29
|
+
header = false;
|
|
30
|
+
return "hunk";
|
|
31
|
+
}
|
|
32
|
+
if (header) return "";
|
|
33
|
+
return line[0] === "+" ? "add" : line[0] === "-" ? "del" : "";
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 一个文件 patch 里新增、删除的行数(只给了整段 unified diff 的节点没有统计,自己数)。 */
|
|
38
|
+
export function countChanges(patch: string): { additions: number; deletions: number } {
|
|
39
|
+
let additions = 0;
|
|
40
|
+
let deletions = 0;
|
|
41
|
+
for (const kind of diffLineKinds(hunkLines(patch))) {
|
|
42
|
+
if (kind === "add") additions++;
|
|
43
|
+
else if (kind === "del") deletions++;
|
|
44
|
+
}
|
|
45
|
+
return { additions, deletions };
|
|
46
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 看板按扩展名决定怎么预览一个文件。
|
|
2
|
+
export type PreviewKind = "markdown" | "code" | "text" | "csv" | "image" | "pdf" | "html" | "docx" | "binary";
|
|
3
|
+
|
|
4
|
+
const CODE_LANG: Record<string, string> = {
|
|
5
|
+
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
6
|
+
json: "json", py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
|
|
7
|
+
c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp", php: "php", sh: "bash", bash: "bash", zsh: "bash",
|
|
8
|
+
sql: "sql", yaml: "yaml", yml: "yaml", toml: "ini", ini: "ini", xml: "xml", css: "css", scss: "scss",
|
|
9
|
+
vue: "xml", svelte: "xml", dockerfile: "dockerfile", makefile: "makefile", diff: "diff", patch: "diff",
|
|
10
|
+
};
|
|
11
|
+
const TEXT_EXT = new Set(["txt", "log", "env", "gitignore", "conf", "cfg", "properties", "lock", "tsv"]);
|
|
12
|
+
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg", "avif"]);
|
|
13
|
+
|
|
14
|
+
export function extOf(path: string): string {
|
|
15
|
+
const name = path.split("/").pop() ?? "";
|
|
16
|
+
const lower = name.toLowerCase();
|
|
17
|
+
if (lower === "dockerfile" || lower === "makefile") return lower;
|
|
18
|
+
const dot = lower.lastIndexOf(".");
|
|
19
|
+
return dot > 0 ? lower.slice(dot + 1) : "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function previewKind(path: string, contentType = ""): PreviewKind {
|
|
23
|
+
const ext = extOf(path);
|
|
24
|
+
if (ext === "md" || ext === "markdown") return "markdown";
|
|
25
|
+
if (ext === "csv") return "csv";
|
|
26
|
+
if (IMAGE_EXT.has(ext)) return "image";
|
|
27
|
+
if (ext === "pdf") return "pdf";
|
|
28
|
+
if (ext === "html" || ext === "htm") return "html";
|
|
29
|
+
if (ext === "docx") return "docx";
|
|
30
|
+
if (CODE_LANG[ext]) return "code";
|
|
31
|
+
if (TEXT_EXT.has(ext)) return "text";
|
|
32
|
+
if (/^text\//.test(contentType) || /json|xml|javascript/.test(contentType)) return "text";
|
|
33
|
+
if (!ext) return "text"; // README、LICENSE 之类
|
|
34
|
+
return "binary";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const codeLang = (path: string) => CODE_LANG[extOf(path)] ?? "";
|
|
38
|
+
|
|
39
|
+
export function fmtSize(bytes: number): string {
|
|
40
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
41
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`;
|
|
42
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 相对某个文件所在目录解析链接(Markdown 里的 ./img.png、../a.md)。 */
|
|
46
|
+
export function resolveRelative(fromFile: string, href: string): string {
|
|
47
|
+
if (href.startsWith("/") || href.startsWith("~")) return href;
|
|
48
|
+
const base = fromFile.split("/").slice(0, -1);
|
|
49
|
+
for (const seg of href.split("/")) {
|
|
50
|
+
if (!seg || seg === ".") continue;
|
|
51
|
+
if (seg === "..") base.pop();
|
|
52
|
+
else base.push(seg);
|
|
53
|
+
}
|
|
54
|
+
return base.join("/");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 一行 CSV(支持引号里的逗号和 "" 转义)。 */
|
|
58
|
+
export function parseCsv(text: string, maxRows = 500): { rows: string[][]; more: boolean } {
|
|
59
|
+
const rows: string[][] = [];
|
|
60
|
+
let row: string[] = [];
|
|
61
|
+
let cell = "";
|
|
62
|
+
let quoted = false;
|
|
63
|
+
const sep = text.includes("\t") && !text.split("\n")[0].includes(",") ? "\t" : ",";
|
|
64
|
+
for (let i = 0; i < text.length; i++) {
|
|
65
|
+
const ch = text[i];
|
|
66
|
+
if (quoted) {
|
|
67
|
+
if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++; }
|
|
68
|
+
else if (ch === '"') quoted = false;
|
|
69
|
+
else cell += ch;
|
|
70
|
+
} else if (ch === '"' && cell === "") quoted = true;
|
|
71
|
+
else if (ch === sep) { row.push(cell); cell = ""; }
|
|
72
|
+
else if (ch === "\n" || ch === "\r") {
|
|
73
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
74
|
+
row.push(cell); cell = "";
|
|
75
|
+
rows.push(row); row = [];
|
|
76
|
+
if (rows.length > maxRows) return { rows: rows.slice(0, maxRows), more: true };
|
|
77
|
+
} else cell += ch;
|
|
78
|
+
}
|
|
79
|
+
if (cell || row.length) { row.push(cell); rows.push(row); }
|
|
80
|
+
return { rows: rows.slice(0, maxRows), more: rows.length > maxRows };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 大 Markdown 分段(每段约 size 字):在代码块外的空行处切,各段单独渲染(整篇几 MB 一次渲染会卡住页面几十秒)。
|
|
85
|
+
* 一直没有空行的超长内容到 4 倍大小也切(在代码块里切时补上收尾、下一段重新开头)。
|
|
86
|
+
*/
|
|
87
|
+
export function splitMarkdown(text: string, size: number): string[] {
|
|
88
|
+
if (text.length <= size) return [text];
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
const lines = text.split("\n");
|
|
91
|
+
let cur: string[] = [];
|
|
92
|
+
let len = 0;
|
|
93
|
+
let fence: string | null = null;
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
96
|
+
if (m) {
|
|
97
|
+
if (!fence) fence = line.trim();
|
|
98
|
+
else if (line.trim().startsWith(fence.slice(0, 3)) && line.trim().replace(/[`~]/g, "") === "") fence = null;
|
|
99
|
+
}
|
|
100
|
+
cur.push(line);
|
|
101
|
+
len += line.length + 1;
|
|
102
|
+
const atBreak = !fence && line.trim() === "" && len >= size;
|
|
103
|
+
if (atBreak || len >= size * 4) {
|
|
104
|
+
if (fence) cur.push(fence.replace(/[^`~].*$/, ""));
|
|
105
|
+
out.push(cur.join("\n"));
|
|
106
|
+
cur = fence ? [fence] : [];
|
|
107
|
+
len = 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (cur.length && cur.some((l) => l !== "")) out.push(cur.join("\n"));
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface MarkdownPart {
|
|
115
|
+
/** Exact source slice; offsets use JavaScript string positions, not UTF-8 bytes. */
|
|
116
|
+
text: string;
|
|
117
|
+
plain: boolean;
|
|
118
|
+
start: number;
|
|
119
|
+
end: number;
|
|
120
|
+
/** Ordinary link definitions for the renderer, never inserted into source text. */
|
|
121
|
+
definitions?: string;
|
|
122
|
+
/** Preview-only formatting; source slices and offsets always remain exact. */
|
|
123
|
+
code?: { start: number; end: number; language: string };
|
|
124
|
+
prefix?: string;
|
|
125
|
+
simplified?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Bound each render tree without inventing closing fences or splitting a giant structure into misleading Markdown. */
|
|
129
|
+
export function splitMarkdownParts(text: string, size: number, options: { preview?: boolean } = {}): MarkdownPart[] {
|
|
130
|
+
if (!Number.isSafeInteger(size) || size < 2) throw new RangeError("Markdown分段大小必须至少为2");
|
|
131
|
+
const parts: MarkdownPart[] = [], definitions = new Map<string, string>();
|
|
132
|
+
let pendingStart = 0, pendingEnd = 0, pendingCost = 0, blockStart = 0, cursor = 0, fence: { char: string; length: number; body: number; language: string } | null = null;
|
|
133
|
+
let definitionSize = 0, tooManyDefinitions = false;
|
|
134
|
+
const push = (start: number, end: number, plain: boolean, extra: Partial<MarkdownPart> = {}) => { if (end > start) parts.push({ text: text.slice(start, end), plain, start, end, ...extra }); };
|
|
135
|
+
const flush = () => { push(pendingStart, pendingEnd, false); pendingStart = pendingEnd; pendingCost = 0; };
|
|
136
|
+
const boundedEnd = (start: number, end: number) => {
|
|
137
|
+
let next = Math.min(end, start + size);
|
|
138
|
+
const previous = text.charCodeAt(next - 1), following = text.charCodeAt(next);
|
|
139
|
+
if (next < end && previous >= 0xd800 && previous <= 0xdbff && following >= 0xdc00 && following <= 0xdfff) next--;
|
|
140
|
+
return next;
|
|
141
|
+
};
|
|
142
|
+
const block = (start: number, end: number, code?: MarkdownPart["code"]) => {
|
|
143
|
+
if (end <= start) return;
|
|
144
|
+
if (options.preview && code) {
|
|
145
|
+
flush();
|
|
146
|
+
while (start < end) {
|
|
147
|
+
let next = boundedEnd(start, end);
|
|
148
|
+
const newline = text.lastIndexOf("\n", next - 1);
|
|
149
|
+
if (next < end && newline >= start && newline + 1 > start + size / 2) next = newline + 1;
|
|
150
|
+
push(start, next, false, { code: { ...code, start: Math.min(next, Math.max(start, code.start)), end: Math.max(start, Math.min(next, code.end)) } }); start = next;
|
|
151
|
+
}
|
|
152
|
+
pendingStart = pendingEnd = end; return;
|
|
153
|
+
}
|
|
154
|
+
if (options.preview) {
|
|
155
|
+
const firstEnd = text.indexOf("\n", start), secondEnd = firstEnd < 0 ? -1 : text.indexOf("\n", firstEnd + 1);
|
|
156
|
+
const headerEnd = secondEnd < 0 ? end : secondEnd + 1;
|
|
157
|
+
const delimiter = firstEnd >= start && firstEnd < end ? text.slice(firstEnd + 1, Math.min(headerEnd, end)).trim() : "";
|
|
158
|
+
// GFM tables are bounded by rows as well as characters: short cells can
|
|
159
|
+
// otherwise produce thousands of elements from a small source slice.
|
|
160
|
+
if (firstEnd >= start && firstEnd < end && headerEnd <= end && headerEnd - start <= Math.min(size / 2, 4096)
|
|
161
|
+
&& /^\|?\s*:?-{1,}:?\s*(?:\|\s*:?-{1,}:?\s*)+\|?$/.test(delimiter)) {
|
|
162
|
+
const columns = delimiter.replace(/^\|/, "").replace(/\|$/, "").split("|").length;
|
|
163
|
+
if (columns <= 32) {
|
|
164
|
+
flush(); const header = text.slice(start, headerEnd); let at = start;
|
|
165
|
+
while (at < end) {
|
|
166
|
+
const first = at === start, bodyStart = first ? headerEnd : at, budget = size - (first ? 0 : header.length);
|
|
167
|
+
let next = bodyStart, rows = 0;
|
|
168
|
+
while (next < end && (rows + 2) * columns <= 384) {
|
|
169
|
+
const newline = text.indexOf("\n", next), rowEnd = newline < 0 ? end : Math.min(end, newline + 1);
|
|
170
|
+
if (rowEnd - at > budget) break;
|
|
171
|
+
next = rowEnd; rows++;
|
|
172
|
+
}
|
|
173
|
+
if (next > at && (first || rows)) push(at, next, false, first ? {} : { prefix: header });
|
|
174
|
+
else {
|
|
175
|
+
const newline = text.indexOf("\n", at), rowEnd = newline < 0 ? end : Math.min(end, newline + 1);
|
|
176
|
+
while (at < rowEnd) { next = boundedEnd(at, rowEnd); push(at, next, true, { simplified: "此表格行较长,以原文显示;文件内容保持完整。" }); at = next; }
|
|
177
|
+
}
|
|
178
|
+
at = next;
|
|
179
|
+
}
|
|
180
|
+
pendingStart = pendingEnd = end; return;
|
|
181
|
+
}
|
|
182
|
+
flush();
|
|
183
|
+
while (start < end) { const next = boundedEnd(start, end); push(start, next, true, { simplified: "此表格列数较多,以原文分段显示;文件内容保持完整。" }); start = next; }
|
|
184
|
+
pendingStart = pendingEnd = end; return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (end - start <= size) {
|
|
188
|
+
const source = options.preview ? text.slice(start, end) : "";
|
|
189
|
+
const cost = options.preview ? (source.match(/[|*_\[\]<>`~]/g)?.length ?? 0) : 0;
|
|
190
|
+
if (options.preview && (cost > 2000 || /\[\^[^\]\r\n]+\]/.test(source))) {
|
|
191
|
+
flush(); push(start, end, true, { simplified: cost > 2000 ? "此段格式较复杂,以原文显示;文件内容保持完整。" : "跨段脚注以原文显示,避免遗漏注释内容。" }); pendingStart = pendingEnd = end; return;
|
|
192
|
+
}
|
|
193
|
+
if (pendingEnd - pendingStart + end - start > size || pendingCost + cost > 2000) flush();
|
|
194
|
+
if (pendingEnd === pendingStart) pendingStart = start;
|
|
195
|
+
pendingEnd = end; pendingCost += cost;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
flush();
|
|
199
|
+
while (start < end) {
|
|
200
|
+
const next = boundedEnd(start, end);
|
|
201
|
+
push(start, next, true, options.preview ? { simplified: "此段内容较长,以原文分段显示;文件内容保持完整。" } : {}); start = next;
|
|
202
|
+
}
|
|
203
|
+
pendingStart = pendingEnd = end;
|
|
204
|
+
};
|
|
205
|
+
while (cursor < text.length) {
|
|
206
|
+
const newline = text.indexOf("\n", cursor), lineEnd = newline < 0 ? text.length : newline, next = newline < 0 ? text.length : newline + 1;
|
|
207
|
+
// Never pass a 100MiB single line into regex/trim/Markdown parsing.
|
|
208
|
+
const bounded = lineEnd - cursor <= size;
|
|
209
|
+
const line = text.slice(cursor, bounded ? lineEnd : Math.min(lineEnd, cursor + 512));
|
|
210
|
+
const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
211
|
+
if (fence) {
|
|
212
|
+
const closing = /^ {0,3}(`{3,}|~{3,})[ \t\r]*$/.exec(line);
|
|
213
|
+
if (bounded && closing && closing[1][0] === fence.char && closing[1].length >= fence.length) {
|
|
214
|
+
block(blockStart, next, { start: fence.body, end: cursor, language: fence.language }); fence = null; blockStart = next;
|
|
215
|
+
}
|
|
216
|
+
} else if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
|
|
217
|
+
block(blockStart, cursor); blockStart = cursor; fence = { char: opening[1][0], length: opening[1].length, body: next, language: opening[2].trim().split(/\s/)[0].slice(0, 80) };
|
|
218
|
+
} else {
|
|
219
|
+
if (bounded) {
|
|
220
|
+
const reference = /^ {0,3}\[(?!\^)([^\]\r\n]+)\]:[ \t]*(?:<[^>\r\n]+>|[^\s\r\n]+)(?:[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^\)\r\n]*\)))?[ \t\r]*$/.exec(line);
|
|
221
|
+
if (reference) {
|
|
222
|
+
const label = reference[1].trim().replace(/\s+/g, " ").toLowerCase();
|
|
223
|
+
if (!definitions.has(label)) {
|
|
224
|
+
definitionSize += line.length + 1;
|
|
225
|
+
if (definitionSize <= Math.min(size, 256 * 1024)) definitions.set(label, line); else tooManyDefinitions = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (/^[ \t\r]*$/.test(line)) { block(blockStart, next); blockStart = next; }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
cursor = next;
|
|
232
|
+
}
|
|
233
|
+
block(blockStart, text.length, fence ? { start: fence.body, end: text.length, language: fence.language } : undefined); flush();
|
|
234
|
+
if (!parts.length) parts.push({ text, plain: false, start: 0, end: text.length });
|
|
235
|
+
const context = [...definitions.values()].join("\n");
|
|
236
|
+
for (const part of parts) {
|
|
237
|
+
if (options.preview) {
|
|
238
|
+
if (part.plain || part.code) continue;
|
|
239
|
+
const used = new Set<string>(); let length = 0;
|
|
240
|
+
for (const match of part.text.matchAll(/\[([^\]\r\n]+)\](?:\[([^\]\r\n]*)\])?/g)) {
|
|
241
|
+
const label = (match[2] || match[1]).trim().replace(/\s+/g, " ").toLowerCase(), definition = definitions.get(label);
|
|
242
|
+
if (definition && !used.has(definition)) { used.add(definition); length += definition.length + 1; }
|
|
243
|
+
}
|
|
244
|
+
if (length > 4096 || (tooManyDefinitions && part.text.includes("["))) { part.plain = true; part.simplified = "引用定义较多,以原文显示,避免显示不完整的链接。"; }
|
|
245
|
+
else if (used.size) part.definitions = [...used].join("\n");
|
|
246
|
+
} else if (tooManyDefinitions) part.plain = true;
|
|
247
|
+
else if (!part.plain && context) part.definitions = context;
|
|
248
|
+
}
|
|
249
|
+
return parts;
|
|
250
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// 工作区文件的基础操作(不依赖界面):可直接打开的地址、下载到本地、读成 Blob。
|
|
2
|
+
// 看板、文件预览和给业务方的工作区接口(workspace.ts)共用。
|
|
3
|
+
import { fileUrlFromBase, type BoardBackend } from "../core/client";
|
|
4
|
+
import { extOf, fmtSize } from "./fileKind";
|
|
5
|
+
import { syncFile } from "./fileSync";
|
|
6
|
+
import { isOriginalDocument, isTaskUploadPath } from "../core/documentUpload";
|
|
7
|
+
|
|
8
|
+
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不在浏览器里拼下载。 */
|
|
9
|
+
const BLOB_DOWNLOAD_MAX = 300 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export const MIME: Record<string, string> = {
|
|
12
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
13
|
+
bmp: "image/bmp", ico: "image/x-icon", avif: "image/avif", pdf: "application/pdf", html: "text/html", htm: "text/html",
|
|
14
|
+
md: "text/markdown", markdown: "text/markdown", txt: "text/plain", csv: "text/csv", json: "application/json", zip: "application/zip",
|
|
15
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
16
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
17
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
18
|
+
};
|
|
19
|
+
export const mimeOf = (path: string) => MIME[extOf(path)] ?? "application/octet-stream";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 浏览器能直接打开的地址(不需要请求头):同源代理时就是文件接口;没有代理注入令牌时用中枢发的短期链接。
|
|
23
|
+
* 中枢太旧发不了短期链接时返回 null(调用方改用读成 Blob)。
|
|
24
|
+
*/
|
|
25
|
+
export async function directFileUrl(backend: BoardBackend, jobId: string, path: string, download = false): Promise<string | null> {
|
|
26
|
+
if (backend.directLinks) return backend.fileUrl(jobId, path, { download });
|
|
27
|
+
const linkBase = backend.fileLinkBase ? await backend.fileLinkBase(jobId) : null;
|
|
28
|
+
return linkBase ? fileUrlFromBase(linkBase, path, download) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class FileReadLimitError extends Error {
|
|
32
|
+
constructor(maxBytes:number){super(`文件超过当前读取上限(${fmtSize(maxBytes)}),请使用直接下载链接`);this.name="FileReadLimitError";}
|
|
33
|
+
}
|
|
34
|
+
/** Bound bytes even with stale metadata; cancellation must settle callers when a custom backend ignores AbortSignal. */
|
|
35
|
+
export async function readBoundedBuffer(backend:BoardBackend,jobId:string,path:string,maxBytes:number,options:{signal?:AbortSignal;onBytes?:(bytes:number)=>void}={}):Promise<ArrayBuffer> {
|
|
36
|
+
const ctl=new AbortController();let limit:FileReadLimitError|undefined;
|
|
37
|
+
const abort=()=>ctl.abort();options.signal?.addEventListener("abort",abort,{once:true});
|
|
38
|
+
if(options.signal?.aborted)ctl.abort();
|
|
39
|
+
let rejectAbort:(()=>void)|undefined;
|
|
40
|
+
try{
|
|
41
|
+
if(ctl.signal.aborted)throw new DOMException("已取消","AbortError");
|
|
42
|
+
const cancelled=new Promise<never>((_resolve,reject)=>{rejectAbort=()=>reject(limit??new DOMException("已取消","AbortError"));ctl.signal.addEventListener("abort",rejectAbort,{once:true});});
|
|
43
|
+
const result=await Promise.race([backend.readBuffer(jobId,path,{signal:ctl.signal,onBytes:bytes=>{
|
|
44
|
+
if(ctl.signal.aborted)return;
|
|
45
|
+
if(bytes>maxBytes){limit=new FileReadLimitError(maxBytes);ctl.abort();return;}
|
|
46
|
+
options.onBytes?.(bytes);
|
|
47
|
+
}}),cancelled]);
|
|
48
|
+
if(ctl.signal.aborted)throw limit??new DOMException("已取消","AbortError");
|
|
49
|
+
if(result.byteLength>maxBytes)throw new FileReadLimitError(maxBytes);
|
|
50
|
+
return result;
|
|
51
|
+
}finally{options.signal?.removeEventListener("abort",abort);if(rejectAbort)ctl.signal.removeEventListener("abort",rejectAbort);ctl.abort();}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 把 Blob 存到本地(浏览器下载)。 */
|
|
55
|
+
export function saveBlob(blob: Blob, name: string) {
|
|
56
|
+
const url = URL.createObjectURL(blob);
|
|
57
|
+
clickDownload(url, name);
|
|
58
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function clickDownload(href: string, name: string) {
|
|
62
|
+
const a = document.createElement("a");
|
|
63
|
+
a.href = href;
|
|
64
|
+
a.download = name;
|
|
65
|
+
document.body.appendChild(a);
|
|
66
|
+
a.click();
|
|
67
|
+
a.remove();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 下载:用浏览器能直接打开的地址(流式、可断点续传,不占内存);同源代理以外的接入向中枢换短期链接。
|
|
72
|
+
* 只有中枢太旧时才 fetch 成 blob 再存(太大的拒绝,免得撑爆浏览器内存)。
|
|
73
|
+
*/
|
|
74
|
+
export async function downloadFile(backend: BoardBackend, jobId: string, path: string, size?: number) {
|
|
75
|
+
let name = path.split("/").pop() || "file";
|
|
76
|
+
if (backend.fileSync && (isOriginalDocument(path) || isTaskUploadPath(path))) {
|
|
77
|
+
const info = await backend.stat(jobId, path); if (info.type === "file" && info.name) name = info.name;
|
|
78
|
+
const result = await syncFile(backend.fileSync, jobId, path, { decode: false });
|
|
79
|
+
saveBlob(new Blob([result.bytes], { type: mimeOf(path) }), name); return;
|
|
80
|
+
}
|
|
81
|
+
const url = await directFileUrl(backend, jobId, path, true);
|
|
82
|
+
if (url) { clickDownload(url, name); return; }
|
|
83
|
+
if (size !== undefined && size > BLOB_DOWNLOAD_MAX) throw new Error(`文件较大(${fmtSize(size)}),当前接入方式不能在浏览器里下载,请升级任务中心`);
|
|
84
|
+
saveBlob(new Blob([await readBoundedBuffer(backend, jobId, path, BLOB_DOWNLOAD_MAX)]), name);
|
|
85
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { BoardBackend } from "../core/client";
|
|
2
|
+
|
|
3
|
+
type Scope = { paths: string[]; dirs: string[]; onDenied?: (message?: string) => void };
|
|
4
|
+
const scopes = new WeakMap<BoardBackend, Map<string, Map<object, Scope>>>();
|
|
5
|
+
const listeners = new WeakMap<BoardBackend, Map<string, Set<() => void>>>();
|
|
6
|
+
/** A preview/list owns its subscription. Unmounted/closed directories are not polled. */
|
|
7
|
+
export function retainFileScope(backend: BoardBackend, jobId: string, scope: Scope): () => void {
|
|
8
|
+
let jobs = scopes.get(backend); if (!jobs) { jobs = new Map(); scopes.set(backend, jobs); }
|
|
9
|
+
let owners = jobs.get(jobId); if (!owners) { owners = new Map(); jobs.set(jobId, owners); }
|
|
10
|
+
const owner = {}; owners.set(owner, scope);
|
|
11
|
+
const notify = () => listeners.get(backend)?.get(jobId)?.forEach(fn => fn());
|
|
12
|
+
notify();
|
|
13
|
+
let active = true;
|
|
14
|
+
return () => { if (!active) return; active = false; owners!.delete(owner); if (!owners!.size && jobs!.get(jobId) === owners) jobs!.delete(jobId); notify(); };
|
|
15
|
+
}
|
|
16
|
+
export function readFileScope(backend: BoardBackend, jobId: string): Scope {
|
|
17
|
+
const active = [...(scopes.get(backend)?.get(jobId)?.values() ?? [])];
|
|
18
|
+
return { paths: [...new Set(active.flatMap(scope => scope.paths))].sort(), dirs: [...new Set(active.flatMap(scope => scope.dirs))].sort() };
|
|
19
|
+
}
|
|
20
|
+
export function denyFileScope(backend: BoardBackend, jobId: string, message?: string, denied?: { paths: readonly string[]; dirs: readonly string[] }) {
|
|
21
|
+
for (const scope of scopes.get(backend)?.get(jobId)?.values() ?? []) {
|
|
22
|
+
// A list owns its directory; a rejected child file must not erase that list.
|
|
23
|
+
if (!denied || (scope.dirs.length ? scope.dirs.some(dir => denied.dirs.includes(dir || ".")) : scope.paths.some(path => denied.paths.includes(path)))) scope.onDenied?.(message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function onFileScope(backend: BoardBackend, jobId: string, listener: () => void) {
|
|
27
|
+
let jobs = listeners.get(backend); if (!jobs) { jobs = new Map(); listeners.set(backend, jobs); }
|
|
28
|
+
let set = jobs.get(jobId); if (!set) { set = new Set(); jobs.set(jobId, set); }
|
|
29
|
+
set.add(listener);
|
|
30
|
+
let active = true;
|
|
31
|
+
return () => { if (!active) return; active = false; set!.delete(listener); if (!set!.size && jobs!.get(jobId) === set) jobs!.delete(jobId); };
|
|
32
|
+
}
|