@spzxg/cli-ui 0.6.2 → 0.6.6

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.
Files changed (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +165 -19
  3. package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
  4. package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
  7. package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +17 -10
  11. package/src/board/BrowserPane.tsx +346 -115
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +54 -31
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +94 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/frameQuote.ts +2 -2
  23. package/src/board/panels.ts +33 -0
  24. package/src/board/textLoad.ts +9 -7
  25. package/src/board/workspace.ts +53 -14
  26. package/src/chat/CliChat.tsx +93 -40
  27. package/src/chat/Composer.tsx +44 -42
  28. package/src/chat/RunControls.tsx +99 -0
  29. package/src/chat/configuration.ts +15 -0
  30. package/src/chat/draft.ts +41 -0
  31. package/src/chat/export.ts +2 -2
  32. package/src/components.tsx +13 -0
  33. package/src/core/blocks.ts +17 -6
  34. package/src/core/callbacks.ts +8 -0
  35. package/src/core/client.ts +103 -19
  36. package/src/core/conversation.ts +4 -3
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +31 -14
  40. package/src/core/store.ts +151 -53
  41. package/src/core/types.ts +64 -9
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/layout/split.ts +67 -30
  47. package/src/msglist/CliMsgList.tsx +19 -13
  48. package/src/operator/branchFlow.ts +35 -0
  49. package/src/operator/index.ts +9 -0
  50. package/src/operator/liveStream.ts +1 -0
  51. package/src/operator/node/AgentConnect.tsx +624 -0
  52. package/src/operator/node/AppearancePanel.tsx +35 -0
  53. package/src/operator/node/BranchHistory.tsx +67 -0
  54. package/src/operator/node/BranchNavigator.tsx +36 -0
  55. package/src/operator/node/BrowserPane.tsx +128 -0
  56. package/src/operator/node/BrowserPopup.tsx +22 -0
  57. package/src/operator/node/Chat.tsx +517 -0
  58. package/src/operator/node/CommandPalette.tsx +213 -0
  59. package/src/operator/node/Composer.tsx +287 -0
  60. package/src/operator/node/InsightsView.tsx +831 -0
  61. package/src/operator/node/Layout.tsx +190 -0
  62. package/src/operator/node/Markdown.tsx +61 -0
  63. package/src/operator/node/Modal.tsx +179 -0
  64. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  65. package/src/operator/node/OnboardingWizard.tsx +250 -0
  66. package/src/operator/node/Orchestrator.tsx +634 -0
  67. package/src/operator/node/ProjectLanding.tsx +86 -0
  68. package/src/operator/node/ProjectsColumn.tsx +137 -0
  69. package/src/operator/node/ProviderAuth.tsx +561 -0
  70. package/src/operator/node/RunControls.tsx +2 -0
  71. package/src/operator/node/RunSwitch.tsx +113 -0
  72. package/src/operator/node/Services.tsx +245 -0
  73. package/src/operator/node/SessionRail.tsx +260 -0
  74. package/src/operator/node/SessionView.tsx +288 -0
  75. package/src/operator/node/SettingsView.tsx +690 -0
  76. package/src/operator/node/TaskBoard.tsx +352 -0
  77. package/src/operator/node/TaskChanges.tsx +530 -0
  78. package/src/operator/node/TaskHero.tsx +50 -0
  79. package/src/operator/node/TasksColumn.tsx +200 -0
  80. package/src/operator/node/Terminal.tsx +143 -0
  81. package/src/operator/node/Transcript.tsx +468 -0
  82. package/src/operator/node/ViewFile.tsx +79 -0
  83. package/src/operator/node/Welcome.tsx +101 -0
  84. package/src/operator/node/WorktreePrune.tsx +333 -0
  85. package/src/operator/node/agents.ts +58 -0
  86. package/src/operator/node/api.ts +40 -0
  87. package/src/operator/node/boardBackend.ts +82 -0
  88. package/src/operator/node/boardEvents.ts +22 -0
  89. package/src/operator/node/boardInteraction.ts +15 -0
  90. package/src/operator/node/boardState.ts +57 -0
  91. package/src/operator/node/boardWorkspace.ts +12 -0
  92. package/src/operator/node/branchFlow.ts +1 -0
  93. package/src/operator/node/composerDraft.ts +52 -0
  94. package/src/operator/node/fileLinks.ts +81 -0
  95. package/src/operator/node/format.ts +290 -0
  96. package/src/operator/node/github.tsx +234 -0
  97. package/src/operator/node/icons.tsx +88 -0
  98. package/src/operator/node/liveStream.ts +270 -0
  99. package/src/operator/node/modals.tsx +720 -0
  100. package/src/operator/node/navHistory.ts +70 -0
  101. package/src/operator/node/persist.ts +26 -0
  102. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  103. package/src/operator/node/protocol/agents/types.ts +394 -0
  104. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  105. package/src/operator/node/protocol/authFailure.ts +66 -0
  106. package/src/operator/node/protocol/customModels.ts +23 -0
  107. package/src/operator/node/protocol/events.ts +13 -0
  108. package/src/operator/node/protocol/features.ts +70 -0
  109. package/src/operator/node/protocol/internalUsage.ts +1 -0
  110. package/src/operator/node/protocol/promptLimits.ts +52 -0
  111. package/src/operator/node/protocol/storageRetention.ts +13 -0
  112. package/src/operator/node/protocol/types.ts +332 -0
  113. package/src/operator/node/protocol/usageLimit.ts +72 -0
  114. package/src/operator/node/shared.tsx +89 -0
  115. package/src/operator/node/transcriptModel.ts +129 -0
  116. package/src/operator/node/types.ts +296 -0
  117. package/src/operator/node/useGlobalEvents.ts +97 -0
  118. package/src/operator/node/useOrchestrator.ts +696 -0
  119. package/src/operator/node/usePrefs.ts +114 -0
  120. package/src/operator/node/useRecaps.ts +91 -0
  121. package/src/operator/node/useTaskStream.ts +280 -0
  122. package/src/operator.base.css +1806 -0
  123. package/src/operator.css +194 -16
  124. package/src/styles.css +49 -1
  125. package/src/ui/Markdown.tsx +7 -2
  126. package/src/ui/Popover.tsx +82 -0
  127. package/src/ui/TextBlock.tsx +3 -3
  128. package/src/ui/Transcript.tsx +17 -10
  129. package/src/ui/fileLinks.ts +6 -0
  130. package/src/ui/format.ts +1 -1
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  // 工作区文件的基础操作(不依赖界面):可直接打开的地址、下载到本地、读成 Blob。
2
2
  // 看板、文件预览和给业务方的工作区接口(workspace.ts)共用。
3
- import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
4
- import { extOf, fmtSize } from "./fileKind.js";
3
+ import { fileUrlFromBase, type BoardBackend } from "../core/client";
4
+ import { extOf, fmtSize } from "./fileKind";
5
5
 
6
6
  /** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不在浏览器里拼下载。 */
7
7
  const BLOB_DOWNLOAD_MAX = 300 * 1024 * 1024;
@@ -26,6 +26,29 @@ export async function directFileUrl(backend: BoardBackend, jobId: string, path:
26
26
  return linkBase ? fileUrlFromBase(linkBase, path, download) : null;
27
27
  }
28
28
 
29
+ export class FileReadLimitError extends Error {
30
+ constructor(maxBytes:number){super(`文件超过当前读取上限(${fmtSize(maxBytes)}),请使用直接下载链接`);this.name="FileReadLimitError";}
31
+ }
32
+ /** Bound bytes even with stale metadata; cancellation must settle callers when a custom backend ignores AbortSignal. */
33
+ export async function readBoundedBuffer(backend:BoardBackend,jobId:string,path:string,maxBytes:number,options:{signal?:AbortSignal;onBytes?:(bytes:number)=>void}={}):Promise<ArrayBuffer> {
34
+ const ctl=new AbortController();let limit:FileReadLimitError|undefined;
35
+ const abort=()=>ctl.abort();options.signal?.addEventListener("abort",abort,{once:true});
36
+ if(options.signal?.aborted)ctl.abort();
37
+ let rejectAbort:(()=>void)|undefined;
38
+ try{
39
+ if(ctl.signal.aborted)throw new DOMException("已取消","AbortError");
40
+ const cancelled=new Promise<never>((_resolve,reject)=>{rejectAbort=()=>reject(limit??new DOMException("已取消","AbortError"));ctl.signal.addEventListener("abort",rejectAbort,{once:true});});
41
+ const result=await Promise.race([backend.readBuffer(jobId,path,{signal:ctl.signal,onBytes:bytes=>{
42
+ if(ctl.signal.aborted)return;
43
+ if(bytes>maxBytes){limit=new FileReadLimitError(maxBytes);ctl.abort();return;}
44
+ options.onBytes?.(bytes);
45
+ }}),cancelled]);
46
+ if(ctl.signal.aborted)throw limit??new DOMException("已取消","AbortError");
47
+ if(result.byteLength>maxBytes)throw new FileReadLimitError(maxBytes);
48
+ return result;
49
+ }finally{options.signal?.removeEventListener("abort",abort);if(rejectAbort)ctl.signal.removeEventListener("abort",rejectAbort);ctl.abort();}
50
+ }
51
+
29
52
  /** 把 Blob 存到本地(浏览器下载)。 */
30
53
  export function saveBlob(blob: Blob, name: string) {
31
54
  const url = URL.createObjectURL(blob);
@@ -51,5 +74,5 @@ export async function downloadFile(backend: BoardBackend, jobId: string, path: s
51
74
  const url = await directFileUrl(backend, jobId, path, true);
52
75
  if (url) { clickDownload(url, name); return; }
53
76
  if (size !== undefined && size > BLOB_DOWNLOAD_MAX) throw new Error(`文件较大(${fmtSize(size)}),当前接入方式不能在浏览器里下载,请升级任务中心`);
54
- saveBlob(new Blob([await backend.readBuffer(jobId, path)]), name);
77
+ saveBlob(new Blob([await readBoundedBuffer(backend, jobId, path, BLOB_DOWNLOAD_MAX)]), name);
55
78
  }
@@ -0,0 +1,94 @@
1
+ import { CenterError, isMissingRoute, type BoardBackend } from '../core/client';
2
+ import { notifyCallback } from '../core/callbacks';
3
+ import type { FileEntry, WorkspaceChangeEvent, WorkspaceFileBatch, WorkspaceFileChange, WorkspaceWatchStatus } from '../core/types';
4
+ import { listFolder } from './folderDownload';
5
+ export interface WorkspaceWatchOptions { intervalMs?:number; onStatus?:(status:WorkspaceWatchStatus)=>void }
6
+ type Listener={change:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']};
7
+ type Entry={listeners:Set<Listener>;stop:()=>void;retry?:()=>void;status:WorkspaceWatchStatus};
8
+ 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}))};}
9
+ const registries=new WeakMap<BoardBackend,Map<string,Entry>>();
10
+ const MAX_TRACKED_FILES=100000;
11
+ /** Internal: a new lease in an aggregating store can retry a terminal stream. */
12
+ export function retryWorkspaceFiles(backend:BoardBackend,jobId:string):void {registries.get(backend)?.get(jobId)?.retry?.();}
13
+ /** One transport per backend/workspace; independent of the active directory or file tab. */
14
+ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listener:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}):()=>void {
15
+ let entries=registries.get(backend);if(!entries){entries=new Map();registries.set(backend,entries);}
16
+ let entry=entries.get(jobId);const subscription={change:listener,status:options.onStatus};
17
+ if(!entry){
18
+ let mode:WorkspaceWatchStatus['mode']=backend.watchFiles?'events':'poll',coverage:WorkspaceWatchStatus['coverage']=mode==='events'?'workspace':'listed-files';
19
+ entry={listeners:new Set(),stop:()=>{},status:{type:'files_watch',jobId,state:'connecting',mode,coverage,complete:false}};entries.set(jobId,entry);
20
+ const owned=entry;let alive=true,baseline:Map<string,FileEntry>|null=null,limited=false,blocked=false,stopTransport:(()=>void)|null=null;
21
+ let timer:ReturnType<typeof setTimeout>|undefined,controller:AbortController|null=null,attempt=0,generation=0;
22
+ const status=(state:WorkspaceWatchStatus['state'],complete=false,error?:string)=>{
23
+ if(!alive)return;owned.status={type:'files_watch',jobId,state,mode,coverage,complete:mode==='events'&&complete&&!limited,...(error?{error}:{})};
24
+ for(const item of [...owned.listeners])notifyCallback(item.status,{...owned.status});
25
+ };
26
+ const publish=(changes:WorkspaceFileChange[],batch:WorkspaceFileBatch,resync=false)=>{
27
+ if(!alive||!changes.length)return;
28
+ const event:WorkspaceChangeEvent={type:'files_changed',jobId,mode,coverage,complete:mode==='events'&&batch.complete&&!limited,changes,revision:batch.revision,...(resync?{resync:true}:{})};
29
+ for(const item of [...owned.listeners])notifyCallback(item.change,cloneWorkspaceChange(event));
30
+ };
31
+ const accept=(batch:WorkspaceFileBatch)=>{
32
+ if(!alive)return;
33
+ if(batch.type==='resync'){if(batch.reason==='workspace_unavailable'&&baseline===null)baseline=new Map();status('reconnecting',false,batch.reason);return;}
34
+ if(batch.type==='snapshot'){
35
+ const next=new Map<string,FileEntry>();let overflow=false;const changes:WorkspaceFileChange[]=[];
36
+ for(const file of batch.files)if(file.type==='file'){if(next.has(file.path)||next.size<MAX_TRACKED_FILES)next.set(file.path,{...file});else overflow=true;}
37
+ if(baseline){
38
+ for(const [path,file] of next){const previous=baseline.get(path);if(!previous)changes.push({kind:'created',path,file});else if(previous.mtime!==file.mtime||previous.size!==file.size||previous.changeToken!==file.changeToken)changes.push({kind:'modified',path,file,previous});}
39
+ if(batch.complete&&!overflow)for(const [path,previous] of baseline)if(!next.has(path))changes.push({kind:'deleted',path,previous});
40
+ }
41
+ // Once tracking overflows, only a complete, bounded snapshot restores
42
+ // completeness. Partial snapshots never prove that missing files vanished.
43
+ if(batch.complete){baseline=next;limited=overflow;}
44
+ else{baseline??=new Map();for(const [path,file] of next){if(baseline.has(path)||baseline.size<MAX_TRACKED_FILES)baseline.set(path,file);else overflow=true;}limited||=overflow;}
45
+ publish(changes,batch,true);
46
+ }else{
47
+ baseline??=new Map();for(const change of batch.changes){if(change.kind==='deleted')baseline.delete(change.path);else if(change.file){if(baseline.has(change.path)||baseline.size<MAX_TRACKED_FILES)baseline.set(change.path,{...change.file});else limited=true;}}
48
+ // Native change events are authoritative even when size/mtime are unchanged.
49
+ publish(batch.changes,batch);
50
+ }
51
+ attempt=0;status('ready',batch.complete);
52
+ };
53
+ const interval=Math.max(1000,options.intervalMs??15000);
54
+ const scan=async()=>{
55
+ if(!alive)return;controller=new AbortController();const signal=controller.signal;
56
+ try{const files=await listFolder(backend,jobId,'',{signal,maxFiles:10000,maxBytes:Infinity});if(alive&&!signal.aborted)accept({type:'snapshot',files:files.map(file=>({...file,name:file.path.split('/').pop()??file.path,type:'file' as const})),complete:true});}
57
+ catch(error){if(alive&&!signal.aborted){attempt++;status('error',false,error instanceof Error?error.message:String(error));}}
58
+ finally{controller=null;if(alive)timer=setTimeout(()=>{timer=undefined;void scan();},Math.min(120000,interval*Math.max(1,attempt)));}
59
+ };
60
+ const connect=()=>{
61
+ if(!alive||!backend.watchFiles)return;const token=++generation;let finished=false,cleanup:(()=>void)|null=null;
62
+ blocked=false;
63
+ status(attempt?'reconnecting':'connecting');
64
+ if(!alive||token!==generation)return;
65
+ // Retirement can precede watchFiles returning its disposer. Clearing the
66
+ // owned handle first also makes reentrant cancellation release it once.
67
+ const retire=()=>{finished=true;if(stopTransport===retire)stopTransport=null;const dispose=cleanup;cleanup=null;notifyCallback(dispose??undefined,undefined);};
68
+ const close=(error:unknown)=>{
69
+ if(!alive||finished||token!==generation)return;retire();if(!alive||token!==generation)return;const code=error instanceof CenterError?error.status:0;
70
+ if(error instanceof CenterError&&(isMissingRoute(error)||(code===501&&error.code==='file_watch_unsupported'))){
71
+ // A directory listing has narrower coverage than native events. Establish
72
+ // a fresh baseline rather than reporting inaccessible paths as deleted.
73
+ mode='poll';coverage='listed-files';baseline=null;limited=false;attempt=0;blocked=false;
74
+ status('connecting',false,error.message);if(alive&&token===generation)void scan();return;
75
+ }
76
+ blocked=[400,404,405,410,413,501].includes(code);const slow=[401,403,429].includes(code);
77
+ status(blocked||slow?'error':'reconnecting',false,error instanceof Error?error.message:undefined);
78
+ if(!alive||token!==generation||blocked)return;
79
+ const delay=slow?30000:Math.min(30000,1000*2**Math.min(attempt++,5));
80
+ timer=setTimeout(()=>{timer=undefined;connect();},delay);
81
+ };
82
+ stopTransport=retire;
83
+ try{cleanup=backend.watchFiles(jobId,{onEvent:batch=>{if(alive&&!finished&&token===generation)accept(batch);},onClose:close});if(finished||!alive||token!==generation)retire();}catch(error){close(error);}
84
+ };
85
+ const wake=()=>{if(!alive)return;if(typeof document!=='undefined'&&document.visibilityState==='hidden')return;if(timer){clearTimeout(timer);timer=undefined;}if(mode==='poll'){if(!controller)void scan();}else{const token=++generation,retire=stopTransport;stopTransport=null;retire?.();if(alive&&token===generation)connect();}};
86
+ owned.retry=()=>{if(blocked)wake();};
87
+ if(typeof window!=='undefined'){window.addEventListener('online',wake);document.addEventListener('visibilitychange',wake);}
88
+ owned.stop=()=>{alive=false;generation++;if(timer)clearTimeout(timer);controller?.abort();stopTransport?.();if(typeof window!=='undefined'){window.removeEventListener('online',wake);document.removeEventListener('visibilitychange',wake);}};
89
+ // Defer initialization until the first listener has been registered.
90
+ queueMicrotask(()=>{if(alive){if(mode==='events')connect();else void scan();}});
91
+ }
92
+ const owned=entry;owned.listeners.add(subscription);notifyCallback(subscription.status,{...owned.status});owned.retry?.();
93
+ let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(subscription);if(!owned.listeners.size){if(entries!.get(jobId)===owned)entries!.delete(jobId);owned.stop();}};
94
+ }
@@ -2,10 +2,10 @@
2
2
  // 有上限:文件数、总大小(读进内存再打包);超了直接报出来,让用户进到更小的目录或单个下载,不会把页面撑爆。
