@spzxg/cli-ui 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +109 -30
  3. package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
  4. package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
  7. package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +11 -8
  11. package/src/board/BrowserPane.tsx +198 -53
  12. package/src/board/CliBoard.tsx +128 -100
  13. package/src/board/FileViewer.tsx +276 -136
  14. package/src/board/FilesPane.tsx +129 -40
  15. package/src/board/HtmlReadingPreview.tsx +52 -0
  16. package/src/board/MarkdownPreview.tsx +134 -0
  17. package/src/board/PanelControls.tsx +19 -7
  18. package/src/board/fileKind.ts +138 -0
  19. package/src/board/fileOps.ts +9 -2
  20. package/src/board/fileScope.ts +29 -0
  21. package/src/board/fileSync.ts +343 -0
  22. package/src/board/fileSyncCache.ts +250 -0
  23. package/src/board/fileSyncProcessor.ts +71 -0
  24. package/src/board/fileSyncQueue.ts +56 -0
  25. package/src/board/fileSyncTransport.ts +85 -0
  26. package/src/board/fileWatch.ts +177 -70
  27. package/src/board/folderDownload.ts +39 -5
  28. package/src/board/frameQuote.ts +12 -10
  29. package/src/board/htmlReading.ts +148 -0
  30. package/src/board/textLoad.ts +18 -41
  31. package/src/board/useBoardUpload.tsx +51 -0
  32. package/src/board/useBoardVisibility.ts +35 -0
  33. package/src/board/workspace.ts +53 -11
  34. package/src/chat/CliChat.tsx +105 -55
  35. package/src/chat/Composer.tsx +55 -21
  36. package/src/chat/GroupChat.tsx +113 -0
  37. package/src/chat/RunControls.tsx +132 -42
  38. package/src/chat/draft.ts +5 -2
  39. package/src/core/blocks.ts +17 -5
  40. package/src/core/client.ts +88 -19
  41. package/src/core/conversation.ts +1 -0
  42. package/src/core/documentUpload.ts +196 -0
  43. package/src/core/provider.tsx +7 -0
  44. package/src/core/store.ts +134 -36
  45. package/src/core/types.ts +31 -10
  46. package/src/index.ts +3 -0
  47. package/src/layout/split.ts +67 -30
  48. package/src/msglist/CliMsgList.tsx +18 -5
  49. package/src/operator/node/Chat.tsx +7 -8
  50. package/src/operator/node/Composer.tsx +99 -72
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +25 -25
  54. package/src/operator/node/Transcript.tsx +28 -11
  55. package/src/operator/node/boardBackend.ts +6 -23
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/protocol/types.ts +2 -2
  63. package/src/operator/node/useTaskStream.ts +8 -5
  64. package/src/operator.base.css +148 -37
  65. package/src/operator.css +148 -37
  66. package/src/styles.css +274 -32
  67. package/src/ui/Markdown.tsx +2 -2
  68. package/src/ui/Popover.tsx +32 -14
  69. package/src/ui/QueuedMessages.tsx +69 -0
  70. package/src/ui/ReferenceCard.tsx +28 -0
  71. package/src/ui/TextBlock.tsx +6 -4
  72. package/src/ui/Transcript.tsx +27 -12
  73. package/src/ui/UploadProgress.tsx +46 -0
  74. package/src/ui/WorkspaceDrawer.tsx +64 -0
  75. package/src/ui/WorkspaceTabs.tsx +60 -0
  76. package/src/ui/format.ts +3 -1
  77. package/src/ui/reference.ts +40 -0
  78. package/src/ui/useChatScrollbar.ts +29 -0
  79. package/src/ui/useComposerAutosize.ts +54 -0
  80. package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
@@ -1,88 +1,195 @@
1
- import { CenterError, type BoardBackend } from '../core/client';
1
+ import { type BoardBackend } from '../core/client';
2
2
  import { notifyCallback } from '../core/callbacks';
