@spzxg/cli-ui 0.6.12 → 0.6.13
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 +13 -2
- package/README.md +2 -2
- package/dist/{cli-ui-embed-B0HDalUb.js → cli-ui-embed-CXaGT9e4.js} +12918 -12885
- package/dist/{cli-ui-index-B0juE2Jq.js → cli-ui-index-bMQJjEBW.js} +2 -2
- package/dist/{cli-ui-jszip.min-CSeehR5Z.js → cli-ui-jszip.min-AMeWMDT5.js} +2 -2
- package/dist/{cli-ui-jszip.min-BBS0YWg7.js → cli-ui-jszip.min-D_w3a6AF.js} +1 -1
- package/dist/{cli-ui-xlsx-Bk3fo5jQ.js → cli-ui-xlsx-DMzew4IO.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/board/CliBoard.tsx +2 -3
- package/src/board/FileViewer.tsx +3 -3
- package/src/board/FilesPane.tsx +1 -1
- package/src/board/fileScope.ts +6 -3
- package/src/board/fileSync.ts +12 -4
- package/src/board/fileSyncProcessor.ts +4 -3
- package/src/board/fileSyncTransport.ts +2 -2
- package/src/board/fileWatch.ts +36 -20
- package/src/core/identity.ts +2 -14
- package/src/core/sha256.ts +21 -0
- package/src/operator/node/Layout.tsx +3 -1
- package/src/operator/node/SessionRail.tsx +2 -2
- package/src/operator.base.css +6 -0
- package/src/operator.css +6 -0
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-CXaGT9e4.js";
|
|
2
2
|
export {
|
|
3
3
|
a as CliBoard,
|
|
4
4
|
i as CliBrowserView,
|
package/package.json
CHANGED
package/src/board/CliBoard.tsx
CHANGED
|
@@ -101,7 +101,7 @@ export interface CliBoardProps extends CliStoreOptions, WorkspaceHandlers {
|
|
|
101
101
|
* 缺省和 quoteToChat 一样。
|
|
102
102
|
*/
|
|
103
103
|
autoQuoteOpenFile?: boolean;
|
|
104
|
-
/**
|
|
104
|
+
/** 保留旧宿主兼容;看板不再显示内置收起按钮。 */
|
|
105
105
|
onCollapse?: () => void;
|
|
106
106
|
/**
|
|
107
107
|
* 收起状态:只显示一条窄栏(点一下展开)。收起时仍然接对话里的打开请求——接到就记下要打开的东西并调 onExpand,
|
|
@@ -125,7 +125,7 @@ export function CliBoard(props: CliBoardProps) {
|
|
|
125
125
|
|
|
126
126
|
interface OpenFile { name?: string; reloadKey?:number; path: string; size?: number; mtime?: number; version: number }
|
|
127
127
|
|
|
128
|
-
function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs,
|
|
128
|
+
function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, collapsed, onExpand, onNavigate, onEvent, onFilesChange, onFilesWatch, onBoardContextChange, watchFiles = true, fileWatchIntervalMs, theme = "light", className, style, inProvider, inlineLayout, inlineOwner, visible: visibilityOverride, onQuoteToChat }: CliBoardProps & { inProvider: boolean }) {
|
|
129
129
|
useComponentEvents(onEvent, {onFilesChange,onFilesWatch,onBoardContextChange}, inProvider);
|
|
130
130
|
const store = useCliStore();
|
|
131
131
|
const backend = store.client.board;
|
|
@@ -471,7 +471,6 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
|
|
|
471
471
|
{info?.dispatched && !file && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 本轮结束后同步,点击立即检查" : "刷新"}>
|
|
472
472
|
<span className={`cli-sync-dot ${running ? "on" : ""}`} />↻
|
|
473
473
|
</button>}
|
|
474
|
-
{onCollapse && <button className="rail-collapse" onClick={onCollapse} title="隐藏面板">{Icon.chevRight()}</button>}
|
|
475
474
|
</div>;
|
|
476
475
|
return <>
|
|
477
476
|
{inlineLayout?.tabsHost && createPortal(tabBar, inlineLayout.tabsHost)}
|
package/src/board/FileViewer.tsx
CHANGED
|
@@ -188,9 +188,9 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
|
|
|
188
188
|
const fileReadAbort = useRef<AbortController | null>(null);
|
|
189
189
|
const readingAbort = useRef<AbortController | null>(null);
|
|
190
190
|
const cancelRead = () => { fileReadAbort.current?.abort(); readingAbort.current?.abort(); setSyncing(false); setParsing(false); setCancelled(true); };
|
|
191
|
-
useEffect(() => retainFileScope(backend, jobId, { paths: [path], dirs: [], onDenied:
|
|
191
|
+
useEffect(() => retainFileScope(backend, jobId, { paths: [path], dirs: [], onDenied: message => {
|
|
192
192
|
fileReadAbort.current?.abort(); readingAbort.current?.abort(); revisionRef.current = undefined;
|
|
193
|
-
setLoaded(null); setHtmlDoc(null); setSyncing(false); setParsing(false); setError("
|
|
193
|
+
setLoaded(null); setHtmlDoc(null); setSyncing(false); setParsing(false); setError(message || "文件访问受限,请检查任务文件目录或访问权限");
|
|
194
194
|
} }), [backend, jobId, path]);
|
|
195
195
|
// 放大:盖住页面看(看板窄的时候看报告、表格、图片)。Esc 还原。
|
|
196
196
|
const [big, setBig] = useState(false);
|
|
@@ -474,7 +474,7 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
|
|
|
474
474
|
const toolbar = <>
|
|
475
475
|
{(copying || copied || busy) && <span className="cli-fv-status" role="status">{copying ? "正在复制路径…" : copied ? "路径已复制" : "正在下载…"}</span>}
|
|
476
476
|
<div className="cli-fv-actions">
|
|
477
|
-
{embedded && <button className="cli-fv-icon" title={syncing || parsing ? "正在同步当前文件" : "刷新当前文件"} aria-label="刷新当前文件" disabled={syncing || parsing} onClick={() => setAttempt(value => value + 1)}>↻</button>}
|
|
477
|
+
{embedded && <button className="cli-fv-icon" title={syncing || parsing ? "正在同步当前文件" : "刷新当前文件"} aria-label="刷新当前文件" aria-busy={syncing || parsing} disabled={syncing || parsing} onClick={() => setAttempt(value => value + 1)}>↻</button>}
|
|
478
478
|
<button ref={moreRef} className="cli-fv-icon cli-fv-more" title="更多文件操作" aria-label="更多文件操作" aria-haspopup="dialog" aria-expanded={actionsOpen} onClick={() => setActionsOpen(open => !open)}>{Icon.dots()}</button>
|
|
479
479
|
{actionsOpen && <Popover className="cli-fv-menu" ariaLabel="文件操作" returnFocusRef={moreRef} onClose={() => setActionsOpen(false)}>
|
|
480
480
|
<div className="cli-fv-menu-head"><strong>文件操作</strong><span title={path}>{path}</span></div>
|
package/src/board/FilesPane.tsx
CHANGED
|
@@ -50,7 +50,7 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
50
50
|
const end = Math.min(entries?.length ?? 0, first + Math.min(80, Math.ceil(viewport / rowHeight) + 8));
|
|
51
51
|
const visiblePaths = (entries ?? []).slice(first, end).filter(file => file.type === "file").map(file => file.path);
|
|
52
52
|
const scopeKey = JSON.stringify(visiblePaths);
|
|
53
|
-
useEffect(() => retainFileScope(backend, jobId, { dirs: [dir || "."], paths: JSON.parse(scopeKey) as string[], onDenied:
|
|
53
|
+
useEffect(() => retainFileScope(backend, jobId, { dirs: [dir || "."], paths: JSON.parse(scopeKey) as string[], onDenied: message => { generation.current++; pageAbort.current?.abort(); setLoading(false); setMoreLoading(false); setEntries(null); setError(message || "工作区访问受限,请检查任务文件目录或访问权限"); } }), [backend, jobId, dir, scopeKey]);
|
|
54
54
|
useEffect(() => {
|
|
55
55
|
if (!listRef.current || typeof ResizeObserver === "undefined") return;
|
|
56
56
|
const observer = new ResizeObserver(([box]) => { if (box.contentRect.height) setViewport(box.contentRect.height); });
|
package/src/board/fileScope.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BoardBackend } from "../core/client";
|
|
2
2
|
|
|
3
|
-
type Scope = { paths: string[]; dirs: string[]; onDenied?: () => void };
|
|
3
|
+
type Scope = { paths: string[]; dirs: string[]; onDenied?: (message?: string) => void };
|
|
4
4
|
const scopes = new WeakMap<BoardBackend, Map<string, Map<object, Scope>>>();
|
|
5
5
|
const listeners = new WeakMap<BoardBackend, Map<string, Set<() => void>>>();
|
|
6
6
|
/** A preview/list owns its subscription. Unmounted/closed directories are not polled. */
|
|
@@ -17,8 +17,11 @@ export function readFileScope(backend: BoardBackend, jobId: string): Scope {
|
|
|
17
17
|
const active = [...(scopes.get(backend)?.get(jobId)?.values() ?? [])];
|
|
18
18
|
return { paths: [...new Set(active.flatMap(scope => scope.paths))].sort(), dirs: [...new Set(active.flatMap(scope => scope.dirs))].sort() };
|
|
19
19
|
}
|
|
20
|
-
export function denyFileScope(backend: BoardBackend, jobId: string) {
|
|
21
|
-
for (const scope of scopes.get(backend)?.get(jobId)?.values() ?? [])
|
|
20
|
+
export function denyFileScope(backend: BoardBackend, jobId: string, message?: string, denied?: { paths: readonly string[]; dirs: readonly string[] }) {
|
|
21
|
+
for (const scope of scopes.get(backend)?.get(jobId)?.values() ?? []) {
|
|
22
|
+
// A list owns its directory; a rejected child file must not erase that list.
|
|
23
|
+
if (!denied || (scope.dirs.length ? scope.dirs.some(dir => denied.dirs.includes(dir || ".")) : scope.paths.some(path => denied.paths.includes(path)))) scope.onDenied?.(message);
|
|
24
|
+
}
|
|
22
25
|
}
|
|
23
26
|
export function onFileScope(backend: BoardBackend, jobId: string, listener: () => void) {
|
|
24
27
|
let jobs = listeners.get(backend); if (!jobs) { jobs = new Map(); listeners.set(backend, jobs); }
|
package/src/board/fileSync.ts
CHANGED
|
@@ -15,7 +15,7 @@ export class FileSyncError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
export interface FileSyncProgress { phase: "manifest" | "chunks" | "verify"; loadedBytes: number; totalBytes: number; downloadedBytes: number; reusedBytes: number }
|
|
17
17
|
export interface FileSyncOptions { signal?: AbortSignal; onProgress?: (progress: FileSyncProgress) => void; decode?: boolean; range?: { start?: number; maxBytes: number; tail?: boolean }; pendingTimeoutMs?: number; /** Require a manifest request started after this explicit refresh. */ fresh?: boolean; /** Remember only successfully viewed ranges, never downloads. */ preview?: boolean }
|
|
18
|
-
export interface FilePreviewRefresh { changed: string[]; error?: Error }
|
|
18
|
+
export interface FilePreviewRefresh { changed: string[]; error?: Error; denied?: Array<{ path: string; message: string }> }
|
|
19
19
|
export interface SyncedFile {
|
|
20
20
|
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
21
21
|
revision: string; namespace: string; hash: string; size: number; start: number; end: number; truncated: boolean;
|
|
@@ -151,7 +151,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
|
|
|
151
151
|
if ([401, 403, 404].includes(status)) {
|
|
152
152
|
last.delete(resource);
|
|
153
153
|
if (previous) {
|
|
154
|
-
if (status === 404) { const scope = { namespace: previous.namespace, taskId: jobId, path }; retireFile(scope); await cache.purgeFile(scope); }
|
|
154
|
+
if (status === 404 || (status === 403 && (error as FileSyncError).code === "file_forbidden")) { const scope = { namespace: previous.namespace, taskId: jobId, path }; retireFile(scope); await cache.purgeFile(scope); }
|
|
155
155
|
else { retireNamespace(previous.namespace); await cache.purgeNamespace(previous.namespace); }
|
|
156
156
|
}
|
|
157
157
|
}
|
|
@@ -261,6 +261,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
|
|
|
261
261
|
// prevent several 100MiB workers allocating full buffers at the same time.
|
|
262
262
|
const work = async (): Promise<FilePreviewRefresh> => {
|
|
263
263
|
const changed = new Set<string>(); let error: Error | undefined, hasRecoverableError = false, retryAfterMs = 0;
|
|
264
|
+
const denied: NonNullable<FilePreviewRefresh["denied"]> = [];
|
|
264
265
|
for (const path of requested) {
|
|
265
266
|
if (signal?.aborted) throw aborted();
|
|
266
267
|
const resource = JSON.stringify([jobId, path]), previous = previews.get(resource);
|
|
@@ -277,7 +278,14 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
|
|
|
277
278
|
}
|
|
278
279
|
} catch (failure) {
|
|
279
280
|
const status = statusOf(failure);
|
|
280
|
-
if (signal?.aborted || (failure instanceof Error && failure.name === "AbortError") || status === 401
|
|
281
|
+
if (signal?.aborted || (failure instanceof Error && failure.name === "AbortError") || status === 401) throw failure;
|
|
282
|
+
if (status === 403) {
|
|
283
|
+
if ((failure as FileSyncError).code !== "file_forbidden") throw failure;
|
|
284
|
+
const scope = { namespace: last.get(resource)?.namespace ?? previous.namespace, taskId: jobId, path };
|
|
285
|
+
last.delete(resource); previews.delete(resource); retireFile(scope); await cache.purgeFile(scope);
|
|
286
|
+
changed.delete(path); denied.push({ path, message: failure instanceof Error ? failure.message : String(failure) });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
281
289
|
if (status === 404) {
|
|
282
290
|
changed.add(path);
|
|
283
291
|
if (previews.get(resource) === previous) {
|
|
@@ -304,7 +312,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
|
|
|
304
312
|
}
|
|
305
313
|
// Preserve the original errors: consumers may share them with other reads.
|
|
306
314
|
const combined = error && hasRecoverableError && retryAfterMs > 0 ? Object.assign(new Error(error.message), error, { name: error.name, retryAfterMs }) : error;
|
|
307
|
-
return { changed: [...changed], ...(combined ? { error: combined } : {}) };
|
|
315
|
+
return { changed: [...changed], ...(combined ? { error: combined } : {}), ...(denied.length ? { denied } : {}) };
|
|
308
316
|
};
|
|
309
317
|
return refreshQueue.run(work, signal);
|
|
310
318
|
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { decodeText } from "../core/textDecode";
|
|
2
|
+
import { sha256 } from "../core/sha256";
|
|
2
3
|
|
|
3
4
|
const aborted = () => new DOMException("文件同步已取消", "AbortError");
|
|
4
5
|
const integrity = (message: string) => Object.assign(new Error(message), { status: 422, code: "integrity" });
|
|
5
|
-
const digest = async (bytes: Uint8Array<ArrayBuffer>) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
|
6
|
+
const digest = async (bytes: Uint8Array<ArrayBuffer>) => globalThis.crypto?.subtle ? [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("") : sha256(bytes);
|
|
6
7
|
type Processed = { bytes: Uint8Array<ArrayBuffer>; text?: string; encoding?: string };
|
|
7
8
|
|
|
8
9
|
/** One worker owns the assembled buffer; chunks are transferred, not copied into a second main-thread file. */
|
|
@@ -18,8 +19,8 @@ export class FileSyncProcessor {
|
|
|
18
19
|
private failed: Error | null = null;
|
|
19
20
|
constructor(private signal: AbortSignal) {
|
|
20
21
|
if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") try {
|
|
21
|
-
const script = `const decode=${decodeText.toString()};let output=null,offset=0;
|
|
22
|
-
const hash=async b=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),v=>v.toString(16).padStart(2,'0')).join('');
|
|
22
|
+
const script = `const decode=${decodeText.toString()},fallback=${sha256.toString()};let output=null,offset=0;
|
|
23
|
+
const hash=async b=>globalThis.crypto?.subtle?Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),v=>v.toString(16).padStart(2,'0')).join(''):fallback(b instanceof Uint8Array?b:new Uint8Array(b));
|
|
23
24
|
onmessage=async({data:m})=>{try{let value;
|
|
24
25
|
if(m.op==='start'){output=new Uint8Array(m.size);offset=0;value=true;}
|
|
25
26
|
if(m.op==='verify'){if(await hash(m.bytes)!==m.hash)throw Error('文件块校验失败');value={bytes:m.bytes};}
|
|
@@ -43,8 +43,8 @@ export function createFileSyncTransport(fetcher: typeof fetch, pathBuilder: (job
|
|
|
43
43
|
const retryAfterMs = retry ? (/^\d+(\.\d+)?$/.test(retry) ? Number(retry) * 1000 : Math.max(0, Date.parse(retry) - Date.now())) : undefined;
|
|
44
44
|
const missingRoute = response.status === 501 || (response.status === 404 && !["file_not_found", "task_not_found", "job_not_found"].includes(error.error ?? ""));
|
|
45
45
|
const code = missingRoute ? "unsupported" : error.error ?? "http_error";
|
|
46
|
-
const common: Record<string, string> = { node_unreachable: "执行节点暂时连不上,请稍后重试", unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_sync_unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_not_found: "文件不存在(可能已删除或改名)", file_forbidden: "
|
|
47
|
-
const message = common[code] ?? (
|
|
46
|
+
const common: Record<string, string> = { node_unreachable: "执行节点暂时连不上,请稍后重试", unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_sync_unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_not_found: "文件不存在(可能已删除或改名)", file_forbidden: error.detail || "文件不在当前任务允许访问的目录中,或没有访问权限", file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
|
|
47
|
+
const message = response.status === 401 ? "登录已失效,请重新登录" : common[code] ?? (response.status === 403 ? error.detail || "没有读取该文件的权限,请检查任务访问权限" : error.detail ?? `文件同步失败(HTTP ${response.status})`);
|
|
48
48
|
throw new FileSyncError(message, response.status, code, retryAfterMs);
|
|
49
49
|
}
|
|
50
50
|
return { response, bytes };
|
package/src/board/fileWatch.ts
CHANGED
|
@@ -78,12 +78,43 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
78
78
|
const state={baseline:new Map(baseline),known:new Set(known)},nextDirectories=new Map(directories),nextCursors=new Map(cursors);
|
|
79
79
|
try{
|
|
80
80
|
const changes:WorkspaceFileChange[]=[];
|
|
81
|
+
let accessError:string|undefined;
|
|
82
|
+
const forbidden=(error:unknown)=>(error as {status?:number;code?:string})?.status===403&&(error as {code?:string}).code==='file_forbidden';
|
|
83
|
+
const deny=(error:unknown,paths:string[],dirs:string[])=>{
|
|
84
|
+
accessError=error instanceof Error?error.message:String(error);
|
|
85
|
+
denyFileScope(backend,jobId,accessError,{paths,dirs});
|
|
86
|
+
};
|
|
87
|
+
// A rejected path in a batch is not evidence that the whole task lost access.
|
|
88
|
+
// Inspect only that batch's metadata; healthy files and directories remain usable.
|
|
89
|
+
const inspect=async(paths:string[],dirs:string[])=>{
|
|
90
|
+
const listed=new Map<string,FileEntry>(),deniedPaths=new Set<string>();
|
|
91
|
+
for(const dir of dirs){
|
|
92
|
+
try{
|
|
93
|
+
const result=await requests.run(()=>backend.stat(jobId,dir||'.'),ctl.signal);if(!current())return;
|
|
94
|
+
if(result.type!=='dir')continue;
|
|
95
|
+
for(const file of result.entries)listed.set(file.path,file);
|
|
96
|
+
const revision=result.revision??JSON.stringify(result.entries.map(file=>[file.path,fingerprint(file)]));
|
|
97
|
+
if(nextDirectories.has(dir)&&nextDirectories.get(dir)!==revision)changes.push({kind:'modified',path:dir});
|
|
98
|
+
nextDirectories.set(dir,revision);
|
|
99
|
+
}catch(error){if(!current())return;if(!forbidden(error))throw error;deny(error,[],[dir||'.']);}
|
|
100
|
+
}
|
|
101
|
+
const files:FileEntry[]=[];
|
|
102
|
+
for(const path of paths){
|
|
103
|
+
const listedFile=listed.get(path);if(listedFile){files.push(listedFile);continue;}
|
|
104
|
+
try{const result=await requests.run(()=>backend.stat(jobId,path),ctl.signal);if(result.type==='file')files.push(result);}
|
|
105
|
+
catch(error){if(!current())return;if(forbidden(error)){deniedPaths.add(path);state.baseline.delete(path);state.known.delete(path);deny(error,[path],[]);}else if((error as {status?:number}).status!==404)throw error;}
|
|
106
|
+
if(!current())return;
|
|
107
|
+
}
|
|
108
|
+
changes.push(...snapshot(files,paths.filter(path=>!deniedPaths.has(path)),state));
|
|
109
|
+
};
|
|
81
110
|
if(backend.pollFiles){
|
|
82
111
|
const activeKeys=new Set<string>();
|
|
83
112
|
const count=Math.max(Math.ceil(scope.paths.length/200),Math.ceil(scope.dirs.length/32));
|
|
84
113
|
for(let index=0;index<count;index++){
|
|
85
114
|
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
|
-
|
|
115
|
+
let batch:Awaited<ReturnType<NonNullable<BoardBackend['pollFiles']>>>;
|
|
116
|
+
try{batch=await requests.run(()=>backend.pollFiles!(jobId,{paths,dirs,cursor:nextCursors.get(key)},ctl.signal),ctl.signal);}
|
|
117
|
+
catch(error){if(!forbidden(error))throw error;nextCursors.delete(key);await inspect(paths,dirs);if(!current())return;continue;}
|
|
87
118
|
if(!current())return;
|
|
88
119
|
if(!batch||typeof batch!=='object')throw invalidResponse('文件变化响应不完整,请重试');
|
|
89
120
|
if(batch.type==='resync'){cursors.delete(key);throw new Error('文件修订已变化,请重新核对');}
|
|
@@ -99,27 +130,12 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
99
130
|
for(const key of nextCursors.keys())if(!activeKeys.has(key))nextCursors.delete(key);
|
|
100
131
|
}else{
|
|
101
132
|
// Compatibility hosts: inspect requested entries, never recurse a workspace.
|
|
102
|
-
|
|
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));
|
|
133
|
+
await inspect(scope.paths,scope.dirs);
|
|
119
134
|
}
|
|
120
135
|
if(!current())return;
|
|
121
136
|
const previews=backend.fileSync?await refreshFilePreviews(backend.fileSync,jobId,scope.paths,ctl.signal):undefined;
|
|
122
137
|
if(!current())return;
|
|
138
|
+
for(const file of previews?.denied??[])deny(new Error(file.message),[file.path],[]);
|
|
123
139
|
const changedPaths=new Set(changes.map(change=>change.path));
|
|
124
140
|
for(const path of previews?.changed??[])if(!changedPaths.has(path)){changes.push({kind:'modified',path});changedPaths.add(path);}
|
|
125
141
|
const active=new Set(scope.paths);for(const path of state.known)if(!active.has(path)){state.known.delete(path);state.baseline.delete(path);}
|
|
@@ -127,7 +143,7 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
127
143
|
baseline=state.baseline;known=state.known;directories=nextDirectories;cursors=nextCursors;
|
|
128
144
|
publish(changes);
|
|
129
145
|
if(previews?.error)throw previews.error;
|
|
130
|
-
failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status('ready');
|
|
146
|
+
failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status(accessError?'error':'ready',accessError);
|
|
131
147
|
}catch(error){if(current()){
|
|
132
148
|
failed++;const code=(error as {status?:number}).status??0;
|
|
133
149
|
const failureCode=(error as {code?:string}).code;
|
|
@@ -139,7 +155,7 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
139
155
|
retryAt=Math.max(Date.now()+base,serverNotBefore)+Math.floor(Math.random()*base/2);
|
|
140
156
|
if(failed>retryDelays.length)exhaustedUntil=Math.max(Date.now()+30000,serverNotBefore);
|
|
141
157
|
}
|
|
142
|
-
if([401,403].includes(code))denyFileScope(backend,jobId);
|
|
158
|
+
if([401,403].includes(code))denyFileScope(backend,jobId,error instanceof Error?error.message:undefined);
|
|
143
159
|
status('error',error instanceof Error?error.message:String(error));
|
|
144
160
|
}}
|
|
145
161
|
finally{if(controller===ctl){
|
package/src/core/identity.ts
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
import type { CenterClientOptions } from './client';
|
|
2
2
|
import { clientId } from './id';
|
|
3
|
+
import { sha256 } from './sha256';
|
|
3
4
|
// A synchronous SHA-256 fingerprint partitions persistent drafts without putting
|
|
4
5
|
// credentials in localStorage keys. It is never used for authentication.
|
|
5
|
-
const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
|
6
|
-
const rotate = (x:number,n:number)=>(x>>>n)|(x<<(32-n));
|
|
7
6
|
export function connectionFingerprint(text:string):string {
|
|
8
|
-
|
|
9
|
-
const view=new DataView(data.buffer);view.setUint32(data.length-8,Math.floor(raw.length/0x20000000));view.setUint32(data.length-4,(raw.length*8)>>>0);
|
|
10
|
-
const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
|
|
11
|
-
for(let offset=0;offset<data.length;offset+=64){
|
|
12
|
-
for(let i=0;i<16;i++)w[i]=view.getInt32(offset+i*4);
|
|
13
|
-
for(let i=16;i<64;i++){const x=w[i-15],y=w[i-2];w[i]=(w[i-16]+(rotate(x,7)^rotate(x,18)^(x>>>3))+w[i-7]+(rotate(y,17)^rotate(y,19)^(y>>>10)))|0;}
|
|
14
|
-
let [a,b,c,d,e,f,g,h]=hash;
|
|
15
|
-
for(let i=0;i<64;i++){const t1=(h+(rotate(e,6)^rotate(e,11)^rotate(e,25))+((e&f)^(~e&g))+K[i]+w[i])|0,t2=((rotate(a,2)^rotate(a,13)^rotate(a,22))+((a&b)^(a&c)^(b&c)))|0;
|
|
16
|
-
h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
|
|
17
|
-
[a,b,c,d,e,f,g,h].forEach((value,i)=>{hash[i]=(hash[i]+value)|0;});
|
|
18
|
-
}
|
|
19
|
-
return hash.map(value=>(value>>>0).toString(16).padStart(8,'0')).join('');
|
|
7
|
+
return sha256(new TextEncoder().encode(text));
|
|
20
8
|
}
|
|
21
9
|
const customScopes=new WeakMap<object,string>();
|
|
22
10
|
export function connectionDraftScope(options:CenterClientOptions&{namespace?:string|null;client?:object}):string {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Keep this byte-only fallback self-contained so file workers can use the same implementation. */
|
|
2
|
+
export function sha256(bytes: Uint8Array): string {
|
|
3
|
+
const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
|
4
|
+
const rotate = (x:number,n:number)=>(x>>>n)|(x<<(32-n));
|
|
5
|
+
// Only the final one or two blocks need padding; do not copy an entire large file.
|
|
6
|
+
const blockBytes=bytes.length-bytes.length%64,tail=new Uint8Array(bytes.length%64<56?64:128);
|
|
7
|
+
tail.set(bytes.subarray(blockBytes));tail[bytes.length%64]=128;
|
|
8
|
+
const input=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength),padding=new DataView(tail.buffer);
|
|
9
|
+
padding.setUint32(tail.length-8,Math.floor(bytes.length/0x20000000));padding.setUint32(tail.length-4,(bytes.length*8)>>>0);
|
|
10
|
+
const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
|
|
11
|
+
for(let offset=0;offset<blockBytes+tail.length;offset+=64){
|
|
12
|
+
const view=offset<blockBytes?input:padding,base=offset<blockBytes?offset:offset-blockBytes;
|
|
13
|
+
for(let i=0;i<16;i++)w[i]=view.getInt32(base+i*4);
|
|
14
|
+
for(let i=16;i<64;i++){const x=w[i-15],y=w[i-2];w[i]=(w[i-16]+(rotate(x,7)^rotate(x,18)^(x>>>3))+w[i-7]+(rotate(y,17)^rotate(y,19)^(y>>>10)))|0;}
|
|
15
|
+
let [a,b,c,d,e,f,g,h]=hash;
|
|
16
|
+
for(let i=0;i<64;i++){const t1=(h+(rotate(e,6)^rotate(e,11)^rotate(e,25))+((e&f)^(~e&g))+K[i]+w[i])|0,t2=((rotate(a,2)^rotate(a,13)^rotate(a,22))+((a&b)^(a&c)^(b&c)))|0;
|
|
17
|
+
h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
|
|
18
|
+
[a,b,c,d,e,f,g,h].forEach((value,i)=>{hash[i]=(hash[i]+value)|0;});
|
|
19
|
+
}
|
|
20
|
+
return hash.map(value=>(value>>>0).toString(16).padStart(8,'0')).join('');
|
|
21
|
+
}
|
|
@@ -88,7 +88,9 @@ export function ColResize({ min, max, onWidth, onReset, side = "left" }: { min:
|
|
|
88
88
|
const move = (e: MouseEvent) => {
|
|
89
89
|
if (!dragging.current) return;
|
|
90
90
|
if (side === "right") {
|
|
91
|
-
|
|
91
|
+
// WorkspaceDrawer keeps display:contents wrappers on desktop; measure the visible rail.
|
|
92
|
+
const next = ref.current?.nextElementSibling as HTMLElement | null;
|
|
93
|
+
const col = next?.querySelector<HTMLElement>(".sess-rail") ?? next;
|
|
92
94
|
if (!col) return;
|
|
93
95
|
const right = col.getBoundingClientRect().right;
|
|
94
96
|
onWidthRef.current(Math.max(min, Math.min(max, Math.round(right - e.clientX))));
|
|
@@ -122,7 +122,7 @@ function ContextPane({ task, sessions, running, onClear }: { task: TaskRow; sess
|
|
|
122
122
|
);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear,
|
|
125
|
+
export function SessionRail({ project, task, sessions, running, onResolveWithAI, onMerged, onPrCreated, onClear, onSwitchToChat,
|
|
126
126
|
tabs, availableTabs, defaultTab, autoContext, watchFiles = true, fileWatchIntervalMs, onFilesChange, onFilesWatch, onBoardContextChange, onEvent, openRequest, onRequestHandled, onQuote, inlineLayout, onExpand, visible = true }: OperatorBoardOptions & {
|
|
127
127
|
project: ProjectRow; task: TaskRow; sessions: Session[]; running: boolean;
|
|
128
128
|
onResolveWithAI: (taskId: string) => Promise<ResolveResult>; onMerged?: () => void;
|
|
@@ -268,7 +268,7 @@ export function SessionRail({ project, task, sessions, running, onResolveWithAI,
|
|
|
268
268
|
</div>
|
|
269
269
|
<PanelControls toolbarRef={setToolbarHost} available={available} open={panels.open} label={label} onShow={id=>{panels.show(id);setTab(id);inlineLayout?.onActivate();}} onClose={close}
|
|
270
270
|
upload={available.includes('files')?{onChoose:upload.choose,disabled:upload.busy||!uploader,hint:canUpload?undefined:'启动会话并创建工作区后可上传文件'}:undefined}/>
|
|
271
|
-
{inlineLayout
|
|
271
|
+
{inlineLayout && onExpand && <button className="rail-collapse" onClick={onExpand} title="展开右侧看板" aria-label="展开看板">{Icon.chevRight()}</button>}
|
|
272
272
|
</div>;
|
|
273
273
|
return <aside className={`sess-rail${inlineLayout?' sess-rail-inline':''}`} aria-label="任务看板" hidden={!contentVisible} style={!contentVisible?{display:'none'}:undefined} onPointerDown={()=>activateBoardState(task.id,owner)} onClickCapture={()=>activateBoardState(task.id,owner)} onFocusCapture={()=>activateBoardState(task.id,owner)}>
|
|
274
274
|
{inlineLayout ? inlineLayout.tabsHost&&createPortal(railTabs,inlineLayout.tabsHost) : railTabs}
|
package/src/operator.base.css
CHANGED
|
@@ -1871,6 +1871,12 @@ button.pop-item:hover{background:var(--surface-2);}
|
|
|
1871
1871
|
[data-chat-layout]>.cli-refs,[data-chat-layout]>.cli-queue{width:calc(100% - 2 * var(--cli-chat-gutter) - var(--cli-chat-scrollbar,0px));max-width:var(--cli-chat-content-max);margin-inline:auto;margin-inline-start:max(var(--cli-chat-gutter),calc((100% - var(--cli-chat-scrollbar,0px) - var(--cli-chat-content-max)) / 2));flex-shrink:0;}
|
|
1872
1872
|
@container chat-column (max-width:900px){[data-chat-layout]>*{--cli-chat-content-max:100%;}}
|
|
1873
1873
|
@container chat-column (max-width:640px){[data-chat-layout]>*{--cli-chat-gutter:12px;}.session [data-chat-layout]>.composer{padding-inline-end:var(--cli-chat-gutter);}}
|
|
1874
|
+
@media (min-width:761px){
|
|
1875
|
+
[data-chat-layout]{--cli-chat-gutter:32px;}
|
|
1876
|
+
.session [data-chat-layout] .composer{padding-inline:var(--cli-chat-gutter);padding-inline-end:calc(var(--cli-chat-gutter) + var(--cli-chat-scrollbar,0px));}
|
|
1877
|
+
[data-chat-layout] .composer>.composer-inner{width:100%;max-width:var(--cli-chat-content-max);margin-inline:auto;}
|
|
1878
|
+
@container chat-column (max-width:640px){[data-chat-layout]>*{--cli-chat-gutter:24px;}.session [data-chat-layout] .composer{padding-inline-end:var(--cli-chat-gutter);}}
|
|
1879
|
+
}
|
|
1874
1880
|
.task-origin{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--ink-3);padding-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
1875
1881
|
.fork-sources{display:flex;flex-wrap:wrap;gap:6px;padding:12px 14px 0;}
|
|
1876
1882
|
.fork-sources>span{max-width:100%;display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--line);background:var(--surface-2);border-radius:8px;font-size:11px;color:var(--ink-2);overflow-wrap:anywhere;}
|
package/src/operator.css
CHANGED
|
@@ -1871,6 +1871,12 @@
|
|
|
1871
1871
|
.cli-ui [data-chat-layout]>.cli-refs,.cli-ui [data-chat-layout]>.cli-queue{width:calc(100% - 2 * var(--cli-chat-gutter) - var(--cli-chat-scrollbar,0px));max-width:var(--cli-chat-content-max);margin-inline:auto;margin-inline-start:max(var(--cli-chat-gutter),calc((100% - var(--cli-chat-scrollbar,0px) - var(--cli-chat-content-max)) / 2));flex-shrink:0;}
|
|
1872
1872
|
@container chat-column (max-width:900px){.cli-ui [data-chat-layout]>*{--cli-chat-content-max:100%;}}
|
|
1873
1873
|
@container chat-column (max-width:640px){.cli-ui [data-chat-layout]>*{--cli-chat-gutter:12px;}.cli-ui .session [data-chat-layout]>.composer{padding-inline-end:var(--cli-chat-gutter);}}
|
|
1874
|
+
@media (min-width:761px){
|
|
1875
|
+
.cli-ui [data-chat-layout]{--cli-chat-gutter:32px;}
|
|
1876
|
+
.cli-ui .session [data-chat-layout] .composer{padding-inline:var(--cli-chat-gutter);padding-inline-end:calc(var(--cli-chat-gutter) + var(--cli-chat-scrollbar,0px));}
|
|
1877
|
+
.cli-ui [data-chat-layout] .composer>.composer-inner{width:100%;max-width:var(--cli-chat-content-max);margin-inline:auto;}
|
|
1878
|
+
@container chat-column (max-width:640px){.cli-ui [data-chat-layout]>*{--cli-chat-gutter:24px;}.cli-ui .session [data-chat-layout] .composer{padding-inline-end:var(--cli-chat-gutter);}}
|
|
1879
|
+
}
|
|
1874
1880
|
.cli-ui .task-origin{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--ink-3);padding-top:7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
1875
1881
|
.cli-ui .fork-sources{display:flex;flex-wrap:wrap;gap:6px;padding:12px 14px 0;}
|
|
1876
1882
|
.cli-ui .fork-sources>span{max-width:100%;display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--line);background:var(--surface-2);border-radius:8px;font-size:11px;color:var(--ink-2);overflow-wrap:anywhere;}
|