3
3
  // 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
4
4
  // 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
5
- import { CenterError, type BoardBackend } from "../core/client.js";
6
- import { fmtSize } from "./fileKind.js";
7
- import { saveBlob } from "./fileOps.js";
8
- import { buildZip, type ZipEntry } from "./zip.js";
5
+ import { CenterError, type BoardBackend } from "../core/client";
6
+ import { fmtSize } from "./fileKind";
7
+ import { saveBlob, readBoundedBuffer, FileReadLimitError } from "./fileOps";
8
+ import { buildZip, type ZipEntry } from "./zip";
9
9
 
10
10
  export const FOLDER_MAX_FILES = 2000;
11
11
  export const FOLDER_MAX_BYTES = 300 * 1024 * 1024;
@@ -17,7 +17,7 @@ const BACKOFF_MS = [1_000, 3_000];
17
17
  export const FAILED_LIST_NAME = "_下载失败的文件.txt";
18
18
 
19
19
  export type FolderProgress = { phase: "listing" | "reading" | "zipping"; files: number; total: number; bytes: number; totalBytes?: number; failed?: number; retrying?: string };
20
- export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }> };
20
+ 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 };
21
21
  export type FolderZip = FolderResult & { blob: Blob; name: string };
22
22
  export type ListOptions = { signal?: AbortSignal; backoff?: number[]; maxFiles?: number; maxBytes?: number };