3
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 }
4
+ import { denyFileScope, onFileScope, readFileScope, retainFileScope } from './fileScope';
5
+ import { onFilePreviewScope, refreshFilePreviews } from './fileSync';
6
+ import { createFileWorkQueue } from './fileSyncQueue';
7
+ export interface WorkspaceWatchOptions { /** @deprecated Retained for compatibility; checks are event-driven, never periodic. */ intervalMs?:number; paths?:readonly string[]; dirs?:readonly string[]; onStatus?:(status:WorkspaceWatchStatus)=>void }
6
8
  type Listener={change:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']};
7
- type Entry={listeners:Set<Listener>;stop:()=>void;retry?:()=>void;status:WorkspaceWatchStatus};
9
+ export interface WorkspaceRefreshOptions { manual?:boolean }
10
+ type Entry={listeners:Set<Listener>;stop:()=>void;retry:(manual?:boolean)=>void;status:WorkspaceWatchStatus};
8
11
  export function cloneWorkspaceChange(event:WorkspaceChangeEvent):WorkspaceChangeEvent {return {...event,changes:event.changes.map(change=>({...change,file:change.file?{...change.file}:undefined,previous:change.previous?{...change.previous}:undefined}))};}
9
12
  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. */
13
+ const metadataQueues=new WeakMap<BoardBackend,ReturnType<typeof createFileWorkQueue>>();
14
+ function metadataQueue(backend:BoardBackend){
15
+ let queue=metadataQueues.get(backend);if(!queue){queue=createFileWorkQueue(4,128);metadataQueues.set(backend,queue);}return queue;
16
+ }
17
+ export function retryWorkspaceFiles(backend:BoardBackend,jobId:string,options:WorkspaceRefreshOptions={}):void {registries.get(backend)?.get(jobId)?.retry(options.manual);}
18
+ const fingerprint=(file:FileEntry)=>`${file.type}:${file.size}:${file.mtime}:${file.changeToken??''}`;
19
+ const validFile=(file:FileEntry)=>!!file&&typeof file.path==='string'&&typeof file.name==='string'&&['file','dir'].includes(file.type)&&Number.isFinite(file.size)&&Number.isFinite(file.mtime);
20
+ const invalidResponse=(message:string)=>Object.assign(new Error(message),{status:502,code:'invalid_response'});
21
+ /** Shared event-driven HTTP checks. No recurring file timer or file event connection. */
14
22
  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};
23
+ const releaseOwnedScope=options.paths?.length||options.dirs?.length?retainFileScope(backend,jobId,{paths:[...options.paths??[]],dirs:[...options.dirs??[]]}):()=>{};
24
+ let jobs=registries.get(backend);if(!jobs){jobs=new Map();registries.set(backend,jobs);}
25
+ let entry=jobs.get(jobId);
17
26
  if(!entry){
18
- const mode=backend.watchFiles?'events':'poll',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
+ // Keep the public transport value for compatibility; it denotes bounded HTTP, not a timer.
28
+ const statusBase={type:'files_watch' as const,jobId,mode:'poll' as const,coverage:'listed-files' as const,complete:false};
29
+ entry={listeners:new Set(),stop:()=>{},retry:()=>{},status:{...statusBase,state:'connecting'}};jobs.set(jobId,entry);
30
+ const owned=entry;
31
+ let alive=true,controller:AbortController|null=null,timer:ReturnType<typeof setTimeout>|undefined,failed=0,generation=0,dirty=false;
32
+ let scopeKey=JSON.stringify(readFileScope(backend,jobId));
33
+ const retryDelays=[1000,2000,4000];
34
+ let serverNotBefore=0,retryAt=0,exhaustedUntil=0;
35
+ const requests=metadataQueue(backend);
36
+ let baseline=new Map<string,FileEntry>(),directories=new Map<string,string|null>(),cursors=new Map<string,string>();
37
+ let known=new Set<string>();
38
+ type ScanState={baseline:Map<string,FileEntry>;known:Set<string>};
39
+ const status=(state:WorkspaceWatchStatus['state'],error?:string)=>{owned.status={...statusBase,state,...(error?{error}:{})};for(const item of [...owned.listeners])notifyCallback(item.status,{...owned.status});};
40
+ const publish=(changes:WorkspaceFileChange[],revision?:string)=>{
27
41
  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}:{})};
