diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..ad72b4f60 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,34 @@ +FROM oven/bun@sha256:b86c67b531d87b4db11470d9b2bd0c519b1976eee6fcd71634e73abfa6230d2e + +# Bun 1.3.10, pinned to the multi-platform image digest. + +ARG CA_CERTIFICATES_VERSION=20250419 +ARG GIT_VERSION=1:2.47.3-0+deb13u1 +ARG GIT_LFS_VERSION=3.6.1-1+deb13u1 +ARG OPENSSH_CLIENT_VERSION=1:10.0p1-7+deb13u4 + +USER root + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates=${CA_CERTIFICATES_VERSION} \ + git=${GIT_VERSION} \ + git-lfs=${GIT_LFS_VERSION} \ + openssh-client=${OPENSSH_CLIENT_VERSION} \ + && rm -rf /var/lib/apt/lists/* \ + && git lfs install --system \ + && install -d -o bun -g bun \ + /workspace/open-pencil/node_modules \ + /workspace/open-pencil/packages/scene-graph/node_modules \ + /workspace/open-pencil/packages/pen/node_modules \ + /workspace/open-pencil/packages/kiwi/node_modules \ + /workspace/open-pencil/packages/fig/node_modules \ + /workspace/open-pencil/packages/core/node_modules \ + /workspace/open-pencil/packages/dom-css/node_modules \ + /workspace/open-pencil/packages/vue/node_modules \ + /workspace/open-pencil/packages/cli/node_modules \ + /workspace/open-pencil/packages/mcp/node_modules \ + /workspace/open-pencil/packages/docs/node_modules \ + /workspace/open-pencil/tools/docs/node_modules + +USER bun diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..93fa2a157 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json", + "name": "OpenPencil", + "build": { + "dockerfile": "Dockerfile", + "context": "." + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace/open-pencil,type=bind,consistency=cached", + "workspaceFolder": "/workspace/open-pencil", + "remoteUser": "bun", + "updateRemoteUserUID": true, + "mounts": [ + "source=open-pencil-root-modules-${devcontainerId},target=/workspace/open-pencil/node_modules,type=volume", + "source=open-pencil-scene-graph-modules-${devcontainerId},target=/workspace/open-pencil/packages/scene-graph/node_modules,type=volume", + "source=open-pencil-pen-modules-${devcontainerId},target=/workspace/open-pencil/packages/pen/node_modules,type=volume", + "source=open-pencil-kiwi-modules-${devcontainerId},target=/workspace/open-pencil/packages/kiwi/node_modules,type=volume", + "source=open-pencil-fig-modules-${devcontainerId},target=/workspace/open-pencil/packages/fig/node_modules,type=volume", + "source=open-pencil-core-modules-${devcontainerId},target=/workspace/open-pencil/packages/core/node_modules,type=volume", + "source=open-pencil-dom-css-modules-${devcontainerId},target=/workspace/open-pencil/packages/dom-css/node_modules,type=volume", + "source=open-pencil-vue-modules-${devcontainerId},target=/workspace/open-pencil/packages/vue/node_modules,type=volume", + "source=open-pencil-cli-modules-${devcontainerId},target=/workspace/open-pencil/packages/cli/node_modules,type=volume", + "source=open-pencil-mcp-modules-${devcontainerId},target=/workspace/open-pencil/packages/mcp/node_modules,type=volume", + "source=open-pencil-docs-modules-${devcontainerId},target=/workspace/open-pencil/packages/docs/node_modules,type=volume", + "source=open-pencil-docs-tool-modules-${devcontainerId},target=/workspace/open-pencil/tools/docs/node_modules,type=volume" + ], + "postCreateCommand": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git lfs pull; fi && bun install --frozen-lockfile", + "containerEnv": { + "TAURI_DEV_HOST": "0.0.0.0" + }, + "forwardPorts": [1420], + "portsAttributes": { + "1420": { + "label": "OpenPencil web editor", + "onAutoForward": "notify" + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0918ec118..dfd4e0e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Add a reproducible Dev Container for web, package, CLI, and non-browser test development. - Add local crash recovery for unsaved and pathless documents, including MCP-created documents. (#487) - Add isolated visual inspection that sends bounded selection renders to the configured Vision model and returns text findings without retaining image data in Design chat history. (#232, #471) - Allow supported AI model profiles to set a provider-specific reasoning effort. (#454) @@ -11,10 +12,13 @@ ### Performance +- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500) - Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph. ### Fixed +- Transfer native `.fig` exports over binary Tauri IPC instead of JSON byte arrays, preventing large desktop saves from being truncated or exhausting WebView memory. (#484) +- Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior. - Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397) - Compose caller CSS with Tailwind defaults when importing DOM/CSS documents. (#397) - Preserve desktop HTTP timeout, abort, and empty-response semantics. (#397) diff --git a/README.md b/README.md index c714a37c7..c75e9d115 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,10 @@ bun run dev # Dev server at localhost:1420 bun run tauri dev # Desktop app (requires Rust) ``` +Alternatively, open the repository in any [Dev Container](https://containers.dev/)-compatible tool. The container pins Bun, installs the workspace dependencies, and forwards the web editor on port 1420. Start it with `bun run dev` after the container is ready. + +The Dev Container supports the web editor, packages, CLI, and automated checks. Native Tauri development still requires the host setup described below because desktop windows and platform WebView dependencies are not provided in the container. + ### Quality gates | Command | Description | diff --git a/desktop/src/fig_container.rs b/desktop/src/fig_container.rs index 16e206e8c..b30e7f1f3 100644 --- a/desktop/src/fig_container.rs +++ b/desktop/src/fig_container.rs @@ -15,7 +15,7 @@ pub fn build_fig_file( meta_json: String, images: Option>, fig_kiwi_version: Option, -) -> Result, String> { +) -> Result { let mut encoder = zstd::Encoder::new(Vec::new(), 3).map_err(|e| e.to_string())?; encoder .include_contentsize(true) @@ -63,5 +63,5 @@ pub fn build_fig_file( } let result = zip.finish().map_err(|e| e.to_string())?; - Ok(result.into_inner()) + Ok(tauri::ipc::Response::new(result.into_inner())) } diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index e16acc0bb..5ba9a51a9 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -139,25 +139,31 @@ export function figmaNodesBounds( interface ClipboardImportMaps { guidMap: Map parentMap: Map + childMap: Map } function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps { const guidMap = new Map() const parentMap = new Map() + const childMap = new Map() for (const nc of nodeChanges) { if (!nc.guid) continue const id = `${nc.guid.sessionID}:${nc.guid.localID}` guidMap.set(id, nc) if (nc.parentIndex?.guid) { - parentMap.set(id, `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}`) + const parentId = `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` + parentMap.set(id, parentId) + const siblings = childMap.get(parentId) + if (siblings) siblings.push(id) + else childMap.set(parentId, [id]) } } - return { guidMap, parentMap } + return { guidMap, parentMap, childMap } } function findInternalNodeIds( guidMap: Map, - parentMap: Map + childMap: Map ): { internalCanvasIds: Set; internalFigmaIds: Set } { const internalCanvasIds = new Set() for (const [id, nc] of guidMap) { @@ -169,8 +175,8 @@ function findInternalNodeIds( const internalFigmaIds = new Set() function markInternal(id: string) { internalFigmaIds.add(id) - for (const [childId, pid] of parentMap) { - if (pid === id && !internalFigmaIds.has(childId)) markInternal(childId) + for (const childId of childMap.get(id) ?? []) { + if (!internalFigmaIds.has(childId)) markInternal(childId) } } for (const canvasId of internalCanvasIds) markInternal(canvasId) @@ -230,8 +236,8 @@ export function importClipboardNodes( offsetY = 0, blobs: Uint8Array[] = [] ): string[] { - const { guidMap, parentMap } = buildClipboardMaps(nodeChanges) - const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, parentMap) + const { guidMap, parentMap, childMap } = buildClipboardMaps(nodeChanges) + const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, childMap) const { topLevel, internalTopLevel } = classifyTopLevelNodes( guidMap, parentMap, @@ -262,12 +268,9 @@ export function importClipboardNodes( created.set(figmaId, node.id) if (ourParentId === targetParentId && !internalFigmaIds.has(figmaId)) createdIds.push(node.id) - const children: string[] = [] - for (const [childId, pid] of parentMap) { - if (pid === figmaId && !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '')) { - children.push(childId) - } - } + const children = (childMap.get(figmaId) ?? []).filter( + (childId) => !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '') + ) sortChildren(children, nc, guidMap) for (const childId of children) { createNode(childId, node.id) diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index d3f0d3d44..dc3f11aa4 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -554,7 +554,7 @@ export async function exportFigFile( if (IS_TAURI) { const { invoke } = await import('@tauri-apps/api/core') return new Uint8Array( - await invoke('build_fig_file', { + await invoke('build_fig_file', { schemaDeflated: Array.from(schemaDeflated), kiwiData: Array.from(kiwiData), thumbnailPng: Array.from(thumbnailPNG), diff --git a/src/app/document/recovery/controller.ts b/src/app/document/recovery/controller.ts index 3c36cb8b3..005ad9bd4 100644 --- a/src/app/document/recovery/controller.ts +++ b/src/app/document/recovery/controller.ts @@ -65,11 +65,9 @@ export function createDocumentRecovery({ if (requestedVersion === protectedVersion) return if (!writing) { const generation = lifecycleGeneration - writing = runWrites(generation) - .catch((error) => console.warn('[Recovery] Snapshot failed:', error)) - .finally(() => { - writing = null - }) + writing = runWrites(generation).finally(() => { + writing = null + }) } await writing } @@ -77,7 +75,7 @@ export function createDocumentRecovery({ const stop: WatchHandle = watchDebounced( () => state.sceneVersion, () => { - void persistNow() + void persistNow().catch((error) => console.warn('[Recovery] Snapshot failed:', error)) }, { debounce: 3000, maxWait: 10000 } ) diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 56a6d2243..cd6953b7d 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -36,7 +36,6 @@ function generateTabId(): string { } const tabsRef = shallowRef([]) -const pendingRecoveryDeletions = new Set>() const activeTabId = shallowRef('') export const activeTab = computed(() => tabsRef.value.find((t) => t.id === activeTabId.value)) @@ -98,13 +97,7 @@ export async function closeTab(tabId: string): Promise { const closingTab = tabsRef.value[idx] const wasActive = activeTabId.value === tabId - const deletion = closingTab.store.discardRecovery() - pendingRecoveryDeletions.add(deletion) - try { - await deletion - } finally { - pendingRecoveryDeletions.delete(deletion) - } + await closingTab.store.persistRecoveryNow() closingTab.store.dispose() tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId) @@ -304,10 +297,7 @@ export async function restoreRecoverySnapshot(id: string): Promise { } export async function prepareForReload(): Promise { - await Promise.all([ - ...pendingRecoveryDeletions, - ...tabsRef.value.map((tab) => tab.store.persistRecoveryNow()) - ]) + await Promise.all(tabsRef.value.map((tab) => tab.store.persistRecoveryNow())) } export function tabCount(): number { diff --git a/tests/e2e/autosave.spec.ts b/tests/e2e/autosave.spec.ts index 431a3af34..ad933514f 100644 --- a/tests/e2e/autosave.spec.ts +++ b/tests/e2e/autosave.spec.ts @@ -67,8 +67,9 @@ test('autosave triggers after scene changes with a file handle', async () => { expect(writeHappened).toBe(true) }) -test('no autosave without file handle', async ({ browser }) => { +test('no autosave without file handle', async ({ browser, baseURL }) => { const context = await browser.newContext({ + baseURL, viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }) diff --git a/tests/e2e/recovery.spec.ts b/tests/e2e/recovery.spec.ts new file mode 100644 index 000000000..1155c87a8 --- /dev/null +++ b/tests/e2e/recovery.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '#tests/e2e/fixtures' +import { CanvasHelper } from '#tests/helpers/canvas' + +test('keeps an unsaved document recoverable after its tab closes', async ({ browser, baseURL }) => { + const context = await browser.newContext({ baseURL }) + const page = await context.newPage() + await page.goto('/') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + + await page.evaluate(async () => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const id = store.createShape('RECTANGLE', 120, 120, 240, 140) + await store.persistRecoveryNow() + store.updateNode(id, { name: 'Retained recovery rectangle' }) + }) + + await page.keyboard.press('ControlOrMeta+t') + await expect(page.getByRole('button', { name: 'New tab' })).toBeVisible() + await page.getByTestId('tabbar-tab').first().getByTestId('tabbar-close').click() + await expect(page.getByRole('button', { name: 'New tab' })).toBeHidden() + await expect + .poll(() => + page.evaluate(async () => { + const request = indexedDB.open('open-pencil-recovery') + const database = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + const transaction = database.transaction('meta') + const countRequest = transaction.objectStore('meta').count() + return new Promise((resolve, reject) => { + countRequest.onsuccess = () => resolve(countRequest.result) + countRequest.onerror = () => reject(countRequest.error) + }) + }) + ) + .toBe(1) + + await page.reload() + await expect(page.getByRole('alertdialog', { name: 'Recover unsaved work' })).toBeVisible() + await page.getByRole('button', { name: 'Restore' }).click() + await expect(page.getByText('Retained recovery rectangle')).toBeVisible() + + await context.close() +}) diff --git a/tests/engine/app/document/recovery/controller.test.ts b/tests/engine/app/document/recovery/controller.test.ts index 572a072b0..dd06a906e 100644 --- a/tests/engine/app/document/recovery/controller.test.ts +++ b/tests/engine/app/document/recovery/controller.test.ts @@ -86,6 +86,32 @@ describe('document recovery controller', () => { recovery.disposeRecovery() }) + test('propagates persistence failures to close and reload callers', async () => { + const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' }) + const store = createMemoryRecoveryStore() + const memoryWrite = store.write.bind(store) + let writeAttempts = 0 + store.write = async (input) => { + writeAttempts++ + if (writeAttempts === 1) throw new Error('recovery storage unavailable') + return memoryWrite(input) + } + const recovery = createDocumentRecovery({ + state, + store, + recoveryId: 'recovery-1', + hasWritableSource: () => false, + buildFigFile: () => new Uint8Array([1]) + }) + state.sceneVersion = 1 + + await expect(recovery.persistNow()).rejects.toThrow('recovery storage unavailable') + await recovery.persistNow() + expect(writeAttempts).toBe(2) + expect((await store.read('recovery-1'))?.sceneVersion).toBe(1) + recovery.disposeRecovery() + }) + test('successful save removes recovery data', async () => { const { state, store, recovery } = setup() state.sceneVersion = 1 diff --git a/tests/helpers/tauri/fig-export-fixture.ts b/tests/helpers/tauri/fig-export-fixture.ts index 92b3b132a..1cba85d6e 100644 --- a/tests/helpers/tauri/fig-export-fixture.ts +++ b/tests/helpers/tauri/fig-export-fixture.ts @@ -20,7 +20,7 @@ mockIPC((cmd, args) => { if (payload.thumbnailPng.length === 0) throw new Error('thumbnailPng is empty') if (payload.images.length !== 0) throw new Error('images should be empty') JSON.parse(payload.metaJson) - return [7, 8, 9] + return new Uint8Array([7, 8, 9]).buffer }) const [{ exportFigFile }, { SceneGraph }] = await Promise.all([