23
23
 
@@ -43,15 +43,19 @@ function sleep(ms: number, signal?: AbortSignal) {
43
43
  export async function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, onRetry?: (attempt: number, error: unknown) => void, backoff = BACKOFF_MS): Promise<T> {
44
44
  for (let attempt = 1; ; attempt++) {
45
45
  if (signal?.aborted) throw abortError();
46
+ let stop:(()=>void)|undefined;
46
47
  try {
47
- return await fn();
48
+ const cancelled=new Promise<never>((_resolve,reject)=>{stop=()=>reject(abortError());signal?.addEventListener("abort",stop,{once:true});});
49
+ const value=await Promise.race([fn(),cancelled]);
50
+ if(signal?.aborted)throw abortError();
51
+ return value;
48
52
  } catch (error) {
49
53
  if (isAbort(error) || signal?.aborted) throw abortError();
50
- if (attempt >= ATTEMPTS || !isTransient(error)) throw error;
54
+ if (attempt >= ATTEMPTS || error instanceof FileReadLimitError || !isTransient(error)) throw error;
51
55
  onRetry?.(attempt, error);
52
56
  const base = backoff[Math.min(attempt - 1, backoff.length - 1)] ?? 0;
53
57
  await sleep(Math.round(base * (0.7 + Math.random() * 0.6)), signal);
54
- }
58
+ } finally {if(stop)signal?.removeEventListener("abort",stop);}
55
59
  }