42
+ const event:WorkspaceChangeEvent={type:'files_changed',jobId,mode:'poll',coverage:'listed-files',complete:false,changes,revision};
29
43
  for(const item of [...owned.listeners])notifyCallback(item.change,cloneWorkspaceChange(event));
30
44
  };
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});
45
+ const snapshot=(files:FileEntry[],paths:string[],state:ScanState):WorkspaceFileChange[]=>{
46
+ if(!Array.isArray(files)||!files.every(validFile))throw invalidResponse('文件变化响应不完整,请重试');
47
+ const current=new Map(files.map(file=>[file.path,file])),changes:WorkspaceFileChange[]=[];
48
+ for(const path of paths){
49
+ const file=current.get(path),previous=state.baseline.get(path);
50
+ if(state.known.has(path)){
51
+ if(file&&!previous)changes.push({kind:'created',path,file});
52
+ else if(!file&&previous)changes.push({kind:'deleted',path,previous});
53
+ else if(file&&previous&&fingerprint(file)!==fingerprint(previous))changes.push({kind:'modified',path,file,previous});
40
54
  }
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);
55
+ state.known.add(path);if(file)state.baseline.set(path,{...file});else state.baseline.delete(path);
50
56
  }
51
- attempt=0;status('ready',batch.complete);
57
+ return changes;
52
58
  };
53
- const interval=Math.max(1000,options.intervalMs??15000);
59
+ const accept=(batch:WorkspaceFileBatch,paths:string[],state:ScanState)=>{
60
+ if(batch.type==='resync')return [];
61
+ if(batch.type==='snapshot')return snapshot(batch.files,paths,state);
62
+ if(batch.type!=='changes'||!Array.isArray(batch.changes)||batch.changes.some(change=>!change||!paths.includes(change.path)||!['created','modified','deleted'].includes(change.kind)||(change.file&&!validFile(change.file))))throw invalidResponse('文件变化响应不完整,请重试');
63
+ for(const change of batch.changes){state.known.add(change.path);if(change.kind==='deleted')state.baseline.delete(change.path);else if(change.file)state.baseline.set(change.path,{...change.file});}
64
+ return batch.changes;
65
+ };
66
+ const hidden=()=>typeof document!=='undefined'&&document.visibilityState==='hidden';
54
67
  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)));}
