feat: add document history transactions

This commit is contained in:
2026-08-02 03:00:59 -04:00
parent f20c83f21e
commit 52b7236803
4 changed files with 31 additions and 8 deletions

View File

@@ -169,7 +169,7 @@ function App() {
return (
<div className="app-shell">
<TopBar page={page} workbench={workbench} onNavigate={navigate} onOpenWorkspace={openWorkspace} />
<TopBar page={page} workbench={workbench} facade={facade} onNavigate={navigate} onOpenWorkspace={openWorkspace} />
{page === 'workspace' ? (
<Workspace
workbench={workbench}
@@ -195,7 +195,7 @@ function App() {
)
}
function TopBar({ page, workbench, onNavigate, onOpenWorkspace }: { page: Page; workbench: Workbench; onNavigate: (page: Page) => void; onOpenWorkspace: () => void }) {
function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page: Page; workbench: Workbench; facade: BitBybitWebCadFacade; onNavigate: (page: Page) => void; onOpenWorkspace: () => void }) {
const [openMenu, setOpenMenu] = useState<MenuName | null>(null)
const handleMenuCommand = (command: string) => {
setOpenMenu(null)
@@ -221,8 +221,8 @@ function TopBar({ page, workbench, onNavigate, onOpenWorkspace }: { page: Page;
<div className="context-document"><span className="context-dot" />Pump Housing <span className="context-separator">/</span> {workbench}</div>
<span className="save-state"><span className="save-dot" />Unsaved changes</span>
</div>
<IconButton icon={Undo2} label="Undo" />
<IconButton icon={Redo2} label="Redo" disabled />
<IconButton icon={Undo2} label="Undo" onClick={() => facade.history.undo()} disabled={!facade.history.canUndo()} />
<IconButton icon={Redo2} label="Redo" onClick={() => facade.history.redo()} disabled={!facade.history.canRedo()} />
<div className="topbar-divider" />
<button className="command-search" onClick={() => onNavigate('help')}><Search size={15} /><span>Search commands</span><kbd> K</kbd></button>
<IconButton icon={HelpCircle} label="Help" onClick={() => onNavigate('help')} />

View File

@@ -30,9 +30,10 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => ({
})
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area'])
const systemCommands = new Set(['new-document', 'save', 'select-object'])
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => {
const known = Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
const known = systemCommands.has(commandId) || Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' }
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
@@ -42,11 +43,14 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO
export function createMockFacade(): BitBybitWebCadFacade {
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), task: null, lastNotice: '', diagnostics: [] }
const listeners = new Set<FacadeListener>()
const undoStack: FacadeState[] = []
const redoStack: FacadeState[] = []
let requestSequence = 0
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
const emitState = () => emit({ type: 'state.changed', state: getState() })
const getState = () => ({ ...state, diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null })
const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; emitState() }
const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() }
const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() }
@@ -61,8 +65,8 @@ export function createMockFacade(): BitBybitWebCadFacade {
emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
}
emit({ type: 'command.started', commandId, context })
if (commandId === 'new-document') state = { ...state, document: createDocument('Untitled document') }
else if (commandId === 'save') state = { ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } }
if (commandId === 'new-document') commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' })
else if (commandId === 'save') commit({ ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } })
else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
else if (commandId === 'create-body') beginTask('create-body')
else if (commandId === 'create-sketch') beginTask('create-sketch')
@@ -70,7 +74,8 @@ export function createMockFacade(): BitBybitWebCadFacade {
}
const facade: BitBybitWebCadFacade = {
app: { document: { getActive: () => getState().document, create: (label) => { state = { ...state, document: createDocument(label), selectedObjectId: '' }; emitState(); return getState().document }, markDirty: () => { state = { ...state, document: { ...state.document, dirty: true } }; emitState() } } },
app: { document: { getActive: () => getState().document, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) } } },
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } },
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), 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: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'completed' } }; emitState() }, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },

View File

@@ -92,6 +92,12 @@ export interface BitBybitWebCadFacade {
markDirty(): void
}
}
readonly history: {
canUndo(): boolean
canRedo(): boolean
undo(): void
redo(): void
}
readonly gui: {
workbench: {
list(): WorkbenchId[]

View File

@@ -59,3 +59,15 @@ test('command events carry a document-scoped request context', () => {
assert.ok(events.includes('command.completed'))
assert.match(requestId, /^req-/)
})
test('document mutations use the facade history boundary', () => {
const facade = createMockFacade()
facade.gui.command.execute({ commandId: 'new-document' })
assert.equal(facade.app.document.getActive().label, 'Untitled document')
assert.equal(facade.history.canUndo(), true)
facade.history.undo()
assert.equal(facade.app.document.getActive().label, 'Pump Housing')
assert.equal(facade.history.canRedo(), true)
facade.history.redo()
assert.equal(facade.app.document.getActive().label, 'Untitled document')
})