56
60
  }
57
61
 
@@ -89,13 +93,21 @@ export type FolderOptions = { onProgress?: (p: FolderProgress) => void; signal?:
89
93
 
90
94
  /** 打包下载 dir("" = 整个工作区)到本地。返回打了几个文件、哪些没读到。 */
91
95
  export async function downloadFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderResult> {
92
- const { blob, name, files, failed } = await zipFolder(backend, jobId, dir, opts);
96
+ const { blob, name, files, failed, failedListName } = await zipFolder(backend, jobId, dir, opts);
97
+ if(opts.signal?.aborted)throw abortError();
93
98
  saveBlob(blob, name);
94
- return { files, failed };
99
+ return { files, failed, ...(failedListName?{failedListName}:{}) };
95
100
  }
96
101
 
97
102
  /** 把 dir("" = 整个工作区)打成 ZIP,返回 Blob(不下载;业务方可以自己上传到服务器)。 */
98
103
  export async function zipFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderZip> {
104
+ const ctl=new AbortController(),abort=()=>ctl.abort();let limit:FileReadLimitError|undefined;
105
+ opts.signal?.addEventListener("abort",abort,{once:true});if(opts.signal?.aborted)ctl.abort();
106
+ try{return await zipFolderRun(backend,jobId,dir,{...opts,signal:ctl.signal},()=>{limit=new FileReadLimitError(FOLDER_MAX_BYTES);ctl.abort();});}
107
+ catch(error){throw limit??error;}
108
+ finally{opts.signal?.removeEventListener("abort",abort);ctl.abort();}
109
+ }
110
+ async function zipFolderRun(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions,exceeded:()=>void): Promise<FolderZip> {
99
111
  const { onProgress, signal } = opts;
100
112
  onProgress?.({ phase: "listing", files: 0, total: 0, bytes: 0 });
101
113
  const files = await listFolder(backend, jobId, dir, { signal, backoff: opts.backoff });
@@ -123,7 +135,7 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
123
135
  const f = files[i];
124
136
  try {
125
137
  const buffer = await withRetry(
126
- () => { got[i] = 0; return backend.readBuffer(jobId, f.path, { signal, onBytes: (n) => { got[i] = n; report(); } }); },
138
+ () => { 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(); } }); },
127
139
  signal,
128
140
  () => { retrying = f.path; report(true); },
129
141
  opts.backoff,
@@ -131,7 +143,7 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
131
143
  const data = new Uint8Array(buffer);
132
144
  got[i] = data.length;
133
145
  // 任务在写、文件比列的时候大了很多:总量还是要守住上限。
134
- if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES * 1.1) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
146
+ if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
135
147
  // ZIP 里的路径相对下载的目录(文件夹名本身作为 ZIP 文件名)。