68
+ if(!alive||controller||hidden())return;
69
+ // A real event retained during cooldown starts one new bounded attempt wave.
70
+ if(dirty&&exhaustedUntil&&Date.now()>=exhaustedUntil){failed=0;exhaustedUntil=0;}
71
+ clearTimer();dirty=false;
72
+ const scope=readFileScope(backend,jobId);
73
+ // No open document/list: no idle root scan and no network subscription.
74
+ if(!scope.paths.length&&!scope.dirs.length){status('ready');return;}
75
+ const ctl=new AbortController();controller=ctl;const token=++generation;
76
+ let retryable=false;
77
+ const current=()=>alive&&!ctl.signal.aborted&&token===generation;
78
+ const state={baseline:new Map(baseline),known:new Set(known)},nextDirectories=new Map(directories),nextCursors=new Map(cursors);
79
+ try{
80
+ const changes:WorkspaceFileChange[]=[];
81
+ if(backend.pollFiles){
82
+ const activeKeys=new Set<string>();
83
+ const count=Math.max(Math.ceil(scope.paths.length/200),Math.ceil(scope.dirs.length/32));
84
+ for(let index=0;index<count;index++){
85
+ const paths=scope.paths.slice(index*200,index*200+200),dirs=scope.dirs.slice(index*32,index*32+32),key=JSON.stringify([paths,dirs]);activeKeys.add(key);
86
+ const batch=await requests.run(()=>backend.pollFiles!(jobId,{paths,dirs,cursor:nextCursors.get(key)},ctl.signal),ctl.signal);
87
+ if(!current())return;
88
+ if(!batch||typeof batch!=='object')throw invalidResponse('文件变化响应不完整,请重试');
89
+ if(batch.type==='resync'){cursors.delete(key);throw new Error('文件修订已变化,请重新核对');}
90
+ if(batch.cursor)nextCursors.set(key,batch.cursor);
91
+ changes.push(...accept(batch,paths,state));
92
+ if(!Array.isArray(batch.directories))throw invalidResponse('目录变化响应不完整,请重试');
93
+ for(const dir of batch.directories){
94
+ if(!dir||!dirs.includes(dir.path)||(dir.revision!==null&&typeof dir.revision!=='string'))throw invalidResponse('目录变化响应范围错误');
95
+ if(nextDirectories.has(dir.path)&&nextDirectories.get(dir.path)!==dir.revision)changes.push({kind:dir.revision===null?'deleted':'modified',path:dir.path});
96
+ nextDirectories.set(dir.path,dir.revision);
97
+ }
98
+ }
99
+ for(const key of nextCursors.keys())if(!activeKeys.has(key))nextCursors.delete(key);
100
+ }else{
101
+ // Compatibility hosts: inspect requested entries, never recurse a workspace.
102
+ const listed=new Map<string,FileEntry>();
103
+ for(const dir of scope.dirs){
104
+ const result=await requests.run(()=>backend.stat(jobId,dir||'.'),ctl.signal);if(!current())return;
105
+ if(result.type!=='dir')continue;
106
+ for(const file of result.entries)listed.set(file.path,file);
107
+ const revision=result.revision??JSON.stringify(result.entries.map(file=>[file.path,fingerprint(file)]));
108
+ if(nextDirectories.has(dir)&&nextDirectories.get(dir)!==revision)changes.push({kind:'modified',path:dir});
109
+ nextDirectories.set(dir,revision);
110
+ }
111
+ const files:FileEntry[]=[];
112
+ for(const path of scope.paths){
113
+ const listedFile=listed.get(path);if(listedFile){files.push(listedFile);continue;}
114
+ try{const result=await requests.run(()=>backend.stat(jobId,path),ctl.signal);if(result.type==='file')files.push(result);}
115
+ catch(error){if((error as {status?:number}).status!==404)throw error;}
116
+ if(!current())return;
117
+ }
118
+ changes.push(...snapshot(files,scope.paths,state));
119
+ }
120
+ if(!current())return;
121
+ const previews=backend.fileSync?await refreshFilePreviews(backend.fileSync,jobId,scope.paths,ctl.signal):undefined;
122
+ if(!current())return;
123
+ const changedPaths=new Set(changes.map(change=>change.path));
124
+ for(const path of previews?.changed??[])if(!changedPaths.has(path)){changes.push({kind:'modified',path});changedPaths.add(path);}
125
+ const active=new Set(scope.paths);for(const path of state.known)if(!active.has(path)){state.known.delete(path);state.baseline.delete(path);}
126
+ for(const dir of nextDirectories.keys())if(!scope.dirs.includes(dir))nextDirectories.delete(dir);
127
+ baseline=state.baseline;known=state.known;directories=nextDirectories;cursors=nextCursors;
128
+ publish(changes);
129
+ if(previews?.error)throw previews.error;
130
+ failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status('ready');
131
+ }catch(error){if(current()){
132
+ failed++;const code=(error as {status?:number}).status??0;
133
+ const failureCode=(error as {code?:string}).code;
134
+ retryable=!['invalid_manifest','invalid_response','integrity','response_too_large','client_queue_timeout'].includes(failureCode??'')&&(!code||[408,429,500,502,503,504].includes(code));
135
+ if(retryable){
136
+ const hint=Number((error as {retryAfterMs?:number}).retryAfterMs);
137
+ if(Number.isFinite(hint)&&hint>0)serverNotBefore=Math.max(serverNotBefore,Date.now()+hint);
138
+ const base=retryDelays[Math.min(failed-1,retryDelays.length-1)];
139
+ retryAt=Math.max(Date.now()+base,serverNotBefore)+Math.floor(Math.random()*base/2);
140
+ if(failed>retryDelays.length)exhaustedUntil=Math.max(Date.now()+30000,serverNotBefore);
141
+ }
142
+ if([401,403].includes(code))denyFileScope(backend,jobId);
143
+ status('error',error instanceof Error?error.message:String(error));
144
+ }}
145
+ finally{if(controller===ctl){
146
+ controller=null;
147
+ if(alive&&!hidden()){
148
+ if(retryable){
149
+ // A new round during an outage cannot skip backoff or replenish this retry budget.
150
+ if(failed<=retryDelays.length){const pending=dirty;dirty=false;schedule(Math.max(0,retryAt-Date.now()),pending);}
151
+ else if(dirty)schedule(Math.max(0,exhaustedUntil-Date.now(),serverNotBefore-Date.now()),true);
152
+ }else if(dirty)schedule(Math.max(100,serverNotBefore-Date.now()),true);
153
+ }
154
+ }}
155
+ };
156
+ function clearTimer(){if(timer!==undefined)clearTimeout(timer);timer=undefined;}
157
+ function schedule(delay:number,pending=false){
158
+ clearTimer();
159
+ // Long server cooldowns defer a real event once, never extend idle retries.
160
+ // Do not let setTimeout overflow and retry before the server's deadline.
161
+ if(delay>2147483647||(!pending&&delay>180000))return;
162
+ timer=setTimeout(()=>{timer=undefined;void scan();},delay);
163
+ }
164
+ const wake=(manual=false)=>{
165
+ if(!alive)return;
166
+ const now=Date.now();
167
+ if(manual){failed=0;exhaustedUntil=0;clearTimer();}
168
+ else if(exhaustedUntil&&now>=exhaustedUntil){
169
+ // Exhaustion alone never restarts work; a later event can restore its budget.
170
+ failed=0;exhaustedUntil=0;
171
+ }
172
+ dirty=true;
173
+ // Pending events coalesce, and automatic wakes cannot bypass an existing retry delay.
174
+ if(!controller&&!hidden()&&timer===undefined)schedule(Math.max(100,serverNotBefore-now,manual?0:Math.max(retryAt,exhaustedUntil)-now),true);
175
+ };
176
+ const recover=()=>wake();
177
+ const scopeChanged=()=>{
178
+ if(!alive)return;
179
+ const next=JSON.stringify(readFileScope(backend,jobId));if(next===scopeKey)return;scopeKey=next;
180
+ generation++;const old=controller;controller=null;old?.abort();wake(true);
59
181
  };
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
- blocked=[400,404,405,410,501].includes(code);const slow=[401,403,429].includes(code);
71
- status(blocked||slow?'error':'reconnecting',false,error instanceof Error?error.message:undefined);
72
- if(!alive||token!==generation||blocked)return;
73
- const delay=slow?30000:Math.min(30000,1000*2**Math.min(attempt++,5));
74
- timer=setTimeout(()=>{timer=undefined;connect();},delay);
75
- };
76
- stopTransport=retire;
77
- 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);}
182
+ const visibility=()=>{
183
+ if(hidden()){generation++;const old=controller;controller=null;old?.abort();clearTimer();dirty=true;}
184
+ else recover();
78
185
  };
