diff --git a/docs/freecad-full-parity-plan.zh-CN.md b/docs/freecad-full-parity-plan.zh-CN.md index 7713263..5345ed2 100644 --- a/docs/freecad-full-parity-plan.zh-CN.md +++ b/docs/freecad-full-parity-plan.zh-CN.md @@ -330,7 +330,7 @@ Geometry Facade 还接入了 Bitbybit IO 的 STEP 和 ASCII STL 导出,作为 | SK-04 求解器边界 | Sketch 节点通过可替换 executor 求解;冲突/无效约束阻止下游,求解结果作为对象更新返回 | 基础 Sketch solve 与异步对象更新测试通过 | 当前是基础 TypeScript solver,不是 `planegcs` WASM;高级约束、拖拽、外部几何和 B-spline 未完成 | | FC-03 ZIP 安全读取 | 先读中央目录再解压;限制 archive/entry/total 字节、压缩比、条目数;拒绝路径穿越、重复路径、加密项、ZIP64 和 XML entity | 缺 Document.xml、路径穿越、压缩炸弹测试通过 | 尚未恢复 BRep/Shape、视图、表达式和参数化对象 | | FC-04/FC-07 只读报告 | `project.fcstd.inspect()` 解析 Document.xml 对象声明、Label、属性数量和类型;未知类型为 proxy,Python 类型为 blocked;脚本资源只告警且不执行 | recognized/proxy/blocked 分类和 Facade-only 测试通过 | `metadata-compatible` 仅表示 XML 元数据可读,不表示 FCStd 可编辑或 round-trip | -| P2-04 Document load | `app.document.load()` 将 Project 快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态并保留 Undo 边界;Projects 页面已调用该 Facade API | 保存→修改→加载后属性恢复、选择清空、任务清空测试通过 | FCStd 导入仍是只读 metadata/proxy;跨文档 ShapeHandle 和恢复后自动几何重算仍待完成 | +| P2-04 Project list/load | `project.list()` 暴露 SQLite/内存快照摘要,`app.document.load()` 将选定快照作为活动 Document 事务恢复,清理旧重算/Shape/Task 状态并保留 Undo 边界;Projects 页面按 documentId 调用 Facade API | 列表摘要、保存→修改→加载后属性恢复、选择清空、任务清空测试通过 | FCStd 导入仍是只读 metadata/proxy;跨文档 ShapeHandle 和恢复后自动几何重算仍待完成 | | P2-03 Resource/recovery fallback | SQLite Worker 在无 OPFS 的浏览器中使用内存资源表,保留 SHA-256 身份和引用计数;恢复报告区分数据库完整但目标文档缺失的 `unavailable`;销毁 Worker 时清空临时资源 | Worker 类型检查、生产构建通过 | 内存资源会随刷新丢失;配额回收、持久资源校验和大文件压力仍待实现 | | PART-07 Shape check | `check-shape` 使用 Facade `geometry.topology()` 生成面/边/顶点数量和 `SHAPE_CHECK_PASSED` 信息诊断;空缓存、空拓扑和 Worker 错误分别返回结构化失败诊断 | 无 Shape、错误选择前置和拓扑调用边界通过测试/构建 | 当前是网格派生拓扑检查,不等价于 OCCT `BRepCheck_Analyzer`,完整修复建议和自交诊断仍待实现 | | P1-02 命令准确性 | manifest 仍可完整展示;Part `primitive/union/cut/intersection` 与 Part Design 核心命令已有 Facade executor;命令状态同时检查工作台和对象类型选择谓词;其余未接入 executor 的命令返回 disabled 与 `COMMAND_UNIMPLEMENTED`,不再静默完成 | Part 基本体/布尔 Shape 缓存、选择不兼容时的禁用原因、`linear-pattern` 状态与诊断测试通过 | 其余工作台要逐项增加真实 executor、Task 参数和黄金回放后才能启用 | diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index b156097..dc57bb2 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1595,7 +1595,7 @@ P0 基线/治理 | P2-03 OPFS 资源管理 | `IN PROGRESS` | Worker 已提供 SHA-256 内容寻址、`bitbybit-assets` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | Worker memory resource fallback | `PASS` | Worker 在 SQLite WASM 无 OPFS 时使用 transient content-addressed resource map,保持 `put/get/release` 引用计数语义;不再把资源 API 错误地绑定到 OPFS | | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;`project.recovery(documentId)` 已提供 SQLite/内存完整性、最后保存版本和警告报告,崩溃点检测和用户可配置策略仍待补齐 | -| Document load/recovery transaction | `PASS` | `app.document.load(documentId)` 通过 Project Facade 恢复 SQLite/内存快照,取消旧重算、释放缓存 Shape、清空选择/任务并提交可撤销的活动文档;Recovery 对“数据库完整但目标快照不存在”返回 `unavailable`,工程管理页已接入默认本地快照打开流程 | +| Document list/load/recovery transaction | `PASS` | `project.list()` 从 SQLite/内存快照返回 documentId、版本、对象数、dirty/readOnly 和更新时间;`app.document.load(documentId)` 通过 Project Facade 恢复快照,取消旧重算、释放缓存 Shape、清空选择/任务并提交可撤销的活动文档;Recovery 对“数据库完整但目标快照不存在”返回 `unavailable`,工程管理页按真实 ID 打开 | | P2-05 多标签页写入协调 | `DONE (降级可观测)` | `SqliteProjectPersistence` 在保存和资源写入外包 `navigator.locks` 独占锁,成功保存通过 `BroadcastChannel` 广播文档版本,Facade 暴露 `project.subscribeExternalChanges()`;无 Web Locks 的运行时能力标记为 `local-queue`,不宣称跨标签页互斥 | | P5-01 Three.js 视口适配器 | `IN PROGRESS` | `src/facade/threeViewport.ts` 使用 `three@0.185.1`,已挂载 WebGL2 场景和资源释放;React 视口通过 Facade OCCT 预览链生成 Pad/Pocket Mesh | | P3-01 Bitbybit OCCT WASM 运行时 | `IN PROGRESS` | 精确锁定 `@bitbybit-dev/occt-worker@1.1.1`,专用 Worker 可加载 34,524,750 字节 OCCT WASM;尚未建立 FreeCAD/OCCT 自构建脚本,不能标记完成 | diff --git a/src/App.tsx b/src/App.tsx index 3427d77..77c73b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,7 +54,7 @@ import { ZoomOut, } from 'lucide-react' import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' -import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade' +import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type ProjectSummary, type PropertyValue, type ShapeHandle } from './facade' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Workbench = WorkbenchId @@ -503,13 +503,20 @@ function ProjectCard({ project, index, onClick }: { project: typeof projects[num } function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice, facade }: { onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { - const openSavedProject = async () => { - const loaded = await facade.app.document.load('doc-pump-housing') + const [savedProjects, setSavedProjects] = useState([]) + const [loading, setLoading] = useState(true) + useEffect(() => { + let cancelled = false + void facade.project.list().then((items) => { if (!cancelled) setSavedProjects(items) }).catch((error: unknown) => { if (!cancelled) showNotice(`Project list unavailable: ${error instanceof Error ? error.message : String(error)}`) }).finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [facade, showNotice]) + const openSavedProject = async (documentId: string) => { + const loaded = await facade.app.document.load(documentId) if (!loaded) { showNotice('No saved local snapshot found'); return } showNotice(`Loaded ${loaded.label}`) onOpenWorkspace() } - return
} />
/
Sort by
{projects.map((project, index) => )}
Local storage3.4 GB of 10 GB used · Last backup just now
+ return
} />
/
Sort by
{loading ?
Loading local projects
: savedProjects.length === 0 ?
No saved local projects
: savedProjects.map((project, index) => { const state = project.readOnly ? 'Read only' : project.dirty ? 'Unsaved' : 'Saved'; return })}
Local storage{savedProjects.length} saved project{savedProjects.length === 1 ? '' : 's'} · browser-managed capacity
} function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) { diff --git a/src/facade/index.ts b/src/facade/index.ts index 0b7fb93..1d93e06 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -3,7 +3,7 @@ export { createSqliteProjectPersistence, PersistenceWriteQueue, ProjectAutosaveS export { ThreeViewportAdapter } from './threeViewport' export { assertShapeHandleIntegrity, BitbybitGeometryRuntime, normalizeBitbybitMesh, validateBooleanCutInput, validateBooleanIntersectionInput, validateBooleanUnionInput, validateBoxInput, validateConeInput, validateCylinderInput, validatePadInput, validatePlacementInput, validatePlanarProfile, validatePocketInput, validateRevolutionInput, validateSphereInput } from './geometryRuntime' export { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_SQL, PROJECT_SCHEMA_VERSION } from './projectSchema' -export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types' +export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, ChamferInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, FilletInput, GeometryCapabilities, GeometryDocumentContext, GeometryFileExport, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, PropertyValue, RecomputeResult, RevolutionInput, SetExpressionInput, SetPropertyInput, ShapeHandle, SubshapeRef, SubshapeTopology, TaskSnapshot } from './types' export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } from './topologyNaming' export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher' export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index f29acc1..d158664 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -579,7 +579,7 @@ export function createMockFacade(): BitBybitWebCadFacade { gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId, state.document.objects.find((object) => object.id === state.selectedObjectId)?.typeId), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } }, selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') }, task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } }, - project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource }, + project: { capabilities: () => projectPersistence.capabilities(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), list: () => projectPersistence.list(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource }, geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } }, viewport: { createAdapter: () => new ThreeViewportAdapter() }, getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify, diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts index 252ce53..3ebff74 100644 --- a/src/facade/persistenceWorker.ts +++ b/src/facade/persistenceWorker.ts @@ -1,9 +1,10 @@ import sqlite3InitModule, { type Database, type Sqlite3Static } from '@sqlite.org/sqlite-wasm' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION } from './projectSchema' -import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource } from './types' +import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectRecoveryReport, ProjectResource, ProjectSummary } from './types' type PersistenceRequest = | { id: number; type: 'initialize' } + | { id: number; type: 'list-projects' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document'; documentId: string } | { id: number; type: 'recovery-report'; documentId: string } @@ -14,6 +15,7 @@ type PersistenceRequest = type PersistenceResponse = | { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } + | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } @@ -101,6 +103,21 @@ const saveDocument = (document: DocumentSnapshot) => { return { documentId: document.id, documentVersion: document.version, persistedAt: now, mode: capabilities.mode } } +const listProjects = (): ProjectSummary[] => { + if (!database) throw new Error('Persistence database is not initialized.') + const rows = database.exec({ + sql: `SELECT documents.id AS document_id, documents.label, documents.version, documents.dirty, documents.read_only, projects.updated_at, COUNT(objects.id) AS object_count + FROM documents + JOIN projects ON projects.id = documents.project_id + LEFT JOIN objects ON objects.document_id = documents.id + GROUP BY documents.id, documents.label, documents.version, documents.dirty, documents.read_only, projects.updated_at + ORDER BY projects.updated_at DESC`, + rowMode: 'object', + returnValue: 'resultRows', + }) as Array> + return rows.map((row) => ({ documentId: String(row.document_id), label: String(row.label), documentVersion: Number(row.version), objectCount: Number(row.object_count), dirty: Boolean(row.dirty), readOnly: Boolean(row.read_only), updatedAt: Number(row.updated_at) })) +} + const loadDocument = (documentId: string): DocumentSnapshot | null => { if (!database) throw new Error('Persistence database is not initialized.') const documents = database.exec({ sql: 'SELECT id, label, version, dirty, read_only, units, recompute_json FROM documents WHERE id = ?', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> @@ -208,6 +225,7 @@ const handle = async (request: PersistenceRequest): Promise if (request.type === 'initialize') return { id: request.id, ok: true, type: 'initialized', capabilities: await initialize() } if (request.type === 'dispose') { database?.close(); database = undefined; transientResources.clear(); return { id: request.id, ok: true, type: 'disposed' } } await initialize() + if (request.type === 'list-projects') return { id: request.id, ok: true, type: 'projects-listed', projects: listProjects() } if (request.type === 'save-document') { const result = saveDocument(request.document); return { id: request.id, ok: true, type: 'saved', ...result } } if (request.type === 'load-document') return { id: request.id, ok: true, type: 'loaded', document: loadDocument(request.documentId) } if (request.type === 'recovery-report') return { id: request.id, ok: true, type: 'recovery-report', report: recoveryReport(request.documentId) } diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index 8d93fdb..3f0559c 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -1,9 +1,9 @@ -import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, Unsubscribe } from './types' +import type { DocumentSnapshot, PersistenceCapabilities, ProjectChangeNotice, ProjectRecoveryReport, ProjectResource, ProjectSaveResult, ProjectSummary, Unsubscribe } from './types' import { cloneSketch } from './sketcher' -type WorkerRequest = { id: number; type: 'initialize' | 'dispose' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string } -type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string } -type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string } +type WorkerRequest = { id: number; type: 'initialize' | 'dispose' | 'list-projects' } | { id: number; type: 'save-document'; document: DocumentSnapshot } | { id: number; type: 'load-document' | 'recovery-report'; documentId: string } | { id: number; type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { id: number; type: 'get-resource'; hash: string } | { id: number; type: 'release-resource'; hash: string } +type WorkerInput = { type: 'initialize' | 'dispose' | 'list-projects' } | { type: 'save-document'; document: DocumentSnapshot } | { type: 'load-document' | 'recovery-report'; documentId: string } | { type: 'put-resource'; bytes: ArrayBuffer; mediaType: string } | { type: 'get-resource'; hash: string } | { type: 'release-resource'; hash: string } +type WorkerResponse = { id: number; ok: true; type: 'initialized'; capabilities: PersistenceCapabilities } | { id: number; ok: true; type: 'projects-listed'; projects: ProjectSummary[] } | { id: number; ok: true; type: 'saved'; documentId: string; documentVersion: number; persistedAt: number; mode: PersistenceCapabilities['mode'] } | { id: number; ok: true; type: 'loaded'; document: DocumentSnapshot | null } | { id: number; ok: true; type: 'recovery-report'; report: ProjectRecoveryReport } | { id: number; ok: true; type: 'resource-put'; resource: ProjectResource } | { id: number; ok: true; type: 'resource-get'; bytes: ArrayBuffer | null } | { id: number; ok: true; type: 'resource-released' } | { id: number; ok: true; type: 'disposed' } | { id: number; ok: false; error: string } const unavailable: PersistenceCapabilities = { mode: 'unavailable', sqliteWasm: false, opfs: false, schemaVersion: 0, reason: 'Persistence Worker is unavailable in this environment.' } const cloneDocument = (document: DocumentSnapshot): DocumentSnapshot => ({ ...document, tree: document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined })), dependencies: document.dependencies?.map((edge) => ({ ...edge })), recompute: document.recompute ? { ...document.recompute, dirtyObjects: [...document.recompute.dirtyObjects], order: [...document.recompute.order], objectStates: { ...document.recompute.objectStates }, errors: document.recompute.errors.map((error) => ({ ...error })) } : undefined }) @@ -12,6 +12,7 @@ export interface ProjectPersistenceClient { initialize(): Promise capabilities(): PersistenceCapabilities subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe + list(): Promise save(document: DocumentSnapshot): Promise load(documentId: string): Promise recovery(documentId: string): Promise @@ -75,7 +76,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { private nextRequestId = 0 private currentCapabilities = unavailable private initialized: Promise | null = null - private readonly fallbackSnapshots = new Map() + private readonly fallbackSnapshots = new Map() private readonly fallbackResources = new Map() private readonly pending = new Map void; reject: (error: Error) => void }>() private readonly writeQueue = new PersistenceWriteQueue() @@ -109,13 +110,23 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { return this.initialized } + async list() { + await this.writeQueue.drain() + await this.initialize() + if (!this.worker) return [...this.fallbackSnapshots.values()].map(({ document, savedAt }) => ({ documentId: document.id, label: document.label, documentVersion: document.version, objectCount: document.objects.length, dirty: document.dirty, readOnly: document.readOnly, updatedAt: savedAt })).sort((left, right) => right.updatedAt - left.updatedAt) + const response = await this.request({ type: 'list-projects' }) + if (!response.ok || response.type !== 'projects-listed') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) + return response.projects.map((project) => ({ ...project })) + } + save(document: DocumentSnapshot) { return this.writeQueue.run(() => this.withCrossTabWriteLock(async () => { await this.initialize() if (!this.worker) { const snapshot = cloneDocument(document) - this.fallbackSnapshots.set(snapshot.id, snapshot) - const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt: Date.now(), mode: this.currentCapabilities.mode } + const persistedAt = Date.now() + this.fallbackSnapshots.set(snapshot.id, { document: snapshot, savedAt: persistedAt }) + const result = { documentId: snapshot.id, documentVersion: snapshot.version, persistedAt, mode: this.currentCapabilities.mode } this.announceSaved(result.documentId, result.documentVersion) return result } @@ -130,7 +141,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { async load(documentId: string) { await this.writeQueue.drain() await this.initialize() - if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot) : null } + if (!this.worker) { const snapshot = this.fallbackSnapshots.get(documentId); return snapshot ? cloneDocument(snapshot.document) : null } const response = await this.request({ type: 'load-document', documentId }) if (!response.ok || response.type !== 'loaded') throw new Error(response.ok ? 'Unexpected persistence response.' : response.error) return response.document @@ -140,7 +151,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient { await this.writeQueue.drain() await this.initialize() if (!this.worker) { - const snapshot = this.fallbackSnapshots.get(documentId) + const snapshot = this.fallbackSnapshots.get(documentId)?.document return { documentId, mode: this.currentCapabilities.mode, schemaVersion: this.currentCapabilities.schemaVersion, integrity: snapshot ? 'ok' : 'unavailable', lastSavedVersion: snapshot?.version ?? null, dirtyAtLastSave: snapshot?.dirty ?? null, warnings: snapshot ? [] : ['No saved snapshot exists in the transient fallback store.'] } } const response = await this.request({ type: 'recovery-report', documentId }) diff --git a/src/facade/types.ts b/src/facade/types.ts index 1c08ab5..6ea00aa 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -93,6 +93,16 @@ export type ProjectSaveResult = { mode: PersistenceCapabilities['mode'] } +export type ProjectSummary = { + documentId: string + label: string + documentVersion: number + objectCount: number + dirty: boolean + readOnly: boolean + updatedAt: number +} + export type ProjectRecoveryReport = { documentId: string mode: PersistenceCapabilities['mode'] @@ -393,6 +403,7 @@ export interface BitBybitWebCadFacade { readonly project: { capabilities(): PersistenceCapabilities subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe + list(): Promise save(document?: DocumentSnapshot): Promise load(documentId: string): Promise recovery(documentId: string): Promise diff --git a/src/styles.css b/src/styles.css index 41c1776..35bce4f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -169,6 +169,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s .project-list-row { width: 100%; min-height: 72px; display: grid; grid-template-columns: 58px minmax(180px, 1fr) 100px 105px 95px 32px; gap: 14px; align-items: center; padding: 7px 14px 7px 8px; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; color: inherit; text-align: left; cursor: pointer; } .project-list-row:last-child { border-bottom: 0; } .project-list-row:hover { background: var(--bg-hover); } +.project-list-empty { min-height: 112px; display: grid; place-items: center; color: var(--text-muted); font-size: 12px; } .list-thumbnail { width: 58px; height: 56px; border-radius: 3px; } .list-thumbnail .preview-shape { width: 42px; height: 28px; } .list-main strong, .list-main span { display: block; } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 7190869..2f5f83c 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -672,6 +672,11 @@ test('project persistence remains behind the facade contract', async () => { facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 55 }) const saved = await facade.project.save() assert.equal(saved.documentId, 'doc-pump-housing') + const projects = await facade.project.list() + assert.equal(projects.length, 1) + const { updatedAt, ...summary } = projects[0] + assert.ok(updatedAt > 0) + assert.deepEqual(summary, { documentId: 'doc-pump-housing', label: 'Pump Housing', documentVersion: saved.documentVersion, objectCount: 7, dirty: true, readOnly: false }) const loaded = await facade.project.load('doc-pump-housing') assert.equal(loaded?.label, 'Pump Housing') assert.equal(loaded?.objects.find((object) => object.id === 'pad')?.properties.find((property) => property.name === 'Length')?.value, 55)