136
148
  entries[i] = { path: f.path.startsWith(base) ? f.path.slice(base.length) : f.path, data, mtime: f.mtime };
137
149
  } catch (error) {
@@ -151,11 +163,15 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
151
163
  const ok = entries.filter((e): e is ZipEntry => !!e);
152
164
  if (!ok.length) throw new Error(`${failed.length} 个文件都没读到:${failed[0]?.reason ?? "未知原因"}`);
153
165
  onProgress?.({ phase: "zipping", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length });
166
+ if(signal?.aborted)throw abortError();
167
+ let failedListName:string|undefined;
154
168
  if (failed.length) {
169
+ const names=new Set(ok.map(entry=>entry.path));failedListName=FAILED_LIST_NAME;
170
+ for(let suffix=1;names.has(failedListName);suffix++)failedListName=`_下载失败的文件 (${suffix}).txt`;
155
171
  const list = ["下面这些文件没有下载下来(其余文件都在压缩包里):", "", ...failed.map((f) => `${f.path}\t${f.reason}`), ""].join("\n");
156
- ok.push({ path: FAILED_LIST_NAME, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
172
+ ok.push({ path: failedListName, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
157
173
  }
158
174
  const blob = buildZip(ok);
159
175
  const name = opts.zipName ?? `${(dir.split("/").filter(Boolean).pop() || "工作区")}.zip`;
160
- return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed };
176
+ return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed, ...(failedListName?{failedListName}:{}) };
161
177
  }
@@ -18,9 +18,9 @@ function bridgeScript(token: string) {
18
18
  return `(function(){var t=${t};function post(m){m.cliUiSel=t;try{parent.postMessage(m,"*")}catch(e){}}
19
19
  function send(){var s=getSelection(),text=s&&!s.isCollapsed?String(s).trim():"",rect=null;
20
20
  if(text&&s.rangeCount){var b=s.getRangeAt(0).getBoundingClientRect();rect={left:b.left,top:b.top,width:b.width}}post({text:text,rect:rect})}
21
- function later(){setTimeout(send,0)}
21
+ var timer;function later(){clearTimeout(timer);timer=setTimeout(send,0)}
22
22
  document.addEventListener("mouseup",later);document.addEventListener("keyup",later);
23
- document.addEventListener("selectionchange",function(){var s=getSelection();if(!s||s.isCollapsed)post({text:""})});
23
+ document.addEventListener("selectionchange",later);
24
24
  addEventListener("scroll",function(){post({scroll:true})},true);
25
25
  addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiClear===t){var s=getSelection();s&&s.removeAllRanges()}});
26
26
  document.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href^="#"]');if(!a)return;e.preventDefault();
@@ -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
+ }
@@ -5,8 +5,8 @@
5
5
  // 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
6
6
  // 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
7
7
 
8
- import type { BoardBackend } from "../core/client.js";
9
- import { decodeText } from "../core/textDecode.js";
8
+ import type { BoardBackend } from "../core/client";
9
+ import { decodeText } from "../core/textDecode";
10
10
 
11
11
  /** 往前多取这么多字节,用来确认文件只是追加。 */
12
12
  export const OVERLAP = 256;
@@ -47,12 +47,13 @@ export function incompleteUtf8Tail(bytes: Uint8Array) {
47
47
  const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((x, i) => x === b[i]);
48
48
 
49
49
  /** 第一次读:开头(或末尾)最多 max 字节。 */
50
- export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<LoadedText> {
50
+ export async function loadText(backend: BoardBackend, jobId: string, path: string, opts: { max: number; tail?: boolean; onBytes?: (bytes: number) => void; signal?: AbortSignal }): Promise<LoadedText> {
51
+ if (opts.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
51
52
  if (!backend.readBytes) {
52
- const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes });
53
+ const r = await backend.readText(jobId, path, opts.max, { tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
53
54
  return { text: r.text, parts: [r.text], truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding, start: 0, end: 0, tailBytes: null };
54
55
  }
55
- const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes });
56
+ const chunk = await backend.readBytes(jobId, path, { maxBytes: opts.max, tail: opts.tail, onBytes: opts.onBytes, signal: opts.signal });
56
57
  const { text, encoding } = decodeText(chunk.bytes, { fromMiddle: chunk.start > 0 });
57
58
  const k = encoding === "utf-8" ? incompleteUtf8Tail(chunk.bytes) : 0;
58
59
  const end = chunk.start + chunk.bytes.length - k;
@@ -67,7 +68,8 @@ export async function loadText(backend: BoardBackend, jobId: string, path: strin
67
68
  * 文件变了:能接上就只取新增的部分,返回新的内容;返回 null = 接不上(被改写 / 变短 / 不是 UTF-8 / 后端不支持),调用方整个重读。
68
69
  * 只看开头且文件比 max 大时,开头没变就不用重读(追加的在后面,本来就不显示)。
69
70
  */
70
- export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number): Promise<LoadedText | null> {
71
+ export async function refreshText(backend: BoardBackend, jobId: string, path: string, prev: LoadedText, max: number, options: { signal?: AbortSignal } = {}): Promise<LoadedText | null> {
72
+ if (options.signal?.aborted) throw new DOMException("文件读取已取消", "AbortError");
71
73
  if (!backend.readBytes || !prev.tailBytes) return null;
72
74
  const overlap = prev.tailBytes.length;
73
75
  const from = prev.end - overlap;
@@ -75,7 +77,7 @@ export async function refreshText(backend: BoardBackend, jobId: string, path: st
75
77
  // 末尾模式攒多了(超过 1.5 倍):整个重读末尾,免得越攒越大。
76
78
  if (prev.tail && prev.end - prev.start > max * 1.5) return null;
77
79
  const room = headOnly ? 0 : prev.tail ? max : Math.max(0, max - (prev.end - prev.start));
78
- const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room });
80
+ const chunk = await backend.readBytes(jobId, path, { start: from, maxBytes: overlap + room, signal: options.signal });
79
81
  if (chunk.size < prev.end || chunk.start !== from || chunk.bytes.length < overlap) return null;
80
82
  if (!sameBytes(chunk.bytes.subarray(0, overlap), prev.tailBytes)) return null;
81
83
  const readEnd = from + chunk.bytes.length;
@@ -11,11 +11,12 @@
11
11
  *
12
12
  * 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
13
13
  */
14
- import type { BoardBackend } from "../core/client.js";
15
- import type { BoardInfo, FileEntry } from "../core/types.js";
16
- import { directFileUrl, downloadFile, mimeOf } from "./fileOps.js";
17
- import { fmtSize } from "./fileKind.js";
18
- import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload.js";
14
+ import { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./fileWatch";
15
+ import type { BoardBackend } from "../core/client";
16
+ import type { BoardInfo, FileEntry, WorkspaceChangeEvent } from "../core/types";
17
+ import { directFileUrl, downloadFile, mimeOf } from "./fileOps";
18
+ import { fmtSize } from "./fileKind";
19
+ import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload";
19
20
 
20
21
  /** readText 最多读这么大(更大的用 readFile)。 */
21
22
  export const WORKSPACE_TEXT_MAX = 20 * 1024 * 1024;
@@ -24,6 +25,8 @@ export const WORKSPACE_FILE_MAX = 500 * 1024 * 1024;
24
25
  /** listAll 最多列这么多个文件。 */
25
26
  export const WORKSPACE_LIST_MAX = 10_000;
26
27
 
28
+ export interface BoardFile { path: string; sourceId?: string }
29
+
27
30
  /** 看板现在的样子(看板组件报告给仓库;没有看板时没有)。 */
28
31
  export interface BoardView {
29
32
  /** 当前页签:files / changes / browser、"file:<路径>"(打开的文件)或自己加的页签 id。 */
@@ -34,6 +37,14 @@ export interface BoardView {
34
37
  file: string | null;
35
38
  /** 开着的文件页签。 */
36
39
  files: string[];
40
+ /** 当前文件身份;切到目录/浏览器时为 null。sourceId 保留来源任务。 */
41
+ current?: BoardFile | null;
42
+ opened?: BoardFile[];
43
+ /** 最近真正访问过的文件(关闭页签后保留,最多40条)。 */
44
+ history?: BoardFile[];
45
+ browser?: { url: string; title: string } | null;
46
+ visible?: boolean;
47
+ autoContext?: boolean;
37
48
  }
38
49
 
39
50
  export interface CliWorkspace {
@@ -41,12 +52,17 @@ export interface CliWorkspace {
41
52
  readonly jobId: string;
42
53
  /** 看板概况:派发到节点没有、节点是否在线、有没有浏览器。 */
43
54
  info(): Promise<BoardInfo>;
55
+ stat(path: string): ReturnType<BoardBackend["stat"]>;
56
+ /** 订阅文件变更。初始快照只建立基线;返回取消函数。 */
57
+ watch(listener: (event: WorkspaceChangeEvent) => void, options?: WorkspaceWatchOptions): () => void;
58
+ /** 包括当前文件、打开页签、最近浏览和显示状态的背景快照。 */
59
+ context(): BoardView | null;
44
60
  /** 列一个目录(不递归)。 */
45
61
  list(dir?: string): Promise<FileEntry[]>;
46
62
  /** 列 dir 下所有文件(递归,只含文件)。 */
47
63
  listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
48
64
  /** 读成文字(GBK 导出的文件会自动转码)。 */
49
- readText(path: string): Promise<string>;
65
+ readText(path: string, opts?: { signal?: AbortSignal }): Promise<string>;
50
66
  /** 读成 Blob。onProgress 报已收到的字节数;超过 maxBytes(缺省 500 MB)报错,改用 fileUrl / download。 */
51
67
  readFile(path: string, opts?: { signal?: AbortSignal; onProgress?: (bytes: number) => void; maxBytes?: number }): Promise<Blob>;
52
68
  /** 浏览器能直接打开 / 下载的地址(没有同源代理时是 30 分钟有效的短期链接);接入方式不支持时为 null,改用 readFile。 */
@@ -70,6 +86,8 @@ export function createWorkspace(deps: {
70
86
  key: string;
71
87
  view: () => BoardView | undefined;
72
88
  open: (path: string) => boolean;
89
+ context?: () => BoardView | null;
90
+ watch?: (listener:(event:WorkspaceChangeEvent)=>void,options?:WorkspaceWatchOptions)=>()=>void;
73
91
  }): CliWorkspace {
74
92
  const { key } = deps;
75
93
  const backend = () => {
@@ -80,6 +98,9 @@ export function createWorkspace(deps: {
80
98
  return {
81
99
  jobId: key,
82
100
  info: () => backend().info(key),
101
+ stat: path => backend().stat(key, clean(path) || "."),
102
+ watch: (listener, options) => deps.watch ? deps.watch(listener,options) : subscribeWorkspaceFiles(backend(), key, listener, options),
103
+ context: () => deps.context?.() ?? deps.view() ?? null,
83
104
  async list(dir = "") {
84
105
  const res = await backend().stat(key, clean(dir) || ".");
85
106
  if (res.type !== "dir") throw new Error(`「${dir}」不是目录`);
@@ -89,30 +110,48 @@ export function createWorkspace(deps: {
89
110
  const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
90
111
  return files.map((f) => ({ name: f.path.split("/").pop() || f.path, path: f.path, type: "file" as const, size: f.size, mtime: f.mtime }));
91
112
  },
92
- async readText(path) {
93
- const r = await backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
94
- if (r.truncated) throw new Error(`文件较大(超过 ${WORKSPACE_TEXT_MAX / 1024 / 1024} MB),请用 readFile 读成 Blob`);
95
- return r.text;
113
+ async readText(path, opts = {}) {
114
+ const signal = opts.signal;
115
+ if (signal?.aborted) throw new DOMException("已取消", "AbortError");
116
+ let rejectAbort: (() => void) | undefined;
117
+ try {
118
+ const reading = signal ? backend().readText(key, clean(path), WORKSPACE_TEXT_MAX, { signal }) : backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
119
+ const r = signal ? await Promise.race([reading, new Promise<never>((_resolve, reject) => {
120
+ rejectAbort = () => reject(new DOMException("已取消", "AbortError"));
121
+ signal.addEventListener("abort", rejectAbort, { once: true });
122
+ if (signal.aborted) rejectAbort();
123
+ })]) : await reading;
124
+ if (r.truncated) throw new Error(`文件较大(超过 ${WORKSPACE_TEXT_MAX / 1024 / 1024} MB),请用 readFile 读成 Blob`);
125
+ return r.text;
126
+ } finally { if (rejectAbort) signal?.removeEventListener("abort", rejectAbort); }
96
127
  },
97
128
  async readFile(path, opts = {}) {
98
129
  const p = clean(path);
130
+ const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
131
+ if(!Number.isFinite(max)||max<0)throw new RangeError("maxBytes 必须是非负有限字节数");
99
132
  // 太大的读到一半就停:整个读进内存会把页面撑爆。
100
133
  const ctl = new AbortController();
101
134
  const stop = () => ctl.abort();
102
135
  opts.signal?.addEventListener("abort", stop, { once: true });
136
+ if(opts.signal?.aborted)ctl.abort();
103
137
  let tooBig = false;
104
- const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
138
+ let rejectAbort:(()=>void)|undefined;
105
139
  try {
106
- const buffer = await backend().readBuffer(key, p, {
140
+ if(ctl.signal.aborted)throw new DOMException("已取消", "AbortError");
141
+ const aborted=new Promise<never>((_resolve,reject)=>{rejectAbort=()=>reject(new DOMException("已取消", "AbortError"));ctl.signal.addEventListener("abort",rejectAbort,{once:true});});
142
+ const buffer = await Promise.race([backend().readBuffer(key, p, {
107
143
  signal: ctl.signal,
108
- onBytes: (n) => { if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
109
- });
144
+ onBytes: (n) => { if(ctl.signal.aborted)return; if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
145
+ }),aborted]);
146
+ if(buffer.byteLength>max){tooBig=true;throw new Error("文件超过读取上限");}
147
+ if(ctl.signal.aborted)throw new DOMException("已取消", "AbortError");
110
148
  return new Blob([buffer], { type: mimeOf(p) });
111
149
  } catch (error) {
112
150
  if (tooBig) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download(浏览器流式下载,不占内存)`);
113
151
  throw error;
114
152
  } finally {
115
153
  opts.signal?.removeEventListener("abort", stop);
154
+ if(rejectAbort)ctl.signal.removeEventListener("abort",rejectAbort);
116
155
  }
117
156
  },
118
157
  async fileUrl(path, opts = {}) {