@spzxg/cli-ui 0.6.5 → 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.
- package/CHANGELOG.md +23 -0
- package/README.md +33 -13
- package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-b8wF7wrN.js} +2 -2
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
- package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +9 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/FileViewer.tsx +22 -9
- package/src/board/fileWatch.ts +9 -3
- package/src/board/frameQuote.ts +2 -2
- package/src/board/textLoad.ts +7 -5
- package/src/board/workspace.ts +15 -5
- package/src/chat/CliChat.tsx +21 -11
- package/src/chat/Composer.tsx +4 -2
- package/src/core/blocks.ts +15 -4
- package/src/core/client.ts +96 -14
- package/src/core/conversation.ts +1 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +68 -27
- package/src/core/types.ts +30 -9
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +9 -3
- package/src/operator/node/Transcript.tsx +7 -2
- package/src/operator/node/boardBackend.ts +6 -4
- package/src/operator/node/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +5 -4
- package/src/operator.base.css +8 -0
- package/src/operator.css +8 -0
- package/src/styles.css +29 -1
- package/src/ui/Popover.tsx +10 -5
- package/src/ui/Transcript.tsx +7 -2
- package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
package/dist/cli-ui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-
|
|
1
|
+
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-BAqonuoD.js";
|
|
2
2
|
export {
|
|
3
3
|
a as CliBoard,
|
|
4
4
|
i as CliBrowserView,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spzxg/cli-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "相互联动的 CliMsgList、CliChat、CliBoard,支持任务中枢和完整 Operator 工作区。",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -37,23 +37,24 @@
|
|
|
37
37
|
"react-dom": "^19.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.20.5",
|
|
40
41
|
"@types/react": "19.2.17",
|
|
41
42
|
"@types/react-dom": "19.2.3",
|
|
42
|
-
"@vitejs/plugin-react": "
|
|
43
|
+
"@vitejs/plugin-react": "4.7.0",
|
|
43
44
|
"jsdom": "^25.0.1",
|
|
44
45
|
"postcss": "^8.5.32",
|
|
45
46
|
"typescript": "^5.7.3",
|
|
46
|
-
"vite": "
|
|
47
|
-
"vitest": "
|
|
47
|
+
"vite": "6.4.5",
|
|
48
|
+
"vitest": "4.1.12"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
51
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
52
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
53
|
+
"@xterm/xterm": "^6.0.0",
|
|
50
54
|
"jszip": "^3.10.2",
|
|
51
55
|
"mammoth": "^1.13.0",
|
|
52
56
|
"react-markdown": "^10.1.0",
|
|
53
57
|
"rehype-highlight": "^7.0.2",
|
|
54
|
-
"remark-gfm": "^4.0.1"
|
|
55
|
-
"@xterm/xterm": "^6.0.0",
|
|
56
|
-
"@xterm/addon-fit": "^0.11.0",
|
|
57
|
-
"@xterm/addon-web-links": "^0.12.0"
|
|
58
|
+
"remark-gfm": "^4.0.1"
|
|
58
59
|
}
|
|
59
60
|
}
|
|
@@ -17,6 +17,13 @@ import { Icon } from "../ui/icons";
|
|
|
17
17
|
|
|
18
18
|
interface FrameMeta { deviceWidth: number; deviceHeight: number }
|
|
19
19
|
type Dialog = Extract<BrowserMessage, { type: "dialog" }>;
|
|
20
|
+
type Tabs = Extract<BrowserMessage, { type: "tabs" }>;
|
|
21
|
+
type Target = { targetId: string; targetEpoch: number };
|
|
22
|
+
function messageTarget(message: BrowserMessage): Target | null {
|
|
23
|
+
return typeof message.targetId === "string" && message.targetId.length > 0 && Number.isSafeInteger(message.targetEpoch) && message.targetEpoch! >= 0
|
|
24
|
+
? { targetId: message.targetId, targetEpoch: message.targetEpoch! } : null;
|
|
25
|
+
}
|
|
26
|
+
const sameTarget = (a: Target | null, b: Target | null) => !!a && !!b && a.targetId === b.targetId && a.targetEpoch === b.targetEpoch;
|
|
20
27
|
|
|
21
28
|
const CAPABILITY_LABEL: Record<string, string> = { config: "任务配置", manual: "手动打开过", ai: "AI 自行启用" };
|
|
22
29
|
const SPECIAL_KEYS: Record<string, number> = {
|
|
@@ -90,6 +97,7 @@ interface BrowserFeed {
|
|
|
90
97
|
/** 发输入用的 viewer id;ready = 输入能送到节点。 */
|
|
91
98
|
viewer: string | null;
|
|
92
99
|
ready: boolean;
|
|
100
|
+
generation: number;
|
|
93
101
|
frames: BrowserFramesState;
|
|
94
102
|
paused: boolean;
|
|
95
103
|
reconnect: () => void;
|
|
@@ -97,7 +105,7 @@ interface BrowserFeed {
|
|
|
97
105
|
* 最近的页面、弹窗、画面:中枢在连上时就推过来,那时画面区(要等状态是「已打开」才挂上)可能还没挂好,
|
|
98
106
|
* 挂上时从这里补上——否则地址栏一直是空的,要等下一次换页。
|
|
99
107
|
*/
|
|
100
|
-
latest: MutableRefObject<{ page?: BrowserMessage; dialog?: BrowserMessage | null; frame?: BrowserMessage }>;
|
|
108
|
+
latest: MutableRefObject<{ generation: number; protocol: boolean; tabs?: Tabs; page?: BrowserMessage; dialog?: BrowserMessage | null; frame?: BrowserMessage }>;
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
/**
|
|
@@ -112,15 +120,44 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
112
120
|
const [ended, setEnded] = useState<string | null>(null);
|
|
113
121
|
const [viewer, setViewer] = useState<string | null>(null);
|
|
114
122
|
const [ready, setReady] = useState(false);
|
|
123
|
+
const [generation, setGeneration] = useState(0);
|
|
115
124
|
const [frames, setFrames] = useState<BrowserFramesState>({ state: "idle" });
|
|
116
125
|
const [retry, setRetry] = useState(0);
|
|
117
126
|
const reconnect = useCallback(() => { setEnded(null); setRetry((n) => n + 1); }, []);
|
|
118
|
-
const latest = useRef<BrowserFeed["latest"]["current"]>({});
|
|
127
|
+
const latest = useRef<BrowserFeed["latest"]["current"]>({ generation: 0, protocol: false });
|
|
128
|
+
const beginViewer = useCallback(() => {
|
|
129
|
+
const previous = latest.current;
|
|
130
|
+
// Epochs belong to one stream. A recovered viewer may start at epoch 1 again.
|
|
131
|
+
// Legacy servers retain their dialog while reconnecting, as before.
|
|
132
|
+
const next = previous.generation + 1;
|
|
133
|
+
latest.current = previous.protocol ? { generation: next, protocol: true } : { ...previous, generation: next };
|
|
134
|
+
setGeneration(next);
|
|
135
|
+
}, []);
|
|
119
136
|
const deliver = useCallback((m: BrowserMessage) => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
137
|
+
const cache = latest.current;
|
|
138
|
+
const target = messageTarget(m);
|
|
139
|
+
const current = cache.page ? messageTarget(cache.page) : null;
|
|
140
|
+
if (target) cache.protocol = true;
|
|
141
|
+
if (m.type === "tabs") {
|
|
142
|
+
cache.protocol = true; cache.tabs = m;
|
|
143
|
+
if (m.activeId === null) { delete cache.page; delete cache.frame; cache.dialog = null; }
|
|
144
|
+
}
|
|
145
|
+
if (m.type === "page") {
|
|
146
|
+
if (cache.protocol && !target) return;
|
|
147
|
+
if (target) {
|
|
148
|
+
cache.protocol = true;
|
|
149
|
+
if (current && (target.targetEpoch < current.targetEpoch || (target.targetEpoch === current.targetEpoch && target.targetId !== current.targetId))) return;
|
|
150
|
+
if (!sameTarget(current, target)) { delete cache.frame; cache.dialog = null; }
|
|
151
|
+
}
|
|
152
|
+
cache.page = m;
|
|
153
|
+
}
|
|
154
|
+
// A frame can arrive late or precede its page announcement. Neither is safe
|
|
155
|
+
// to display or use for coordinate input in the current page.
|
|
156
|
+
else if (cache.protocol && m.type !== "tabs" && m.type !== "error" && (!target || !sameTarget(current, target))) return;
|
|
157
|
+
else if (target && current && !sameTarget(current, target)) return;
|
|
158
|
+
if (m.type === "dialog") cache.dialog = m;
|
|
159
|
+
else if (m.type === "dialog_closed") cache.dialog = null;
|
|
160
|
+
else if (m.type === "frame") cache.frame = m;
|
|
124
161
|
sink.current?.(m);
|
|
125
162
|
}, [sink]);
|
|
126
163
|
|
|
@@ -135,6 +172,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
135
172
|
const connect = () => {
|
|
136
173
|
if (!alive) return;
|
|
137
174
|
let finished = false;
|
|
175
|
+
let seenViewer = false;
|
|
138
176
|
let stopThis: (() => void) | null = null;
|
|
139
177
|
const retire = () => {
|
|
140
178
|
if (!alive || finished) return false;
|
|
@@ -144,7 +182,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
144
182
|
return true;
|
|
145
183
|
};
|
|
146
184
|
stopThis = backend.browserLive!(jobId, {
|
|
147
|
-
onViewer: (id, isReady) => { if (!alive || finished) return; attempt = 0; setViewer(id); setReady(isReady); },
|
|
185
|
+
onViewer: (id, isReady) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(isReady); },
|
|
148
186
|
onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
|
|
149
187
|
onMessage: (message) => { if (alive && !finished) deliver(message); },
|
|
150
188
|
onFrames: (state) => { if (alive && !finished) setFrames(state); },
|
|
@@ -170,7 +208,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
170
208
|
};
|
|
171
209
|
connect();
|
|
172
210
|
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
173
|
-
}, [backend, jobId, mode, visible, retry, deliver]);
|
|
211
|
+
}, [backend, jobId, mode, visible, retry, deliver, beginViewer]);
|
|
174
212
|
|
|
175
213
|
// ── 旧版:状态一条(页面在后台时也断开,切回来再连:人多时少占中枢和节点的连接) ──
|
|
176
214
|
useEffect(() => {
|
|
@@ -213,6 +251,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
213
251
|
const connect = () => {
|
|
214
252
|
if (!alive) return;
|
|
215
253
|
let finished = false;
|
|
254
|
+
let seenViewer = false;
|
|
216
255
|
let stopThis: (() => void) | null = null;
|
|
217
256
|
const again = (detail?: string) => {
|
|
218
257
|
// A stream can emit an end event and then close; retire it only once.
|
|
@@ -226,7 +265,7 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
226
265
|
};
|
|
227
266
|
setFrames({ state: attempt ? "retrying" : "connecting" });
|
|
228
267
|
stopThis = backend.browserFrames(jobId, {
|
|
229
|
-
onViewer: (id) => { if (!alive || finished) return; attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
|
|
268
|
+
onViewer: (id) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
|
|
230
269
|
onMessage: (message) => { if (alive && !finished) deliver(message); },
|
|
231
270
|
onEnd: (end) => {
|
|
232
271
|
if (end.reason !== "client_closed") { again(end.reason === "refused" ? end.detail : undefined); return; }
|
|
@@ -244,16 +283,16 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
244
283
|
};
|
|
245
284
|
connect();
|
|
246
285
|
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
247
|
-
}, [backend, jobId, legacyRunning, retry, deliver]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
286
|
+
}, [backend, jobId, legacyRunning, retry, deliver, beginViewer]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
248
287
|
|
|
249
288
|
// 浏览器关了:上一个页面的地址、弹窗、画面都作废,重新打开时不先闪一下旧画面。
|
|
250
289
|
const running = status?.state === "running";
|
|
251
|
-
useEffect(() => { if (status && !running) latest.current = {}; }, [running, status]);
|
|
290
|
+
useEffect(() => { if (status && !running) latest.current = { generation: latest.current.generation, protocol: false }; }, [running, status]);
|
|
252
291
|
|
|
253
|
-
return { status, setStatus, streamError, ended, viewer, ready, frames, paused: !visible, reconnect, latest };
|
|
292
|
+
return { status, setStatus, streamError, ended, viewer, ready, generation, frames, paused: !visible, reconnect, latest };
|
|
254
293
|
}
|
|
255
294
|
|
|
256
|
-
interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void }
|
|
295
|
+
interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void; clear(): void }
|
|
257
296
|
|
|
258
297
|
/** base64 → 字节。 */
|
|
259
298
|
function decodeBase64(data: string): Uint8Array {
|
|
@@ -276,19 +315,30 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hi
|
|
|
276
315
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
277
316
|
const meta = useRef<FrameMeta | null>(null);
|
|
278
317
|
const seq = useRef({ next: 0, drawn: 0 });
|
|
318
|
+
const imageGeneration = useRef(0);
|
|
279
319
|
const [fallback] = useState(() => typeof createImageBitmap !== "function");
|
|
280
320
|
const [fallbackFrame, setFallbackFrame] = useState<{src:string;meta:FrameMeta|null} | null>(null);
|
|
281
321
|
|
|
282
322
|
useImperativeHandle(ref, () => ({
|
|
323
|
+
clear() {
|
|
324
|
+
imageGeneration.current++;
|
|
325
|
+
seq.current = { next: 0, drawn: 0 };
|
|
326
|
+
meta.current = null;
|
|
327
|
+
setFallbackFrame(null);
|
|
328
|
+
const canvas = canvasRef.current;
|
|
329
|
+
// Resetting dimensions also clears its pixels without requiring a 2d context.
|
|
330
|
+
if (canvas) canvas.width = canvas.width;
|
|
331
|
+
},
|
|
283
332
|
draw(data, frameMeta) {
|
|
284
333
|
const type = data.startsWith("iVBOR") ? "image/png" : "image/jpeg";
|
|
285
334
|
if (fallback) { setFallbackFrame({src:`data:${type};base64,${data}`,meta:frameMeta}); return; }
|
|
286
335
|
const n = ++seq.current.next;
|
|
336
|
+
const generation = imageGeneration.current;
|
|
287
337
|
let bytes: Uint8Array;
|
|
288
338
|
try { bytes = decodeBase64(data); } catch { return; }
|
|
289
339
|
createImageBitmap(new Blob([bytes as BlobPart], { type })).then((bitmap) => {
|
|
290
340
|
const canvas = canvasRef.current;
|
|
291
|
-
if (!canvas || n < seq.current.drawn) { bitmap.close(); return; }
|
|
341
|
+
if (!canvas || generation !== imageGeneration.current || n < seq.current.drawn) { bitmap.close(); return; }
|
|
292
342
|
seq.current.drawn = n;
|
|
293
343
|
meta.current = frameMeta;
|
|
294
344
|
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { canvas.width = bitmap.width; canvas.height = bitmap.height; }
|
|
@@ -335,11 +385,11 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hi
|
|
|
335
385
|
const onPointerDown = (e: React.PointerEvent) => {
|
|
336
386
|
if (!human || !point(e.clientX,e.clientY)) return;
|
|
337
387
|
e.preventDefault();
|
|
338
|
-
onFocusKeys();
|
|
339
388
|
if (e.pointerType === "touch") {
|
|
340
389
|
touch.current = { id: e.pointerId, x: e.clientX, y: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };
|
|
341
390
|
return;
|
|
342
391
|
}
|
|
392
|
+
onFocusKeys();
|
|
343
393
|
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
|
344
394
|
pressed.current = MOUSE_BUTTONS[e.button] ?? "left";
|
|
345
395
|
mouse("mousePressed", e, { button: pressed.current, clickCount: e.detail || 1 });
|
|
@@ -369,10 +419,12 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hi
|
|
|
369
419
|
const t = touch.current;
|
|
370
420
|
if (e.pointerType === "touch" && t && t.id === e.pointerId) {
|
|
371
421
|
touch.current = null;
|
|
372
|
-
if (!t.moved) {
|
|
422
|
+
if (!t.moved && point(e.clientX, e.clientY)) {
|
|
423
|
+
// Send before focusing: the mobile keyboard can resize/reposition the frame.
|
|
373
424
|
// 触屏点一下 = 鼠标点击
|
|
374
425
|
mouse("mousePressed", e, { button: "left", clickCount: 1 });
|
|
375
426
|
mouse("mouseReleased", e, { button: "left", clickCount: 1 });
|
|
427
|
+
onFocusKeys();
|
|
376
428
|
}
|
|
377
429
|
return;
|
|
378
430
|
}
|
|
@@ -435,6 +487,12 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
435
487
|
const screenRef = useRef<ScreenHandle | null>(null);
|
|
436
488
|
const keysRef = useRef<HTMLTextAreaElement | null>(null);
|
|
437
489
|
const [hasFrame, setHasFrame] = useState(false);
|
|
490
|
+
const [tabs, setTabs] = useState<Tabs | null>(null);
|
|
491
|
+
const [switching, setSwitching] = useState<string | null>(null);
|
|
492
|
+
const targetRef = useRef<Target | null>(null);
|
|
493
|
+
const frameReady = useRef(false);
|
|
494
|
+
const pendingTarget = useRef<string | null>(null);
|
|
495
|
+
const pageGeneration = useRef(feed.latest.current.generation);
|
|
438
496
|
const [page, setPage] = useState<{ url: string; title: string }>({ url: "", title: "" });
|
|
439
497
|
const [addr, setAddr] = useState("");
|
|
440
498
|
const [editing, setEditing] = useState(false);
|
|
@@ -458,34 +516,6 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
458
516
|
}, []);
|
|
459
517
|
useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []);
|
|
460
518
|
|
|
461
|
-
// 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
|
|
462
|
-
const latest = feed.latest;
|
|
463
|
-
useEffect(() => {
|
|
464
|
-
const handle = (m: BrowserMessage) => {
|
|
465
|
-
if (m.type === "frame" && m.data) {
|
|
466
|
-
screenRef.current?.draw(m.data, m.metadata ?? null);
|
|
467
|
-
lastFrameAt.current = Date.now();
|
|
468
|
-
setStuck(false);
|
|
469
|
-
setHasFrame(true);
|
|
470
|
-
} else if (m.type === "page") setPage({ url: m.url ?? "", title: m.title ?? "" });
|
|
471
|
-
else if (m.type === "error" && m.message) { if(pendingDialog.current){pendingDialog.current=null;setDialogSending(false);} flash(m.message); }
|
|
472
|
-
else if (m.type === "dialog") { pendingDialog.current = null; setDialogSending(false); setDialog(m); setPromptText(m.defaultPrompt ?? ""); }
|
|
473
|
-
else if (m.type === "dialog_closed") { pendingDialog.current = null; setDialogSending(false); setDialog(null); }
|
|
474
|
-
else if (m.type === "clipboard") {
|
|
475
|
-
const source=latest.current.page;
|
|
476
|
-
const sequence=++clipboardSequence.current;
|
|
477
|
-
setCopied({text:m.text,page:{url:source?.type==='page'?source.url??'':'',title:source?.type==='page'?source.title??'':''}});
|
|
478
|
-
void writeClipboard(m.text).then((ok) => {if(sequence===clipboardSequence.current)flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000);});
|
|
479
|
-
}
|
|
480
|
-
};
|
|
481
|
-
const { page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
|
|
482
|
-
if (lastPage) handle(lastPage);
|
|
483
|
-
if (lastDialog) handle(lastDialog);
|
|
484
|
-
if (lastFrame) handle(lastFrame);
|
|
485
|
-
sink.current = handle;
|
|
486
|
-
return () => { sink.current = null; clipboardSequence.current++; };
|
|
487
|
-
}, [sink, flash, latest]);
|
|
488
|
-
|
|
489
519
|
useEffect(() => { if (!editing) setAddr(page.url); }, [page.url, editing]);
|
|
490
520
|
const onPageRef = useRef(onPage);
|
|
491
521
|
onPageRef.current = onPage;
|
|
@@ -527,6 +557,11 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
527
557
|
backend.browserInput(jobId, viewer, batch).catch((error: unknown) => {
|
|
528
558
|
if(!mountedRef.current||inflight.current!==token||viewerRef.current!==viewer||!controlRef.current)return;
|
|
529
559
|
if (pendingDialog.current && batch.includes(pendingDialog.current)) { pendingDialog.current = null; setDialogSending(false); }
|
|
560
|
+
if (batch.some((message) => message.type === "select_tab")) {
|
|
561
|
+
flash("页面切换未确认,正在重新连接…", 5000);
|
|
562
|
+
reconnect();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
530
565
|
// 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连,避免用户以为点了没反应。
|
|
531
566
|
if (error instanceof CenterError && (error.status === 404 || error.status === 409)) {
|
|
532
567
|
queue.current = [];
|
|
@@ -549,6 +584,11 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
549
584
|
if (msg.type !== "mouse" || msg.event === "mousePressed") flash("画面连接还没就绪,刚才的操作没有送达,请稍后再操作");
|
|
550
585
|
return false;
|
|
551
586
|
}
|
|
587
|
+
if (feed.latest.current.protocol) {
|
|
588
|
+
if (!targetRef.current || (!frameReady.current && msg.type !== "dialog") || pendingTarget.current || pageGeneration.current !== feed.latest.current.generation) return false;
|
|
589
|
+
// Preserve command identity (dialog acknowledgement tracks the queued object).
|
|
590
|
+
Object.assign(msg, targetRef.current);
|
|
591
|
+
}
|
|
552
592
|
lastInputAt.current = Date.now();
|
|
553
593
|
const q = queue.current;
|
|
554
594
|
// 请求卡住时别无限攒:超了先丢鼠标移动,还超就丢最早的。
|
|
@@ -564,7 +604,101 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
564
604
|
} else q.push(msg);
|
|
565
605
|
flush();
|
|
566
606
|
return true;
|
|
567
|
-
}, [flush, flash]);
|
|
607
|
+
}, [flush, flash, feed.latest]);
|
|
608
|
+
|
|
609
|
+
const clearPage = useCallback(() => {
|
|
610
|
+
inputEpoch.current++;
|
|
611
|
+
queue.current = [];
|
|
612
|
+
inflight.current = null;
|
|
613
|
+
frameReady.current = false;
|
|
614
|
+
screenRef.current?.clear();
|
|
615
|
+
setHasFrame(false);
|
|
616
|
+
setPage({ url: "", title: "" });
|
|
617
|
+
setAddr(""); setEditing(false);
|
|
618
|
+
if (keysRef.current) keysRef.current.value = "";
|
|
619
|
+
pendingDialog.current = null; setDialogSending(false); setDialog(null); setPromptText("");
|
|
620
|
+
clipboardSequence.current++; setCopied(null); setNotice(null);
|
|
621
|
+
lastInputAt.current = 0; lastFrameAt.current = 0; setStuck(false);
|
|
622
|
+
}, []);
|
|
623
|
+
const selectTab = (id: string) => {
|
|
624
|
+
const current = targetRef.current;
|
|
625
|
+
if (!human || !feed.ready || !viewerRef.current || tabs?.canSelect !== true || !current || pendingTarget.current || id === current.targetId || pageGeneration.current !== feed.latest.current.generation) return;
|
|
626
|
+
if (!tabs.tabs.some((tab) => tab.id === id)) return;
|
|
627
|
+
clearPage();
|
|
628
|
+
pendingTarget.current = id; setSwitching(id);
|
|
629
|
+
queue.current.push({ type: "select_tab", targetId: id, expectedTargetId: current.targetId, expectedTargetEpoch: current.targetEpoch });
|
|
630
|
+
lastInputAt.current = Date.now();
|
|
631
|
+
flush();
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
useEffect(() => {
|
|
635
|
+
if (pageGeneration.current === feed.generation || !feed.latest.current.protocol) return;
|
|
636
|
+
pageGeneration.current = feed.generation;
|
|
637
|
+
targetRef.current = null; pendingTarget.current = null; setSwitching(null); setTabs(null);
|
|
638
|
+
clearPage();
|
|
639
|
+
}, [feed.generation, feed.latest, clearPage]);
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
if (!switching) return;
|
|
642
|
+
const timer = setTimeout(() => {
|
|
643
|
+
if (!pendingTarget.current) return;
|
|
644
|
+
flash("切换页面尚未确认,正在重新连接…", 5000);
|
|
645
|
+
reconnect();
|
|
646
|
+
}, UNRESPONSIVE_MS);
|
|
647
|
+
return () => clearTimeout(timer);
|
|
648
|
+
}, [switching, flash, reconnect]);
|
|
649
|
+
|
|
650
|
+
// 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
|
|
651
|
+
const latest = feed.latest;
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
const handle = (m: BrowserMessage) => {
|
|
654
|
+
if (pageGeneration.current !== latest.current.generation) {
|
|
655
|
+
pageGeneration.current = latest.current.generation;
|
|
656
|
+
if (latest.current.protocol) {
|
|
657
|
+
targetRef.current = null; pendingTarget.current = null; setSwitching(null); setTabs(null);
|
|
658
|
+
clearPage();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const target = messageTarget(m);
|
|
662
|
+
// Until the page announcement confirms a switch, events from the previous
|
|
663
|
+
// page may still be in transit (notably clipboard results and JS dialogs).
|
|
664
|
+
if (pendingTarget.current && m.type !== "page" && m.type !== "tabs" && m.type !== "error") return;
|
|
665
|
+
if (m.type === "tabs") {
|
|
666
|
+
setTabs(m);
|
|
667
|
+
if (m.activeId === null) { clearPage(); targetRef.current = null; pendingTarget.current = null; setSwitching(null); }
|
|
668
|
+
} else if (m.type === "page") {
|
|
669
|
+
// The old page may still announce its title while a switch is in flight.
|
|
670
|
+
if (pendingTarget.current && sameTarget(targetRef.current, target)) return;
|
|
671
|
+
if (target && !sameTarget(targetRef.current, target)) {
|
|
672
|
+
clearPage(); targetRef.current = target;
|
|
673
|
+
pendingTarget.current = null; setSwitching(null);
|
|
674
|
+
}
|
|
675
|
+
setPage({ url: m.url ?? "", title: m.title ?? "" });
|
|
676
|
+
}
|
|
677
|
+
if (m.type === "frame" && m.data) {
|
|
678
|
+
if (latest.current.protocol && (pendingTarget.current || !sameTarget(targetRef.current, target))) return;
|
|
679
|
+
screenRef.current?.draw(m.data, m.metadata ?? null);
|
|
680
|
+
frameReady.current = true;
|
|
681
|
+
lastFrameAt.current = Date.now();
|
|
682
|
+
setStuck(false);
|
|
683
|
+
setHasFrame(true);
|
|
684
|
+
} else if (m.type === "error" && m.message) { if(pendingDialog.current){pendingDialog.current=null;setDialogSending(false);} if (pendingTarget.current) reconnect(); flash(m.message); }
|
|
685
|
+
else if (m.type === "dialog") { pendingDialog.current = null; setDialogSending(false); setDialog(m); setPromptText(m.defaultPrompt ?? ""); }
|
|
686
|
+
else if (m.type === "dialog_closed") { pendingDialog.current = null; setDialogSending(false); setDialog(null); }
|
|
687
|
+
else if (m.type === "clipboard") {
|
|
688
|
+
const source=latest.current.page;
|
|
689
|
+
const sequence=++clipboardSequence.current;
|
|
690
|
+
setCopied({text:m.text,page:{url:source?.type==='page'?source.url??'':'',title:source?.type==='page'?source.title??'':''}});
|
|
691
|
+
void writeClipboard(m.text).then((ok) => {if(sequence===clipboardSequence.current)flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000);});
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const { tabs: lastTabs, page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
|
|
695
|
+
if (lastTabs) handle(lastTabs);
|
|
696
|
+
if (lastPage) handle(lastPage);
|
|
697
|
+
if (lastDialog) handle(lastDialog);
|
|
698
|
+
if (lastFrame) handle(lastFrame);
|
|
699
|
+
sink.current = handle;
|
|
700
|
+
return () => { sink.current = null; clipboardSequence.current++; };
|
|
701
|
+
}, [sink, flash, latest, clearPage, reconnect]);
|
|
568
702
|
|
|
569
703
|
// 键盘:
|
|
570
704
|
// · 普通字符(不带 Ctrl / ⌘)作为真正的按键发出(带 text)——网页的 keydown、快捷键、输入即搜索都能收到;
|
|
@@ -602,23 +736,34 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
602
736
|
|
|
603
737
|
const { frames, paused } = feed;
|
|
604
738
|
const retrying = frames.state === "retrying";
|
|
739
|
+
const interactive = human && (!latest.current.protocol || (feed.ready && !!targetRef.current && hasFrame && !switching && pageGeneration.current === latest.current.generation));
|
|
740
|
+
const activeTab = switching ?? targetRef.current?.targetId ?? tabs?.activeId;
|
|
741
|
+
const listedTabs = tabs?.tabs.filter((tab, index, all) => typeof tab.id === "string" && !!tab.id && all.findIndex((candidate) => candidate.id === tab.id) === index) ?? [];
|
|
605
742
|
return (
|
|
606
743
|
<div className="bp-viewer">
|
|
744
|
+
{listedTabs.length > 0 && <nav className="bp-tabs" aria-label="任务浏览器页面">
|
|
745
|
+
{listedTabs.map((tab) => <button key={tab.id} className={`bp-tab${activeTab === tab.id ? " active" : ""}`} aria-pressed={activeTab === tab.id}
|
|
746
|
+
title={`${tab.title || "未命名页面"}${tab.url ? `\n${tab.url}` : ""}${!human ? "\n接管浏览器后可切换" : ""}`}
|
|
747
|
+
disabled={!human || !feed.ready || tabs?.canSelect !== true || !!switching || !targetRef.current}
|
|
748
|
+
onClick={() => selectTab(tab.id)}>
|
|
749
|
+
<span>{tab.title || tab.url || "未命名页面"}</span>{switching === tab.id && <span className="bp-tab-wait" aria-label="正在切换">…</span>}
|
|
750
|
+
</button>)}
|
|
751
|
+
</nav>}
|
|
607
752
|
<div className="prev-chrome bp-chrome">
|
|
608
|
-
<button className="bp-nav" disabled={!
|
|
609
|
-
<button className="bp-nav" disabled={!
|
|
610
|
-
<button className="bp-nav" disabled={!
|
|
753
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "back" })} title="后退">‹</button>
|
|
754
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "forward" })} title="前进">›</button>
|
|
755
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "reload" })} title="刷新">↻</button>
|
|
611
756
|
<form className="bp-addr-form" onSubmit={(e) => { e.preventDefault(); const url = normalizeUrl(addr); if (url) nav({ type: "navigate", url }); setEditing(false); (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.blur(); }}>
|
|
612
|
-
<input className="prev-addr bp-addr" value={addr} readOnly={!
|
|
757
|
+
<input className="prev-addr bp-addr" value={addr} readOnly={!interactive} title={page.title || page.url}
|
|
613
758
|
onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} onChange={(e) => setAddr(e.target.value)} />
|
|
614
759
|
</form>
|
|
615
760
|
</div>
|
|
616
|
-
<div className={`bp-screen ${
|
|
761
|
+
<div className={`bp-screen ${interactive ? "live" : ""}`}>
|
|
617
762
|
{/* 画面一直挂着(收到第一帧前隐藏),解码在内存里做,不受宿主页面 CSP 的 img-src 限制。 */}
|
|
618
|
-
<Screen ref={screenRef} human={
|
|
763
|
+
<Screen ref={screenRef} human={interactive} inputEpoch={inputEpoch.current} hidden={!hasFrame} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
|
|
619
764
|
{!hasFrame && (
|
|
620
765
|
<div className="bp-empty">
|
|
621
|
-
{paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
|
|
766
|
+
{switching ? "正在切换页面…" : paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
|
|
622
767
|
{retrying && <><br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={reconnect}>立即重试</button></>}
|
|
623
768
|
</div>
|
|
624
769
|
)}
|
|
@@ -656,7 +801,7 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
656
801
|
ref={keysRef}
|
|
657
802
|
className="bp-keys"
|
|
658
803
|
aria-label="向浏览器输入"
|
|
659
|
-
disabled={!
|
|
804
|
+
disabled={!interactive}
|
|
660
805
|
onKeyDown={onKeyDown}
|
|
661
806
|
onInput={(e) => { if (!(e.nativeEvent as InputEvent).isComposing) flushText(e.currentTarget); }}
|
|
662
807
|
onCompositionEnd={(e) => flushText(e.currentTarget)}
|
package/src/board/FileViewer.tsx
CHANGED
|
@@ -146,6 +146,7 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
146
146
|
const kind: PreviewKind = previewKind(path);
|
|
147
147
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
148
148
|
const [error, setError] = useState<string | null>(null);
|
|
149
|
+
const [downloadError, setDownloadError] = useState<string | null>(null);
|
|
149
150
|
const [copied, setCopied] = useState(false);
|
|
150
151
|
const [busy, setBusy] = useState(false);
|
|
151
152
|
const [mode, setMode] = useState<"preview" | "source">("preview");
|
|
@@ -245,8 +246,13 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
245
246
|
const r = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : { left: box.left + box.width / 2, width: 0, top: box.top + 48 };
|
|
246
247
|
setSel({ text, x: Math.min(Math.max(8, r.left - box.left + r.width / 2), box.width - 8), y: Math.max(40, r.top - box.top) });
|
|
247
248
|
};
|
|
248
|
-
|
|
249
|
-
const
|
|
249
|
+
let selectionTimer: ReturnType<typeof setTimeout> | undefined;
|
|
250
|
+
const onUp = () => {
|
|
251
|
+
if (selectionTimer !== undefined) clearTimeout(selectionTimer);
|
|
252
|
+
selectionTimer = setTimeout(() => { selectionTimer = undefined; read(); }, 0);
|
|
253
|
+
};
|
|
254
|
+
// Native touch selection/handle changes need not generate mouseup or keyup.
|
|
255
|
+
const onChange = onUp;
|
|
250
256
|
const body = bodyRef.current;
|
|
251
257
|
document.addEventListener("mouseup", onUp);
|
|
252
258
|
document.addEventListener("keyup", onUp);
|
|
@@ -257,6 +263,7 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
257
263
|
body?.addEventListener("scroll", onScroll, { passive: true });
|
|
258
264
|
document.addEventListener("mousedown", onDown);
|
|
259
265
|
return () => {
|
|
266
|
+
if (selectionTimer !== undefined) clearTimeout(selectionTimer);
|
|
260
267
|
document.removeEventListener("mousedown", onDown);
|
|
261
268
|
document.removeEventListener("mouseup", onUp);
|
|
262
269
|
document.removeEventListener("keyup", onUp);
|
|
@@ -298,8 +305,8 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
298
305
|
const prev = loadedRef.current?.kind === "text" && textKey.current === key ? loadedRef.current : null;
|
|
299
306
|
textKey.current = key;
|
|
300
307
|
if (!prev) setProgress(0);
|
|
301
|
-
const full = () => loadText(backend, jobId, path, { max: TEXT_MAX, tail, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
|
|
302
|
-
(prev ? refreshText(backend, jobId, path, prev, TEXT_MAX).then((r) => r ?? full(), full) : full()).then(
|
|
308
|
+
const full = () => loadText(backend, jobId, path, { max: TEXT_MAX, tail, signal: ctl.signal, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
|
|
309
|
+
(prev ? refreshText(backend, jobId, path, prev, TEXT_MAX, { signal: ctl.signal }).then((r) => r ?? full(), full) : full()).then(
|
|
303
310
|
(r) => {
|
|
304
311
|
if (!alive) return;
|
|
305
312
|
const box = bodyRef.current;
|
|
@@ -321,8 +328,9 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
321
328
|
}), [backend, jobId, path, name, links]);
|
|
322
329
|
|
|
323
330
|
const download = async () => {
|
|
324
|
-
|
|
325
|
-
|
|
331
|
+
if (busy) return;
|
|
332
|
+
setBusy(true); setDownloadError(null);
|
|
333
|
+
try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setDownloadError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
|
|
326
334
|
};
|
|
327
335
|
const openUrl = links.url ? links.url(path) : src.url;
|
|
328
336
|
const openNew = () => { if (openUrl) window.open(openUrl, "_blank", "noopener"); };
|
|
@@ -380,11 +388,15 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
380
388
|
{onClose && <button className="cli-fv-icon" onClick={onClose} title="关闭">{Icon.x()}</button>}
|
|
381
389
|
</div>
|
|
382
390
|
)}
|
|
391
|
+
{downloadError && <div className="cli-fv-trunc" role="alert">下载失败:{downloadError}
|
|
392
|
+
<button className="cli-fv-btn" disabled={busy} onClick={() => void download()}>重试下载</button>
|
|
393
|
+
<button className="cli-fv-btn" onClick={() => setDownloadError(null)} aria-label="关闭下载错误">关闭</button>
|
|
394
|
+
</div>}
|
|
383
395
|
<div className={`cli-fv-b k-${kind}`} ref={bodyRef}>
|
|
384
396
|
{error || src.error ? (
|
|
385
397
|
<div className="cli-fv-note err">读取失败:{error ?? src.error}
|
|
386
398
|
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={retry}>重试</button>
|
|
387
|
-
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()}>{Icon.download()} 下载</button>
|
|
399
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
388
400
|
</div>
|
|
389
401
|
) : kind === "binary" ? (
|
|
390
402
|
<div className="cli-fv-note">这种文件不能在这里预览。
|
|
@@ -498,8 +510,9 @@ function SourceView({ backend, jobId, path, version }: { backend: BoardBackend;
|
|
|
498
510
|
const [state, setState] = useState<{ text: string; size?: number; truncated?: boolean } | null>(null);
|
|
499
511
|
useEffect(() => {
|
|
500
512
|
let alive = true;
|
|
501
|
-
|
|
502
|
-
|
|
513
|
+
const ctl = new AbortController();
|
|
514
|
+
backend.readText(jobId, path, TEXT_MAX, { signal: ctl.signal }).then((r) => { if (alive) setState({ text: r.text, size: r.size, truncated: r.truncated }); }, (e: unknown) => { if (alive) setState({ text: `读取失败:${e instanceof Error ? e.message : String(e)}` }); });
|
|
515
|
+
return () => { alive = false; ctl.abort(); };
|
|
503
516
|
}, [backend, jobId, path, version]);
|
|
504
517
|
if (!state) return <div className="cli-fv-note">加载中…</div>;
|
|
505
518
|
return (
|
package/src/board/fileWatch.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CenterError, type BoardBackend } from '../core/client';
|
|
1
|
+
import { CenterError, isMissingRoute, 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
4
|
import { listFolder } from './folderDownload';
|
|
@@ -15,7 +15,7 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
15
15
|
let entries=registries.get(backend);if(!entries){entries=new Map();registries.set(backend,entries);}
|
|
16
16
|
let entry=entries.get(jobId);const subscription={change:listener,status:options.onStatus};
|
|
17
17
|
if(!entry){
|
|
18
|
-
|
|
18
|
+
let mode:WorkspaceWatchStatus['mode']=backend.watchFiles?'events':'poll',coverage:WorkspaceWatchStatus['coverage']=mode==='events'?'workspace':'listed-files';
|
|
19
19
|
entry={listeners:new Set(),stop:()=>{},status:{type:'files_watch',jobId,state:'connecting',mode,coverage,complete:false}};entries.set(jobId,entry);
|
|
20
20
|
const owned=entry;let alive=true,baseline:Map<string,FileEntry>|null=null,limited=false,blocked=false,stopTransport:(()=>void)|null=null;
|
|
21
21
|
let timer:ReturnType<typeof setTimeout>|undefined,controller:AbortController|null=null,attempt=0,generation=0;
|
|
@@ -67,7 +67,13 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
67
67
|
const retire=()=>{finished=true;if(stopTransport===retire)stopTransport=null;const dispose=cleanup;cleanup=null;notifyCallback(dispose??undefined,undefined);};
|
|
68
68
|
const close=(error:unknown)=>{
|
|
69
69
|
if(!alive||finished||token!==generation)return;retire();if(!alive||token!==generation)return;const code=error instanceof CenterError?error.status:0;
|
|
70
|
-
|
|
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);
|
|
71
77
|
status(blocked||slow?'error':'reconnecting',false,error instanceof Error?error.message:undefined);
|
|
72
78
|
if(!alive||token!==generation||blocked)return;
|
|
73
79
|
const delay=slow?30000:Math.min(30000,1000*2**Math.min(attempt++,5));
|
package/src/board/frameQuote.ts
CHANGED
|
@@ -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",
|
|
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();
|
package/src/board/textLoad.ts
CHANGED
|
@@ -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;
|