feat: expose persisted project summaries
This commit is contained in:
15
src/App.tsx
15
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<ProjectSummary[]>([])
|
||||
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 <div className="projects-page"><PageHeader eyebrow="Project manager" title="Your projects." description="Local-first project storage with explicit save and recovery states." actions={<><button className="button button-outline" onClick={() => onNavigate('import')}><Upload size={16} />Import</button><button className="button button-primary" onClick={onOpenWorkspace}><Plus size={16} />New project</button></>} /><div className="manager-toolbar"><div className="large-search"><Search size={16} /><input placeholder="Search projects" /><kbd>/</kbd></div><div className="toolbar-select"><span>Sort by</span><select><option>Last modified</option><option>Name</option><option>Object count</option></select><ChevronDown size={14} /></div><IconButton icon={LayoutGrid} label="Grid view" active /><IconButton icon={ListTree} label="List view" /></div><div className="project-list">{projects.map((project, index) => <button className="project-list-row" key={project.name} onClick={() => void openSavedProject()}><div className={`list-thumbnail preview-${index}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="list-main"><strong>{project.name}</strong><span>{project.path}</span></div><span className="list-count">{project.objects} objects</span><span className="list-modified">{project.modified}</span><Badge tone={project.state === 'Unsaved' ? 'amber' : project.state === 'Read only' ? 'muted' : 'green'}>{project.state}</Badge><span className="row-action" title="Open project"><ArrowRight size={16} /></span></button>)}</div><div className="storage-banner"><div className="storage-icon"><HardDrive size={18} /></div><div><strong>Local storage</strong><span>3.4 GB of 10 GB used · Last backup just now</span></div><button className="text-button" onClick={() => onNavigate('settings')}>Manage storage <ArrowRight size={14} /></button></div></div>
|
||||
return <div className="projects-page"><PageHeader eyebrow="Project manager" title="Your projects." description="Local-first project storage with explicit save and recovery states." actions={<><button className="button button-outline" onClick={() => onNavigate('import')}><Upload size={16} />Import</button><button className="button button-primary" onClick={onOpenWorkspace}><Plus size={16} />New project</button></>} /><div className="manager-toolbar"><div className="large-search"><Search size={16} /><input placeholder="Search projects" /><kbd>/</kbd></div><div className="toolbar-select"><span>Sort by</span><select><option>Last modified</option><option>Name</option><option>Object count</option></select><ChevronDown size={14} /></div><IconButton icon={LayoutGrid} label="Grid view" active /><IconButton icon={ListTree} label="List view" /></div><div className="project-list">{loading ? <div className="project-list-empty">Loading local projects</div> : savedProjects.length === 0 ? <div className="project-list-empty">No saved local projects</div> : savedProjects.map((project, index) => { const state = project.readOnly ? 'Read only' : project.dirty ? 'Unsaved' : 'Saved'; return <button className="project-list-row" key={project.documentId} onClick={() => void openSavedProject(project.documentId)}><div className={`list-thumbnail preview-${index % 4}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="list-main"><strong>{project.label}</strong><span>Local / {project.documentId}</span></div><span className="list-count">{project.objectCount} objects</span><span className="list-modified">{new Date(project.updatedAt).toLocaleDateString()}</span><Badge tone={state === 'Unsaved' ? 'amber' : state === 'Read only' ? 'muted' : 'green'}>{state}</Badge><span className="row-action" title="Open project"><ArrowRight size={16} /></span></button> })}</div><div className="storage-banner"><div className="storage-icon"><HardDrive size={18} /></div><div><strong>Local storage</strong><span>{savedProjects.length} saved project{savedProjects.length === 1 ? '' : 's'} · browser-managed capacity</span></div><button className="text-button" onClick={() => onNavigate('settings')}>Manage storage <ArrowRight size={14} /></button></div></div>
|
||||
}
|
||||
|
||||
function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, string | number>>
|
||||
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<Record<string, string | number | null>>
|
||||
@@ -208,6 +225,7 @@ const handle = async (request: PersistenceRequest): Promise<PersistenceResponse>
|
||||
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) }
|
||||
|
||||
@@ -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<PersistenceCapabilities>
|
||||
capabilities(): PersistenceCapabilities
|
||||
subscribeExternalChanges(listener: (notice: ProjectChangeNotice) => void): Unsubscribe
|
||||
list(): Promise<ProjectSummary[]>
|
||||
save(document: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
recovery(documentId: string): Promise<ProjectRecoveryReport>
|
||||
@@ -75,7 +76,7 @@ export class SqliteProjectPersistence implements ProjectPersistenceClient {
|
||||
private nextRequestId = 0
|
||||
private currentCapabilities = unavailable
|
||||
private initialized: Promise<PersistenceCapabilities> | null = null
|
||||
private readonly fallbackSnapshots = new Map<string, DocumentSnapshot>()
|
||||
private readonly fallbackSnapshots = new Map<string, { document: DocumentSnapshot; savedAt: number }>()
|
||||
private readonly fallbackResources = new Map<string, { resource: ProjectResource; bytes: Uint8Array }>()
|
||||
private readonly pending = new Map<number, { resolve: (response: WorkerResponse) => 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 })
|
||||
|
||||
@@ -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<ProjectSummary[]>
|
||||
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
recovery(documentId: string): Promise<ProjectRecoveryReport>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user