From ad7ed98c867a215421d1b8e8cf8bfd96793f94ed Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 18:18:35 +0200 Subject: [PATCH 1/8] fix: serialize autosaves per project context (#339) --- src/services/auto-save.ts | 61 +++++++++++--- tests/services/auto-save.test.ts | 131 +++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 12 deletions(-) diff --git a/src/services/auto-save.ts b/src/services/auto-save.ts index bd14676..f9e0ebb 100644 --- a/src/services/auto-save.ts +++ b/src/services/auto-save.ts @@ -9,7 +9,10 @@ const AUTO_SAVE_DEBOUNCE_MS = 2000; interface AutoSaveContextState { isDirty: boolean; + changeRevision: number; + persistedRevision: number; saveTimeout: ReturnType | null; + saveQueue: Promise; dispose: (() => void) | null; suppressSaveCount: number; } @@ -69,8 +72,8 @@ class AutoSaveService { const state = this.getState(context); if (state.suppressSaveCount > 0) return; - state.isDirty = true; - this.setDirtyContext(context, true); + state.changeRevision++; + this.updateDirtyState(context, state); this.clearSaveTimeout(state); state.saveTimeout = setTimeout(() => { @@ -103,8 +106,8 @@ class AutoSaveService { clearPendingSave(context: ProjectContext = defaultProjectContext) { const state = this.getState(context); this.clearSaveTimeout(state); - state.isDirty = false; - this.setDirtyContext(context, false); + state.persistedRevision = state.changeRevision; + this.updateDirtyState(context, state); } /** Run project load/reset work without treating reset signals as user edits. */ @@ -132,24 +135,50 @@ class AutoSaveService { private async performSave( context: ProjectContext, options: { force?: boolean; rethrow?: boolean } = {} - ) { + ): Promise { const state = this.getState(context); if (!state.isDirty && !options.force) return; - const wasDirty = state.isDirty; - state.isDirty = false; - this.setDirtyContext(context, false); + if (options.force) { + // Some direct project mutations do not create history entries. A forced + // save is itself a request to persist the current state. + state.changeRevision++; + this.updateDirtyState(context, state); + } + + const projectId = context.project.id.value; + const requestedRevision = state.changeRevision; + const save = state.saveQueue.then(() => + this.writeProject(context, state, projectId, requestedRevision, options) + ); + + // A failed save must not prevent the next queued request from running. + state.saveQueue = save.catch(() => {}); + await save; + } + + private async writeProject( + context: ProjectContext, + state: AutoSaveContextState, + projectId: string, + requestedRevision: number, + options: { force?: boolean; rethrow?: boolean } + ): Promise { + if (requestedRevision <= state.persistedRevision) return; try { const projectData = await context.project.saveProject(); const thumbnail = await createThumbnailSafely(context); - await projectRepository.save(context.project.id.value, projectData, { + await projectRepository.save(projectId, projectData, { thumbnail, }); - context.project.lastSaved.value = Date.now(); + if (context.project.id.value === projectId) { + context.project.lastSaved.value = Date.now(); + } + state.persistedRevision = Math.max(state.persistedRevision, requestedRevision); + this.updateDirtyState(context, state); } catch (error) { - state.isDirty = wasDirty || state.isDirty; - this.setDirtyContext(context, state.isDirty); + this.updateDirtyState(context, state); log.error('Auto-save failed:', error); if (options.rethrow) throw error; } @@ -161,7 +190,10 @@ class AutoSaveService { const state: AutoSaveContextState = { isDirty: false, + changeRevision: 0, + persistedRevision: 0, saveTimeout: null, + saveQueue: Promise.resolve(), dispose: null, suppressSaveCount: 0, }; @@ -219,6 +251,11 @@ class AutoSaveService { this.dirtyContexts.value = new Set(this.dirtyContextSet); } + + private updateDirtyState(context: ProjectContext, state: AutoSaveContextState) { + state.isDirty = state.persistedRevision < state.changeRevision; + this.setDirtyContext(context, state.isDirty); + } } export const autoSaveService = new AutoSaveService(); diff --git a/tests/services/auto-save.test.ts b/tests/services/auto-save.test.ts index a44aba4..a443270 100644 --- a/tests/services/auto-save.test.ts +++ b/tests/services/auto-save.test.ts @@ -43,6 +43,22 @@ import { const createdContexts: ProjectContext[] = []; +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function settleSaveQueue() { + for (let index = 0; index < 20; index++) { + await Promise.resolve(); + } +} + function makeCommand(run?: () => void): Command { return { id: 'test-cmd', @@ -170,4 +186,119 @@ describe('AutoSaveService', () => { expect(savedProjects.size).toBe(2); expect(createProjectThumbnail).toHaveBeenCalledTimes(2); }); + + it('serializes saves for one context and writes an edit made during a save afterwards', async () => { + const context = createContext('ordered-project', 'First state'); + const firstWrite = deferred(); + const secondWrite = deferred(); + + vi.mocked(projectRepository.save) + .mockImplementationOnce(() => firstWrite.promise) + .mockImplementationOnce(() => secondWrite.promise); + + autoSaveService.start(context); + await context.history.execute(makeCommand()); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(2000); + await settleSaveQueue(); + + expect(projectRepository.save).toHaveBeenCalledTimes(1); + + await context.history.execute( + makeCommand(() => { + context.project.name.value = 'Second state'; + }) + ); + await Promise.resolve(); + const forcedSave = autoSaveService.saveNow(context); + await settleSaveQueue(); + + expect(projectRepository.save).toHaveBeenCalledTimes(1); + + firstWrite.resolve(); + await settleSaveQueue(); + + expect(projectRepository.save).toHaveBeenCalledTimes(2); + expect(vi.mocked(projectRepository.save).mock.calls[1][1].name).toBe('Second state'); + expect(autoSaveService.isDirty(context)).toBe(true); + + secondWrite.resolve(); + await forcedSave; + expect(autoSaveService.isDirty(context)).toBe(false); + }); + + it('captures the project identity before asynchronous serialization', async () => { + const context = createContext('original-project', 'Original project'); + const serializedProject = await context.project.saveProject(); + const serialization = deferred(); + vi.spyOn(context.project, 'saveProject').mockReturnValueOnce(serialization.promise); + + const save = autoSaveService.saveNow(context); + await settleSaveQueue(); + context.project.id.value = 'replacement-project'; + serialization.resolve(serializedProject); + await save; + + expect(projectRepository.save).toHaveBeenCalledWith( + 'original-project', + serializedProject, + expect.any(Object) + ); + }); + + it('keeps a failed forced save dirty and allows a later retry', async () => { + const context = createContext('retry-project', 'Retry project'); + vi.mocked(projectRepository.save) + .mockRejectedValueOnce(new Error('write failed')) + .mockResolvedValueOnce(); + + await expect(autoSaveService.saveNow(context)).rejects.toThrow('write failed'); + expect(autoSaveService.isDirty(context)).toBe(true); + + await autoSaveService.saveNow(context); + + expect(projectRepository.save).toHaveBeenCalledTimes(2); + expect(autoSaveService.isDirty(context)).toBe(false); + }); + + it('lets a newer queued save clear dirty state after an older save fails', async () => { + const context = createContext('failure-order-project', 'Older state'); + const firstWrite = deferred(); + vi.mocked(projectRepository.save) + .mockImplementationOnce(() => firstWrite.promise) + .mockResolvedValueOnce(); + + const olderSave = autoSaveService.saveNow(context); + await settleSaveQueue(); + + context.project.name.value = 'Newer state'; + const newerSave = autoSaveService.saveNow(context); + firstWrite.reject(new Error('older write failed')); + + await expect(olderSave).rejects.toThrow('older write failed'); + await newerSave; + + expect(projectRepository.save).toHaveBeenCalledTimes(2); + expect(vi.mocked(projectRepository.save).mock.calls[1][1].name).toBe('Newer state'); + expect(autoSaveService.isDirty(context)).toBe(false); + }); + + it('allows separate contexts to save independently', async () => { + const contextA = createContext('parallel-a', 'Parallel A'); + const contextB = createContext('parallel-b', 'Parallel B'); + const writeA = deferred(); + + vi.mocked(projectRepository.save).mockImplementation((id) => { + return id === 'parallel-a' ? writeA.promise : Promise.resolve(); + }); + + const saveA = autoSaveService.saveNow(contextA); + const saveB = autoSaveService.saveNow(contextB); + await saveB; + + expect(projectRepository.save).toHaveBeenCalledTimes(2); + + writeA.resolve(); + await saveA; + }); }); From 1096be896ab39f58b4ceb37d2cf5effa6838f3aa Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 18:24:51 +0200 Subject: [PATCH 2/8] fix: wait for active saves before deleting projects (#339) --- src/services/auto-save.ts | 8 ++++-- src/services/project-library.ts | 2 +- tests/services/project-library.test.ts | 34 ++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/services/auto-save.ts b/src/services/auto-save.ts index f9e0ebb..3217b08 100644 --- a/src/services/auto-save.ts +++ b/src/services/auto-save.ts @@ -102,12 +102,16 @@ class AutoSaveService { return this.dirtyContexts.value.has(context); } - /** Drop a pending debounce without writing. Used when the open project is deleted. */ - clearPendingSave(context: ProjectContext = defaultProjectContext) { + /** + * Drop queued work and wait for a write that has already started. + * Deletion can then run after every older write that could recreate the record. + */ + async clearPendingSave(context: ProjectContext = defaultProjectContext): Promise { const state = this.getState(context); this.clearSaveTimeout(state); state.persistedRevision = state.changeRevision; this.updateDirtyState(context, state); + await state.saveQueue; } /** Run project load/reset work without treating reset signals as user edits. */ diff --git a/src/services/project-library.ts b/src/services/project-library.ts index c36219e..c20af81 100644 --- a/src/services/project-library.ts +++ b/src/services/project-library.ts @@ -127,7 +127,7 @@ export class ProjectLibraryService { async deleteProject(id: string, settings: DeleteProjectSettings = {}): Promise { const context = settings.context ?? defaultProjectContext; if (id === context.project.id.value) { - autoSaveService.clearPendingSave(context); + await autoSaveService.clearPendingSave(context); } await this.repository.delete(id); diff --git a/tests/services/project-library.test.ts b/tests/services/project-library.test.ts index 5c00c6e..a5b82d3 100644 --- a/tests/services/project-library.test.ts +++ b/tests/services/project-library.test.ts @@ -337,6 +337,40 @@ describe('ProjectLibraryService', () => { expect(projects.has('open')).toBe(false); }); + it('waits for an in-flight auto-save before deleting the project', async () => { + projects.set('open', makeProject('Open')); + await openProjectInStore('open', makeProject('Open')); + autoSaveService.start(); + + let finishWrite!: () => void; + const writeGate = new Promise((resolve) => { + finishWrite = resolve; + }); + repository.save.mockImplementationOnce(async (id: string, project: ProjectFile) => { + await writeGate; + projects.set(id, cloneProject(project)); + }); + + await historyStore.execute( + makeCommand(() => { + projectStore.name.value = 'Saving project'; + }) + ); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(2000); + + const deletion = service.deleteProject('open'); + await Promise.resolve(); + + expect(repository.delete).not.toHaveBeenCalled(); + + finishWrite(); + await deletion; + + expect(projects.has('open')).toBe(false); + expect(repository.delete).toHaveBeenCalledWith('open'); + }); + it('does not let an old auto-save timer write the previous project under the new id', async () => { projects.set('a', makeProject('Project A')); projects.set('b', makeProject('Project B')); From 1123307c20471124c24826758dd8ef81ca0ad48a Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 11 Jul 2026 18:30:53 +0200 Subject: [PATCH 3/8] fix: bind file actions and exports to active projects (#342) --- src/components/app/pf-project-browser.ts | 10 ++- src/components/app/pf-pwa-update-toast.ts | 4 +- src/components/app/pixel-forge-app.ts | 43 +++++++--- src/components/dialogs/pf-export-dialog.ts | 50 +++++++----- src/components/menu/pf-menu-bar.ts | 14 +++- src/services/aseprite-writer.ts | 45 ++++++++--- .../components/app/pf-project-browser.test.ts | 45 +++++++++++ .../app/pf-pwa-update-toast.test.ts | 30 +++++++ tests/components/app/pixel-forge-app.test.ts | 81 +++++++++++++++++++ .../dialogs/pf-export-dialog.test.ts | 47 ++++++++++- tests/components/menu/pf-menu-bar.test.ts | 27 +++++++ tests/services/aseprite-writer.test.ts | 29 ++++++- 12 files changed, 367 insertions(+), 58 deletions(-) diff --git a/src/components/app/pf-project-browser.ts b/src/components/app/pf-project-browser.ts index 26f3de9..8a1af88 100644 --- a/src/components/app/pf-project-browser.ts +++ b/src/components/app/pf-project-browser.ts @@ -599,9 +599,9 @@ export class PFProjectBrowser extends BaseComponent { this.errorMessage = ''; try { - const activeContext = getActiveProjectContext(); - if (id === activeContext.project.id.value) { - await autoSaveService.saveNow(activeContext); + const openItem = workspaceStore.getProjectItem(id); + if (openItem) { + await autoSaveService.saveNow(openItem.context); } await projectLibrary.duplicateProject(id); await this.loadProjects(); @@ -620,7 +620,9 @@ export class PFProjectBrowser extends BaseComponent { try { const activeContext = getActiveProjectContext(); const deletedOpenProject = project.id === activeContext.project.id.value; - await projectLibrary.deleteProject(project.id, { context: activeContext }); + const projectContext = + workspaceStore.getProjectItem(project.id)?.context ?? activeContext; + await projectLibrary.deleteProject(project.id, { context: projectContext }); await this.loadProjects(); if (deletedOpenProject) { diff --git a/src/components/app/pf-pwa-update-toast.ts b/src/components/app/pf-pwa-update-toast.ts index d1ae121..f5fad4f 100644 --- a/src/components/app/pf-pwa-update-toast.ts +++ b/src/components/app/pf-pwa-update-toast.ts @@ -2,6 +2,7 @@ import { css, html, nothing } from 'lit'; import { customElement } from 'lit/decorators.js'; import { BaseComponent } from '../../core/base-component'; import { autoSaveService } from '../../services/auto-save'; +import { getActiveProjectContext } from '../../stores/project-context'; import { pwaStore } from '../../stores/pwa'; @customElement('pf-pwa-update-toast') @@ -107,7 +108,8 @@ export class PFPwaUpdateToast extends BaseComponent { }; private restart = () => { - void pwaStore.restartWithUpdate(() => autoSaveService.saveNow()); + const context = getActiveProjectContext(); + void pwaStore.restartWithUpdate(() => autoSaveService.saveNow(context)); }; render() { diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 93233f7..59c3587 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -30,6 +30,7 @@ import "./pf-pwa-update-toast"; import { activeProjectContext, getActiveProjectContext, + type ProjectContext, } from "../../stores/project-context"; import { workspaceStore } from "../../stores/workspace"; import { viewportStore } from "../../stores/viewport"; @@ -313,6 +314,8 @@ export class PixelForgeApp extends BaseComponent { private warningTimer: number | null = null; private fileImportTimer: number | null = null; private fileDropHandlingStarted = false; + private deleteCurrentProjectContext: ProjectContext | null = null; + private exportProjectContext: ProjectContext | null = null; connectedCallback() { super.connectedCallback(); @@ -504,11 +507,10 @@ export class PixelForgeApp extends BaseComponent { }; private handleDuplicateCurrentProject = async () => { + const context = getActiveProjectContext(); try { - await autoSaveService.saveNow(); - await projectLibrary.duplicateProject( - getActiveProjectContext().project.id.value - ); + await autoSaveService.saveNow(context); + await projectLibrary.duplicateProject(context.project.id.value); this.showWarning("Project duplicated"); } catch (error) { log.error("Failed to duplicate project:", error); @@ -517,13 +519,25 @@ export class PixelForgeApp extends BaseComponent { }; private handleDeleteCurrentProject = () => { + this.deleteCurrentProjectContext = getActiveProjectContext(); this.showDeleteCurrentDialog = true; }; + private dismissDeleteCurrentProject = () => { + this.deleteCurrentProjectContext = null; + this.showDeleteCurrentDialog = false; + }; + private handleShowExportDialog = () => { + this.exportProjectContext = getActiveProjectContext(); this.showExportDialog = true; }; + private handleExportDialogClose = () => { + this.exportProjectContext = null; + this.showExportDialog = false; + }; + private handleProjectBrowserClose = () => { if (!this.projectSelectionRequired) { this.showProjectBrowser = false; @@ -551,12 +565,12 @@ export class PixelForgeApp extends BaseComponent { }; private confirmDeleteCurrentProject = async () => { - this.showDeleteCurrentDialog = false; + const context = + this.deleteCurrentProjectContext ?? getActiveProjectContext(); + this.dismissDeleteCurrentProject(); try { - await projectLibrary.deleteProject( - getActiveProjectContext().project.id.value - ); + await projectLibrary.deleteProject(context.project.id.value, { context }); this.handleCurrentProjectDeleted(); } catch (error) { log.error("Failed to delete project:", error); @@ -742,12 +756,14 @@ export class PixelForgeApp extends BaseComponent { const isTimelineCollapsed = panelStore.panelStates.value.timeline?.collapsed ?? false; const activeProject = activeProjectContext.value.project; + const deleteProject = + this.deleteCurrentProjectContext?.project ?? activeProject; return html`