79
- 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();}};
80
- owned.retry=()=>{if(blocked)wake();};
81
- if(typeof window!=='undefined'){window.addEventListener('online',wake);document.addEventListener('visibilitychange',wake);}
82
- owned.stop=()=>{alive=false;generation++;if(timer)clearTimeout(timer);controller?.abort();stopTransport?.();if(typeof window!=='undefined'){window.removeEventListener('online',wake);document.removeEventListener('visibilitychange',wake);}};
83
- // Defer initialization until the first listener has been registered.
84
- queueMicrotask(()=>{if(alive){if(mode==='events')connect();else void scan();}});
186
+ const releaseScope=onFileScope(backend,jobId,scopeChanged);
187
+ const releasePreviews=backend.fileSync?onFilePreviewScope(backend.fileSync,jobId,()=>wake()):()=>{};
188
+ if(typeof window!=='undefined'){window.addEventListener('online',recover);document.addEventListener('visibilitychange',visibility);}
189
+ owned.retry=wake;
190
+ owned.stop=()=>{alive=false;clearTimer();controller?.abort();releaseScope();releasePreviews();if(typeof window!=='undefined'){window.removeEventListener('online',recover);document.removeEventListener('visibilitychange',visibility);}};
191
+ queueMicrotask(()=>{if(alive)void scan();});
85
192
  }
86
- const owned=entry;owned.listeners.add(subscription);notifyCallback(subscription.status,{...owned.status});owned.retry?.();
87
- 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();}};
193
+ const owned=entry,subscription={change:listener,status:options.onStatus};owned.listeners.add(subscription);notifyCallback(subscription.status,{...owned.status});
194
+ let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(subscription);if(!owned.listeners.size){if(jobs!.get(jobId)===owned)jobs!.delete(jobId);owned.stop();}releaseOwnedScope();};
88
195
  }
@@ -3,6 +3,7 @@
3
3
  // 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
4
4
  // 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
5
5
  import { CenterError, type BoardBackend } from "../core/client";
6
+ import type { DirListing, FileEntry } from "../core/types";
6
7
  import { fmtSize } from "./fileKind";
7
8
  import { saveBlob, readBoundedBuffer, FileReadLimitError } from "./fileOps";
8
9
  import { buildZip, type ZipEntry } from "./zip";
@@ -68,17 +69,50 @@ export async function listFolder(backend: BoardBackend, jobId: string, dir: stri
68
69
  while (queue.length) {
69
70
  if (signal?.aborted) throw abortError();
70
71
  const cur = queue.shift()!;
71
- let res;
72
+ const entries = new Map<string, FileEntry>();
73
+ const cursors = new Set<string>();
74
+ let cursor: string | undefined, revision: string | undefined;
75
+ let directoryBytes = 0, directoryFiles = 0;
72
76
  try {
73
- res = await withRetry(() => backend.stat(jobId, cur), signal, undefined, backoff);
77
+ do {
78
+ const res = await withRetry(async () => {
79
+ try { return backend.listFiles ? await backend.listFiles(jobId, cur, { cursor, limit: 200, signal }) : await backend.stat(jobId, cur); }
80
+ catch (error) {
81
+ // New short-HTTP clients preserve status on ordinary Errors too.
82
+ const http = error as { status?: number; code?: string; message?: string };
83
+ if (!(error instanceof CenterError) && typeof http.status === "number") throw new CenterError(http.message ?? "列出目录失败", http.code ?? `HTTP_${http.status}`, http.status);
84
+ throw error;
85
+ }
86
+ }, signal, undefined, backoff);
87
+ if (res.type !== "dir") {
88
+ if (cursor) throw new Error("目录在读取时发生变化,请重新下载");
89
+ break;
90
+ }
91
+ const page: DirListing = res;
92
+ if (cursor && (!revision || page.revision !== revision)) throw new Error("目录在读取时发生变化,请重新下载");
93
+ revision ??= page.revision;
94
+ for (const entry of page.entries) {
95
+ if (entries.has(entry.path)) throw new Error("目录分页数据重复,请重新下载");
96
+ entries.set(entry.path, entry);
97
+ if (entry.type === "file") {
98
+ directoryFiles++; directoryBytes += entry.size;
99
+ if (out.length + directoryFiles > maxFiles) throw new Error(`文件超过 ${maxFiles} 个,请进到更小的目录再下载`);
100
+ if (bytes + directoryBytes > maxBytes) throw new Error(`总大小超过 ${fmtSize(maxBytes)},请进到更小的目录或单个下载`);
101
+ }
102
+ }
103
+ cursor = page.nextCursor;
104
+ if (page.truncated && (!cursor || !backend.listFiles)) throw new Error(`「${cur === "." ? "工作区" : cur}」里文件太多,节点只列出了一部分;请进到更小的目录再下载`);
105
+ if (cursor && (!revision || cursors.has(cursor))) throw new Error("目录分页游标无效,请重新下载");
106
+ if (cursor) cursors.add(cursor);
107
+ } while (cursor);
74
108
  } catch (error) {
75
109
  // 子目录在列的时候被任务删了:跳过它;要下载的目录本身没了照常报错。
76
110
  if (cur !== (dir || ".") && error instanceof CenterError && error.status === 404) continue;
111
+ if (error instanceof CenterError && error.status === 409) throw new Error("目录在读取时发生变化,请重新下载");
77
112
  throw error;
78
113
  }
79
- if (res.type !== "dir") continue;
80
- if (res.truncated) throw new Error(`「${cur === "." ? "工作区" : cur}」里文件太多,节点只列出了一部分;请进到更小的目录再下载`);
81
- for (const e of res.entries) {
114
+ // Commit a directory only after every page has the same revision.
115
+ for (const e of entries.values()) {
82
116
  if (e.type === "dir") { queue.push(e.path); continue; }
83
117
  out.push({ path: e.path, size: e.size, mtime: e.mtime });
84
118
  bytes += e.size;
@@ -6,7 +6,7 @@
6
6
  // · 相对路径的样式 / 图片按文件原地址解析(<base>),文档里的 #锚点 在页内滚动(不然会按 <base> 整页跳走)。
7
7
 
8
8
  /** iframe → 宿主:选区(text 为空 = 取消了选择)或滚动了(收起按钮)。 */
9
- export interface FrameSelMessage { cliUiSel: string; text?: string; rect?: { left: number; top: number; width: number } | null; scroll?: boolean }
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
10
 
11
11
  const randomId = () => Math.random().toString(36).slice(2, 12) + Math.random().toString(36).slice(2, 12);
12
12
 
@@ -16,13 +16,15 @@ export const frameToken = randomId;
16
16
  function bridgeScript(token: string) {
17
17
  const t = JSON.stringify(token);
18
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();
19
20
  function send(){var s=getSelection(),text=s&&!s.isCollapsed?String(s).trim():"",rect=null;
20
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})}
21
- function later(){setTimeout(send,0)}
22
+ var timer;function later(){clearTimeout(timer);timer=setTimeout(send,0)}
22
23
  document.addEventListener("mouseup",later);document.addEventListener("keyup",later);
23
- document.addEventListener("selectionchange",function(){var s=getSelection();if(!s||s.isCollapsed)post({text:""})});
24
- addEventListener("scroll",function(){post({scroll:true})},true);
24
+ document.addEventListener("selectionchange",later);
25
+ addEventListener("scroll",function(){post({scroll:true,top:scrollY,left:scrollX})},true);
25
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)});
26
28
  document.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href^="#"]');if(!a)return;e.preventDefault();
27
29
  var id=decodeURIComponent(a.getAttribute("href").slice(1)),el=id?document.getElementById(id)||document.getElementsByName(id)[0]:document.body;el&&el.scrollIntoView()});})();`;
28
30
  }
@@ -41,10 +43,10 @@ export function bridgeDoc(html: string, opts: { token: string; base?: string })
41
43
  const head = `<meta http-equiv="Content-Security-Policy" content="${escAttr(csp)}">`
42
44
  + (opts.base ? `<base href="${escAttr(opts.base)}">` : "")
43
45
  + `<script nonce="${opts.token}">${bridgeScript(opts.token)}</script>`;
44
- // 插在 <head> 里最前面;没有 <head> 就插在 doctype 后面(解析器会自动补出 head),不放到 doctype 前面免得变成怪异模式。
45
- const headTag = /<head(\s[^>]*)?>/i.exec(html);
46
- if (headTag) { const at = headTag.index + headTag[0].length; return html.slice(0, at) + head + html.slice(at); }
47
- const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
48
- if (doctype) return doctype[0] + head + html.slice(doctype[0].length);
49
- return head + html;
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;
50
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
+ }