diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json index 487b148d..04bc4dcb 100644 --- a/apps/web/messages/cs.json +++ b/apps/web/messages/cs.json @@ -198,6 +198,7 @@ "editor_error_title": "Chyba při načítání souboru", "project_download_no_files": "V projektu nebyly nalezeny žádné soubory ke stažení", "project_layout_load_error": "Nepodařilo se načíst nastavení rozložení, používá se výchozí", + "project_save_before_leave_error": "Projektové soubory se nepodařilo uložit. Navigace byla zastavena, aby se změny neztratily.", "project_provider_load_failed": "Nepodařilo se načíst projekt", "project_panel_pkg_load_error": "Nepodařilo se načíst knihovny", "project_panel_pkg_versions_error": "Nepodařilo se načíst verze knihovny", @@ -235,6 +236,14 @@ "editor_jacly_autosave_error": "Nepodařilo se uložit automatickou zálohu projektu", "editor_jacly_save_json_error": "Nepodařilo se uložit JSON", "editor_jacly_save_code_error": "Nepodařilo se uložit generovaný kód", + "editor_jacly_recovery_title": "Soubor bloků je poškozený", + "editor_jacly_recovery_description": "Jacly našel platnou zálohu ({backupName}). Nahradit poškozený soubor index.jacly touto zálohou? Poškozený soubor bude zachován.", + "editor_jacly_recovery_keep": "Ponechat poškozený soubor", + "editor_jacly_recovery_replace": "Nahradit ze zálohy", + "editor_jacly_recovery_restoring": "Obnovuji...", + "editor_jacly_recovery_kept": "Poškozený soubor index.jacly nebyl změněn", + "editor_jacly_recovery_success": "Soubor index.jacly byl obnoven ze zálohy", + "editor_jacly_recovery_error": "Obnovení souboru index.jacly ze zálohy se nezdařilo", "editor_jacly_missing_packages": "Některé bloky ve vašem workspace vyžadují balíčky, které nejsou aktuálně nainstalovány. Zkontrolujte chybějící balíčky a nainstalujte je.", "editor_jacly_loading_desc": "Připravuji váš workspace...", "editor_jacly_installing_header": "Instalace balíčků", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 2830fb7e..692cb46f 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -234,6 +234,14 @@ "editor_jacly_autosave_error": "Failed to save project autosave backup", "editor_jacly_save_json_error": "Failed to save JSON", "editor_jacly_save_code_error": "Failed to save generated code", + "editor_jacly_recovery_title": "The blocks file is damaged", + "editor_jacly_recovery_description": "Jacly found a valid backup ({backupName}). Replace the damaged index.jacly file with this backup? The damaged file will be preserved.", + "editor_jacly_recovery_keep": "Keep damaged file", + "editor_jacly_recovery_replace": "Replace from backup", + "editor_jacly_recovery_restoring": "Restoring...", + "editor_jacly_recovery_kept": "The damaged index.jacly file was not changed", + "editor_jacly_recovery_success": "index.jacly was restored from backup", + "editor_jacly_recovery_error": "Failed to restore index.jacly from backup", "editor_jacly_missing_packages": "Some blocks in your workspace require packages that are not currently installed. Please review the missing packages and install them.", "editor_jacly_loading_desc": "Preparing your workspace...", "editor_jacly_installing_header": "Installing Packages", @@ -242,6 +250,7 @@ "editor_code_file_update_error": "Error updating file", "project_download_no_files": "No files found in project to download", "project_layout_load_error": "Failed to load layout settings, using default", + "project_save_before_leave_error": "Failed to save project files. Navigation was stopped to protect your changes.", "device_wifi_network_added": "WiFi network added successfully", "device_wifi_network_add_failed": "Failed to add WiFi network", "device_wifi_network_removed": "WiFi network removed successfully", diff --git a/apps/web/src/editor/code/code-editor-rw.tsx b/apps/web/src/editor/code/code-editor-rw.tsx index 93f926ad..9eedee3f 100644 --- a/apps/web/src/editor/code/code-editor-rw.tsx +++ b/apps/web/src/editor/code/code-editor-rw.tsx @@ -20,6 +20,7 @@ export function CodeEditorRW({ filePath }: CodeEditorRWProps) { const fullPath = `${projectPath}/${filePath}`; useEffect(() => { + setLoading(true); async function loadFile() { if (!monacoService) return; await monacoService.requestFile(filePath); @@ -29,14 +30,9 @@ export function CodeEditorRW({ filePath }: CodeEditorRWProps) { loadFile(); return () => { - monacoService?.closeFile(filePath); + void monacoService?.closeFile(filePath); }; - }, [fullPath]); - - function handleEditorChange(value: string | undefined) { - if (value === undefined) return; - monacoService?.updateFile(filePath, value); - } + }, [filePath, monacoService]); if (loading) { return
{m.editor_loading()}
; @@ -53,7 +49,6 @@ export function CodeEditorRW({ filePath }: CodeEditorRWProps) { automaticLayout: true, fixedOverflowWidgets: true, }} - onChange={handleEditorChange} /> ); } diff --git a/apps/web/src/editor/jacly/index.ts b/apps/web/src/editor/jacly/index.ts index b1c06a2c..cd5e2505 100644 --- a/apps/web/src/editor/jacly/index.ts +++ b/apps/web/src/editor/jacly/index.ts @@ -1,5 +1,12 @@ export { JaclyEditorComponent } from './jacly'; -export { AUTOSAVE_INTERVAL_MS, writeAutosaveBackup, writeStartupBackup } from './jacly-backup'; +export { + AUTOSAVE_INTERVAL_MS, + preserveCorruptIndex, + writeAutosaveBackup, + writeStartupBackup, +} from './jacly-backup'; +export type { JaclyBackupCandidate } from './jacly-backup-recovery'; +export { findNewestValidBackup } from './jacly-backup-recovery'; export type { EditorJaclyActions, EditorJaclyContextValue, @@ -17,4 +24,3 @@ export { export { JaclyEditorPanel } from './jacly-panel'; export { EditorJaclyProvider } from './jacly-provider'; export { jaclySaveCoordinator } from './jacly-save-coordinator'; -export { createLatestFileWriter, type LatestFileWriter } from './latest-file-writer'; diff --git a/apps/web/src/editor/jacly/jacly-backup-recovery.ts b/apps/web/src/editor/jacly/jacly-backup-recovery.ts new file mode 100644 index 00000000..1dfae8ce --- /dev/null +++ b/apps/web/src/editor/jacly/jacly-backup-recovery.ts @@ -0,0 +1,54 @@ +import { basename, extname, join } from 'node:path'; +import type { ProjectFsPromises } from './jacly-files'; + +const BACKUP_DIR = 'backup'; + +export interface JaclyBackupCandidate { + path: string; + name: string; + content: string; + json: object; +} + +export async function findNewestValidBackup( + fsp: ProjectFsPromises, + projectPath: string, + jaclyPath: string, +): Promise { + const name = basename(jaclyPath, extname(jaclyPath)); + const ext = extname(jaclyPath); + const backupDir = join(projectPath, BACKUP_DIR); + + try { + const files = (await fsp.readdir(backupDir)) as string[]; + const candidates = await Promise.all( + files + .filter( + (file) => + (file.startsWith(`${name}-A-`) || file.startsWith(`${name}-S-`)) && file.endsWith(ext), + ) + .map(async (file) => ({ + file, + mtimeMs: (await fsp.stat(join(backupDir, file))).mtimeMs, + })), + ); + candidates.sort((left, right) => right.mtimeMs - left.mtimeMs); + + for (const { file } of candidates) { + const path = join(backupDir, file); + try { + const content = await fsp.readFile(path, 'utf-8'); + const json: unknown = JSON.parse(content); + if (typeof json === 'object' && json !== null && !Array.isArray(json)) { + return { path, name: file, content, json }; + } + } catch { + // Continue to older backups when a backup is incomplete or invalid. + } + } + } catch { + return null; + } + + return null; +} diff --git a/apps/web/src/editor/jacly/jacly-backup.ts b/apps/web/src/editor/jacly/jacly-backup.ts index 55218b38..7f9730ce 100644 --- a/apps/web/src/editor/jacly/jacly-backup.ts +++ b/apps/web/src/editor/jacly/jacly-backup.ts @@ -1,4 +1,5 @@ import { basename, extname, join } from 'node:path'; +import { durableWriteFile } from '@/project'; import type { ProjectFsPromises } from './jacly-files'; export const AUTOSAVE_INTERVAL_MS = 5 * 60 * 1000; @@ -19,7 +20,7 @@ async function writeBackupFile( projectPath: string, jaclyPath: string, content: string, - prefix: 'S' | 'A', + prefix: 'S' | 'A' | 'C', ): Promise { const name = basename(jaclyPath, extname(jaclyPath)); const ext = extname(jaclyPath); @@ -28,7 +29,16 @@ async function writeBackupFile( await fsp.mkdir(backupDir, { recursive: true }); const fileName = `${name}-${prefix}-${formatTimestamp(new Date())}${ext}`; - await fsp.writeFile(join(backupDir, fileName), content, 'utf-8'); + await durableWriteFile(fsp, join(backupDir, fileName), content); +} + +export async function preserveCorruptIndex( + fsp: ProjectFsPromises, + projectPath: string, + jaclyPath: string, + content: string, +): Promise { + await writeBackupFile(fsp, projectPath, jaclyPath, content, 'C'); } async function pruneAutosaveBackups( diff --git a/apps/web/src/editor/jacly/jacly-files.ts b/apps/web/src/editor/jacly/jacly-files.ts index 991fc00b..b49cf739 100644 --- a/apps/web/src/editor/jacly/jacly-files.ts +++ b/apps/web/src/editor/jacly/jacly-files.ts @@ -16,6 +16,7 @@ export async function readOrCreateJsonFile( fs: ProjectFs, fsp: ProjectFsPromises, filePath: string, + createFile: (path: string, content: string) => Promise, ): Promise { await ensureParentDir(fsp, filePath); @@ -23,6 +24,6 @@ export async function readOrCreateJsonFile( return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } - await fsp.writeFile(filePath, '{}', 'utf-8'); + await createFile(filePath, '{}'); return {}; } diff --git a/apps/web/src/editor/jacly/jacly-provider.tsx b/apps/web/src/editor/jacly/jacly-provider.tsx index 425f7caa..2dc97417 100644 --- a/apps/web/src/editor/jacly/jacly-provider.tsx +++ b/apps/web/src/editor/jacly/jacly-provider.tsx @@ -6,14 +6,18 @@ import { m } from '@/core/paraglide/messages'; import { getLocale } from '@/core/paraglide/runtime'; import { useJacDevice } from '@/device'; import { packageEventsService } from '@/packages'; -import { useActiveProject } from '@/project'; -import { AUTOSAVE_INTERVAL_MS, writeAutosaveBackup, writeStartupBackup } from './jacly-backup'; +import { createLatestFileWriter, durableWriteFile, useActiveProject } from '@/project'; +import { + AUTOSAVE_INTERVAL_MS, + preserveCorruptIndex, + writeAutosaveBackup, + writeStartupBackup, +} from './jacly-backup'; +import { findNewestValidBackup, type JaclyBackupCandidate } from './jacly-backup-recovery'; import { EditorJaclyContext } from './jacly-context'; import { ensureParentDir, readOrCreateJsonFile } from './jacly-files'; +import { JaclyRecoveryDialog } from './jacly-recovery-dialog'; import { jaclySaveCoordinator } from './jacly-save-coordinator'; -import { createLatestFileWriter } from './latest-file-writer'; - -const FILE_RELOAD_DELAY_MS = 50; export function EditorJaclyProvider({ children }: { children: ReactNode }) { const { @@ -27,6 +31,9 @@ export function EditorJaclyProvider({ children }: { children: ReactNode }) { const [engine] = useState(() => new JaclyEngine()); const [initialJson, setInitialJson] = useState(null); const [jaclyBlocksData, setJaclyBlocksData] = useState(null); + const [recoveryCandidate, setRecoveryCandidate] = useState(null); + const [corruptJsonContent, setCorruptJsonContent] = useState(null); + const [restoringBackup, setRestoringBackup] = useState(false); const latestJsonContentRef = useRef(null); const jsonWriterRef = useRef | null>(null); const codeWriterRef = useRef | null>(null); @@ -39,7 +46,11 @@ export function EditorJaclyProvider({ children }: { children: ReactNode }) { filePath: jsonPath, writeFile: async (filePath, content, encoding) => { await ensureParentDir(fsp, filePath); - await fsp.writeFile(filePath, content, encoding); + await durableWriteFile(fsp, filePath, content, encoding); + }, + onError: (error) => { + console.error('Failed to save Jacly JSON:', error); + enqueueSnackbar(m.editor_jacly_save_json_error(), { variant: 'error' }); }, }); @@ -47,16 +58,23 @@ export function EditorJaclyProvider({ children }: { children: ReactNode }) { filePath: codePath, writeFile: async (filePath, content, encoding) => { await ensureParentDir(fsp, filePath); - await fsp.writeFile(filePath, content, encoding); + await durableWriteFile(fsp, filePath, content, encoding); + }, + onError: (error) => { + console.error('Failed to save generated code:', error); + enqueueSnackbar(m.editor_jacly_save_code_error(), { variant: 'error' }); }, }); - const unregisterFlush = jaclySaveCoordinator.registerFlushCallback(async () => { - await Promise.all([ - jsonWriterRef.current?.flushPending(), - codeWriterRef.current?.flushPending(), - ]); - }); + const unregisterFlush = jaclySaveCoordinator.registerFlushCallback( + async () => { + await Promise.all([ + jsonWriterRef.current?.flushPending(), + codeWriterRef.current?.flushPending(), + ]); + }, + () => Boolean(jsonWriterRef.current?.isPending() || codeWriterRef.current?.isPending()), + ); return () => { unregisterFlush(); @@ -71,40 +89,8 @@ export function EditorJaclyProvider({ children }: { children: ReactNode }) { useEffect(() => { let cancelled = false; - let reloadTimer: ReturnType | undefined; - let watcher: ReturnType | undefined; const jsonPath = getFileName('JACLY_INDEX'); - const clearReloadTimer = () => { - if (!reloadTimer) return; - clearTimeout(reloadTimer); - reloadTimer = undefined; - }; - - const reloadJsonFromDisk = async () => { - try { - const content = await fsp.readFile(jsonPath, 'utf-8'); - if (content === latestJsonContentRef.current) { - return; - } - const parsed = JSON.parse(content) as object; - latestJsonContentRef.current = JSON.stringify(parsed, null, 2); - if (!cancelled) { - setInitialJson(parsed); - } - } catch (error) { - console.error('Failed to reload Jacly JSON from disk:', error); - enqueueSnackbar(m.editor_jacly_load_error(), { variant: 'error' }); - } - }; - - const scheduleReload = () => { - clearReloadTimer(); - reloadTimer = setTimeout(() => { - void reloadJsonFromDisk(); - }, FILE_RELOAD_DELAY_MS); - }; - async function load() { if (!jacProject) { if (!cancelled) { @@ -115,10 +101,24 @@ export function EditorJaclyProvider({ children }: { children: ReactNode }) { } try { - const [jsonData, blockData] = await Promise.all([ - readOrCreateJsonFile(fs, fsp, jsonPath), - jacProject.getJaclyData(getLocale()), - ]); + const blockData = await jacProject.getJaclyData(getLocale()); + let jsonData: object; + try { + jsonData = await readOrCreateJsonFile(fs, fsp, jsonPath, async (path, content) => { + await durableWriteFile(fsp, path, content); + }); + } catch (error) { + const corruptContent = await fsp.readFile(jsonPath, 'utf-8'); + const candidate = await findNewestValidBackup(fsp, projectPath, jsonPath); + if (!candidate) throw error; + + if (!cancelled) { + setJaclyBlocksData(blockData); + setCorruptJsonContent(corruptContent); + setRecoveryCandidate(candidate); + } + return; + } const serialized = JSON.stringify(jsonData, null, 2); latestJsonContentRef.current = serialized; if (cancelled) return; @@ -127,18 +127,12 @@ export function EditorJaclyProvider({ children }: { children: ReactNode }) { void writeStartupBackup(fsp, projectPath, jsonPath, serialized).catch((err) => console.error('Failed to create startup backup:', err), ); - watcher = fs.watch(jsonPath, (eventType) => { - if (cancelled) return; - if (eventType === 'rename' || eventType === 'change') { - scheduleReload(); - } - }); } catch (error) { console.error('Failed to load editor data:', error); enqueueSnackbar(m.editor_jacly_load_error(), { variant: 'error' }); if (!cancelled) { - latestJsonContentRef.current = '{}'; - setInitialJson({}); + latestJsonContentRef.current = null; + setInitialJson(null); } } } @@ -147,8 +141,6 @@ export function EditorJaclyProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; - clearReloadTimer(); - watcher?.close(); }; }, [fs, fsp, projectPath, getFileName, jacProject]); @@ -200,6 +192,31 @@ export function EditorJaclyProvider({ children }: { children: ReactNode }) { } }, []); + const handleRecoveryCancel = useCallback(() => { + setRecoveryCandidate(null); + enqueueSnackbar(m.editor_jacly_recovery_kept(), { variant: 'warning' }); + }, []); + + const handleRecoveryConfirm = useCallback(async () => { + if (!recoveryCandidate || corruptJsonContent == null) return; + const jsonPath = getFileName('JACLY_INDEX'); + setRestoringBackup(true); + try { + await preserveCorruptIndex(fsp, projectPath, jsonPath, corruptJsonContent); + await durableWriteFile(fsp, jsonPath, recoveryCandidate.content); + latestJsonContentRef.current = recoveryCandidate.content; + setInitialJson(recoveryCandidate.json); + setRecoveryCandidate(null); + setCorruptJsonContent(null); + enqueueSnackbar(m.editor_jacly_recovery_success(), { variant: 'success' }); + } catch (error) { + console.error('Failed to restore Jacly backup:', error); + enqueueSnackbar(m.editor_jacly_recovery_error(), { variant: 'error' }); + } finally { + setRestoringBackup(false); + } + }, [corruptJsonContent, fsp, getFileName, projectPath, recoveryCandidate]); + return ( {children} + {recoveryCandidate ? ( + void handleRecoveryConfirm()} + /> + ) : null} ); } diff --git a/apps/web/src/editor/jacly/jacly-recovery-dialog.tsx b/apps/web/src/editor/jacly/jacly-recovery-dialog.tsx new file mode 100644 index 00000000..076bed57 --- /dev/null +++ b/apps/web/src/editor/jacly/jacly-recovery-dialog.tsx @@ -0,0 +1,46 @@ +import { m } from '@/core/paraglide/messages'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/ui/components/alert-dialog'; + +interface JaclyRecoveryDialogProps { + backupName: string; + restoring: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function JaclyRecoveryDialog({ + backupName, + restoring, + onCancel, + onConfirm, +}: JaclyRecoveryDialogProps) { + return ( + + + + {m.editor_jacly_recovery_title()} + + {m.editor_jacly_recovery_description({ backupName })} + + + + + {m.editor_jacly_recovery_keep()} + + + {restoring ? m.editor_jacly_recovery_restoring() : m.editor_jacly_recovery_replace()} + + + + + ); +} diff --git a/apps/web/src/editor/jacly/jacly-save-coordinator.ts b/apps/web/src/editor/jacly/jacly-save-coordinator.ts index 562542f1..d7674f44 100644 --- a/apps/web/src/editor/jacly/jacly-save-coordinator.ts +++ b/apps/web/src/editor/jacly/jacly-save-coordinator.ts @@ -1,19 +1,24 @@ type FlushCallback = () => Promise; +type PendingCallback = () => boolean; class JaclySaveCoordinator { - private callbacks = new Set(); + private callbacks = new Map(); - registerFlushCallback(callback: FlushCallback): () => void { - this.callbacks.add(callback); + registerFlushCallback(callback: FlushCallback, isPending: PendingCallback): () => void { + this.callbacks.set(callback, isPending); return () => { this.callbacks.delete(callback); }; } async flushPendingWrites(): Promise { - const callbacks = [...this.callbacks]; + const callbacks = [...this.callbacks.keys()]; await Promise.all(callbacks.map((callback) => callback())); } + + hasPendingWrites(): boolean { + return [...this.callbacks.values()].some((isPending) => isPending()); + } } export const jaclySaveCoordinator = new JaclySaveCoordinator(); diff --git a/apps/web/src/installer/installer-provider.tsx b/apps/web/src/installer/installer-provider.tsx index 25d18199..d57d5b6c 100644 --- a/apps/web/src/installer/installer-provider.tsx +++ b/apps/web/src/installer/installer-provider.tsx @@ -5,7 +5,13 @@ import { getBoardVersions, } from '@jaculus/firmware/boards'; import { getRequest } from '@jaculus/jacly/project'; -import { ESPLoader, type IEspLoaderTerminal, type LoaderOptions, Transport } from 'esptool-js'; +import { + ESPLoader, + type FlashSizeValues, + type IEspLoaderTerminal, + type LoaderOptions, + Transport, +} from 'esptool-js'; import { enqueueSnackbar } from 'notistack'; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { logger } from '@/core'; @@ -266,7 +272,7 @@ export function InstallerProvider({ const newFlasher = new ESP32Flasher(terminal, (progress) => { setState((prev) => ({ ...prev, flashProgress: progress })); }); - await newFlasher.setup(newEsploader); + await newFlasher.setup(newEsploader, flashSize as FlashSizeValues); flasherRef.current = newFlasher; changeChip(chipName); diff --git a/apps/web/src/installer/services/flasher.ts b/apps/web/src/installer/services/flasher.ts index 8274728a..1ae9a2ce 100644 --- a/apps/web/src/installer/services/flasher.ts +++ b/apps/web/src/installer/services/flasher.ts @@ -1,6 +1,6 @@ import { type Manifest, parseManifest } from '@jaculus/firmware/manifest'; import { Archive } from '@obsidize/tar-browserify'; -import type { ESPLoader, IEspLoaderTerminal } from 'esptool-js'; +import type { ESPLoader, FlashSizeValues, IEspLoaderTerminal } from 'esptool-js'; import pako from 'pako'; export interface FlashProgress { @@ -16,6 +16,7 @@ export interface FlashProgress { // Downloads, extracts, and flashes firmware to ESP32 family devices. export class ESP32Flasher { private esploader: ESPLoader | null = null; + private flashSize: FlashSizeValues = '4MB'; private terminal: IEspLoaderTerminal; private onProgress?: (progress: FlashProgress) => void; @@ -90,8 +91,11 @@ export class ESP32Flasher { } // Attaches an already-initialized ESPLoader instance. - async setup(esploader: ESPLoader): Promise { + async setup(esploader: ESPLoader, flashSize?: FlashSizeValues): Promise { this.esploader = esploader; + if (flashSize) { + this.flashSize = flashSize; + } this.terminal.writeLine(`Using ESPLoader for chip: ${this.esploader.chip.CHIP_NAME}`); } @@ -154,7 +158,7 @@ export class ESP32Flasher { await this.esploader.writeFlash({ fileArray, - flashSize: '4MB', + flashSize: this.flashSize, flashMode: 'keep', flashFreq: 'keep', eraseAll: false, diff --git a/apps/web/src/project/index.ts b/apps/web/src/project/index.ts index 5d310091..4e857d1c 100644 --- a/apps/web/src/project/index.ts +++ b/apps/web/src/project/index.ts @@ -50,6 +50,11 @@ export { packProjectAsTarGz, packProjectAsZip, } from './services/download'; +export { durableWriteFile } from './services/durable-file-write'; +export { + createLatestFileWriter, + type LatestFileWriter, +} from './services/latest-file-writer'; export { loadPackageFromBytes, loadPackageFromFile, diff --git a/apps/web/src/project/services/deferred-unmount.ts b/apps/web/src/project/services/deferred-unmount.ts new file mode 100644 index 00000000..928b3f2c --- /dev/null +++ b/apps/web/src/project/services/deferred-unmount.ts @@ -0,0 +1,19 @@ +export class DeferredUnmountCoordinator { + private pendingUnmounts = new Map(); + + cancel(key: string): void { + this.pendingUnmounts.delete(key); + } + + schedule(key: string, pendingWork: Promise, unmount: () => void): void { + const token = Symbol(key); + this.pendingUnmounts.set(key, token); + void pendingWork + .catch(() => undefined) + .finally(() => { + if (this.pendingUnmounts.get(key) !== token) return; + this.pendingUnmounts.delete(key); + unmount(); + }); + } +} diff --git a/apps/web/src/project/services/durable-file-write.ts b/apps/web/src/project/services/durable-file-write.ts new file mode 100644 index 00000000..14d551af --- /dev/null +++ b/apps/web/src/project/services/durable-file-write.ts @@ -0,0 +1,83 @@ +interface DurableFilePromises { + writeFile(path: string, content: string, encoding: BufferEncoding): Promise; + readFile(path: string, encoding: BufferEncoding): Promise; + rename(oldPath: string, newPath: string): Promise; + unlink(path: string): Promise; +} + +const MAX_WRITE_ATTEMPTS = 3; + +let temporaryFileCounter = 0; +const destinationWriteQueues = new Map>(); + +function getTemporaryPath(filePath: string): string { + temporaryFileCounter += 1; + return `${filePath}.tmp-${Date.now()}-${temporaryFileCounter}`; +} + +async function writeAndVerify( + fsp: DurableFilePromises, + filePath: string, + content: string, + encoding: BufferEncoding, +): Promise { + const temporaryPath = getTemporaryPath(filePath); + + try { + await fsp.writeFile(temporaryPath, content, encoding); + const temporaryContent = await fsp.readFile(temporaryPath, encoding); + if (temporaryContent !== content) { + throw new Error(`Temporary file verification failed for ${filePath}`); + } + + await fsp.rename(temporaryPath, filePath); + const persistedContent = await fsp.readFile(filePath, encoding); + if (persistedContent !== content) { + throw new Error(`Persisted file verification failed for ${filePath}`); + } + } catch (error) { + await fsp.unlink(temporaryPath).catch(() => undefined); + throw error; + } +} + +async function writeWithRetries( + fsp: DurableFilePromises, + filePath: string, + content: string, + encoding: BufferEncoding, +): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt += 1) { + try { + await writeAndVerify(fsp, filePath, content, encoding); + return; + } catch (error) { + lastError = error; + } + } + + throw lastError; +} + +export async function durableWriteFile( + fsp: DurableFilePromises, + filePath: string, + content: string, + encoding: BufferEncoding = 'utf-8', +): Promise { + const previousWrite = destinationWriteQueues.get(filePath) ?? Promise.resolve(); + const currentWrite = previousWrite + .catch(() => undefined) + .then(() => writeWithRetries(fsp, filePath, content, encoding)); + destinationWriteQueues.set(filePath, currentWrite); + + try { + await currentWrite; + } finally { + if (destinationWriteQueues.get(filePath) === currentWrite) { + destinationWriteQueues.delete(filePath); + } + } +} diff --git a/apps/web/src/editor/jacly/latest-file-writer.ts b/apps/web/src/project/services/latest-file-writer.ts similarity index 63% rename from apps/web/src/editor/jacly/latest-file-writer.ts rename to apps/web/src/project/services/latest-file-writer.ts index 8f8ab22e..f6a6d219 100644 --- a/apps/web/src/editor/jacly/latest-file-writer.ts +++ b/apps/web/src/project/services/latest-file-writer.ts @@ -3,18 +3,22 @@ type WriteFileFn = (path: string, content: string, encoding: BufferEncoding) => export interface LatestFileWriter { schedule: (content: string) => void; flushPending: () => Promise; + isPending: () => boolean; dispose: () => Promise; } export function createLatestFileWriter({ writeFile, filePath, + onError, }: { writeFile: WriteFileFn; filePath: string; + onError?: (error: unknown) => void; }): LatestFileWriter { let pendingContent: string | null = null; let activeWrite: Promise | null = null; + let writeError: unknown = null; const flushLoop = async () => { while (pendingContent != null) { @@ -24,33 +28,36 @@ export function createLatestFileWriter({ } }; - const ensureFlush = () => { + const ensureFlush = (): Promise => { if (activeWrite) { return activeWrite; } - activeWrite = (async () => { - try { - await flushLoop(); - } finally { + activeWrite = flushLoop() + .then(() => { + writeError = null; + }) + .catch((error) => { + writeError = error; + throw error; + }) + .finally(() => { activeWrite = null; - if (pendingContent != null) { - await ensureFlush(); - } - } - })(); + }); return activeWrite; }; const schedule = (content: string) => { pendingContent = content; - void ensureFlush(); + void ensureFlush().catch((error) => onError?.(error)); }; const flushPending = async () => { - if (pendingContent == null && !activeWrite) return; - await ensureFlush(); + while (pendingContent != null || activeWrite) { + await ensureFlush(); + } + if (writeError) throw writeError; }; const dispose = async () => { @@ -60,6 +67,7 @@ export function createLatestFileWriter({ return { schedule, flushPending, + isPending: () => pendingContent != null || activeWrite != null, dispose, }; } diff --git a/apps/web/src/project/services/monaco-service.ts b/apps/web/src/project/services/monaco-service.ts index 73fbc94f..d0bca3c3 100644 --- a/apps/web/src/project/services/monaco-service.ts +++ b/apps/web/src/project/services/monaco-service.ts @@ -1,6 +1,8 @@ import type { FSInterface } from '@jaculus/project/fs'; import type { useMonaco } from '@monaco-editor/react'; import { inferLanguageFromPath } from '@/editor'; +import { durableWriteFile } from './durable-file-write'; +import { createLatestFileWriter, type LatestFileWriter } from './latest-file-writer'; import type { TypeScriptIntelliSenseService } from './ts-intellisense-service'; export type Monaco = NonNullable>; @@ -10,6 +12,10 @@ export class MonacoService { private projectPath: string; private openedFiles: Set = new Set(); private watchers: Map> = new Map(); + private writers: Map = new Map(); + private reloadTimers: Map> = new Map(); + private localRevisions: Map = new Map(); + private applyingExternalChanges: Set = new Set(); private tsService: TypeScriptIntelliSenseService | null = null; constructor(fs: FSInterface, monaco: Monaco, projectPath: string) { @@ -36,6 +42,57 @@ export class MonacoService { this.watchers.delete(filePath); } + private clearReloadTimer(filePath: string) { + const timer = this.reloadTimers.get(filePath); + if (!timer) return; + clearTimeout(timer); + this.reloadTimers.delete(filePath); + } + + private scheduleReload( + filePath: string, + fullPath: string, + uri: ReturnType, + ) { + this.clearReloadTimer(filePath); + const timer = setTimeout(async () => { + this.reloadTimers.delete(filePath); + const model = this.monaco.editor.getModel(uri); + const writer = this.writers.get(filePath); + if (!model || writer?.isPending()) return; + + const revisionBeforeRead = this.localRevisions.get(filePath) ?? 0; + try { + const newContent = await this.fs.promises.readFile(fullPath, 'utf-8'); + if ( + revisionBeforeRead !== (this.localRevisions.get(filePath) ?? 0) || + writer?.isPending() || + newContent === model.getValue() + ) { + return; + } + + this.applyingExternalChanges.add(filePath); + try { + model.pushEditOperations( + [], + [{ range: model.getFullModelRange(), text: newContent }], + () => null, + ); + } finally { + this.applyingExternalChanges.delete(filePath); + } + } catch (error) { + if ((error as { code?: string }).code === 'ENOENT') { + await this.closeFile(filePath); + return; + } + console.error(`Failed to reload file ${filePath}:`, error); + } + }, 50); + this.reloadTimers.set(filePath, timer); + } + async requestFile(filePath: string) { try { const fullPath = this.getFullPath(filePath); @@ -53,29 +110,27 @@ export class MonacoService { } this.openedFiles.add(filePath); + this.localRevisions.set(filePath, 0); + this.writers.set( + filePath, + createLatestFileWriter({ + filePath: fullPath, + writeFile: async (path, content, encoding) => { + await durableWriteFile(this.fs.promises, path, content, encoding); + }, + onError: (error) => console.error(`Failed to save file ${filePath}:`, error), + }), + ); model.onDidChangeContent(() => { - if (model!.isDisposed()) return; + if (model!.isDisposed() || this.applyingExternalChanges.has(filePath)) return; const value = model!.getValue(); this.updateFile(filePath, value); }); - const watcher = this.fs.watch(fullPath, async (eventType) => { - if (eventType === 'rename') { - await this.closeFile(filePath); - } else if (eventType === 'change') { - setTimeout(async () => { - const m = this.monaco.editor.getModel(uri); - if (!m) return; - try { - const newContent = await this.fs.promises.readFile(fullPath, 'utf-8'); - if (newContent === m.getValue()) return; - const fullRange = m.getFullModelRange(); - m.pushEditOperations([], [{ range: fullRange, text: newContent }], () => null); - } catch (err) { - console.error(`Failed to reload file ${filePath}:`, err); - } - }, 50); + const watcher = this.fs.watch(fullPath, (eventType) => { + if (eventType === 'rename' || eventType === 'change') { + this.scheduleReload(filePath, fullPath, uri); } }); this.watchers.set(filePath, watcher); @@ -85,13 +140,20 @@ export class MonacoService { } } - async updateFile(filePath: string, content: string) { - const fullPath = this.getFullPath(filePath); - await this.fs.promises.writeFile(fullPath, content, 'utf-8'); + updateFile(filePath: string, content: string) { + const writer = this.writers.get(filePath); + if (!writer) return; + this.localRevisions.set(filePath, (this.localRevisions.get(filePath) ?? 0) + 1); + writer.schedule(content); } async closeFile(filePath: string) { this.closeWatcher(filePath); + this.clearReloadTimer(filePath); + + const writer = this.writers.get(filePath); + this.writers.delete(filePath); + await writer?.dispose(); const fullPath = this.getFullPath(filePath); const uri = this.monaco.Uri.file(fullPath); @@ -101,9 +163,17 @@ export class MonacoService { } this.openedFiles.delete(filePath); + this.localRevisions.delete(filePath); + this.applyingExternalChanges.delete(filePath); + } + + async flush() { + await Promise.all(Array.from(this.writers.values(), (writer) => writer.flushPending())); } - async flush() {} + hasPendingWrites() { + return Array.from(this.writers.values(), (writer) => writer.isPending()).some(Boolean); + } async dispose() { await Promise.all(Array.from(this.openedFiles, (filePath) => this.closeFile(filePath))); diff --git a/apps/web/src/project/services/project-fs-service.ts b/apps/web/src/project/services/project-fs-service.ts index 4b34eeed..66ab1933 100644 --- a/apps/web/src/project/services/project-fs-service.ts +++ b/apps/web/src/project/services/project-fs-service.ts @@ -3,6 +3,7 @@ import { Zip } from '@zenfs/archives'; import { configure, fs, mount, mounts, resolveMountConfig, umount } from '@zenfs/core'; import { IndexedDB } from '@zenfs/dom'; import { enqueueSnackbar } from 'notistack'; +import { DeferredUnmountCoordinator } from './deferred-unmount'; export interface ProjectFsInterface { fs: FSInterface; @@ -48,6 +49,8 @@ export async function mountProject(projectId: string): Promise { + this.deferredUnmounts.cancel(projectId); + return mountProject(projectId); + } + + unmount(projectId: string): void { + this.deferredUnmounts.cancel(projectId); + unmountProject(projectId); + } + + unmountAfter(projectId: string, pendingWork: Promise): void { + this.deferredUnmounts.schedule(projectId, pendingWork, () => unmountProject(projectId)); + } + async withMount( projectId: string, action: (fsInterface: ProjectFsInterface) => Promise, ): Promise { const wasMounted = isMounted(projectId); - const fsInterface = await mountProject(projectId); + const fsInterface = await this.mount(projectId); try { return await action(fsInterface); } finally { if (!wasMounted) { - unmountProject(projectId); + this.unmount(projectId); } } } diff --git a/apps/web/src/project/state/active-project-provider.tsx b/apps/web/src/project/state/active-project-provider.tsx index 4078dc1a..7336a135 100644 --- a/apps/web/src/project/state/active-project-provider.tsx +++ b/apps/web/src/project/state/active-project-provider.tsx @@ -22,6 +22,7 @@ interface ActiveProjectProviderProps { dbProject: IDbProject; projectFsService: ProjectFsService; projectManService: ProjectManagementService; + flushPendingWrites?: () => Promise; children: ReactNode; } @@ -33,6 +34,7 @@ export function ActiveProjectProvider({ dbProject: project, projectFsService, projectManService, + flushPendingWrites, children, }: ActiveProjectProviderProps) { const monaco = useMonaco(); @@ -69,13 +71,17 @@ export function ActiveProjectProvider({ return () => { projectManService.touchProject(project.id); mounted = false; - projectFsService.unmount(project.id); tsService?.dispose(); - service?.dispose(); + const pendingCleanup = Promise.all([flushPendingWrites?.(), service?.dispose()]).catch( + (cleanupError) => { + console.error('Failed to flush project files before unmount:', cleanupError); + }, + ); + projectFsService.unmountAfter(project.id, pendingCleanup); setMonacoService(null); setError(null); }; - }, [project.id, projectFsService, projectManService, monaco]); + }, [project.id, projectFsService, projectManService, monaco, flushPendingWrites]); const getFileName = useCallback( (fileType: keyof typeof JaclyFiles) => { @@ -118,7 +124,7 @@ export function ActiveProjectProvider({ error, monacoService, }; - }, [fsInterface, currentProject, error]); + }, [fsInterface, currentProject, error, monacoService]); const actions = useMemo( () => ({ diff --git a/apps/web/src/routes/project/$projectId.tsx b/apps/web/src/routes/project/$projectId.tsx index d11f81f5..4d6c0101 100644 --- a/apps/web/src/routes/project/$projectId.tsx +++ b/apps/web/src/routes/project/$projectId.tsx @@ -1,10 +1,13 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; import { enqueueSnackbar } from 'notistack'; +import { useCallback } from 'react'; import { Console } from '@/console'; import { m } from '@/core/paraglide/messages'; import { JacDevice } from '@/device'; +import { jaclySaveCoordinator } from '@/editor'; import { JacPackages } from '@/packages'; import { ActiveProject, ProjectEditor } from '@/project'; +import { ProjectSaveGuard } from './-project-save-guard'; export const Route = createFileRoute('/project/$projectId')({ loader: async ({ context, params }) => { @@ -23,13 +26,16 @@ export const Route = createFileRoute('/project/$projectId')({ function ProjectEditorRoute() { const project = Route.useLoaderData(); const { projectFsService, projectManService, streamBusService } = Route.useRouteContext(); + const flushPendingWrites = useCallback(() => jaclySaveCoordinator.flushPendingWrites(), []); return ( + diff --git a/apps/web/src/routes/project/-project-save-guard.tsx b/apps/web/src/routes/project/-project-save-guard.tsx new file mode 100644 index 00000000..44f16656 --- /dev/null +++ b/apps/web/src/routes/project/-project-save-guard.tsx @@ -0,0 +1,57 @@ +import { useBlocker } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; +import { useCallback, useEffect } from 'react'; +import { m } from '@/core/paraglide/messages'; +import { jaclySaveCoordinator } from '@/editor'; +import { useActiveProject } from '@/project'; + +export function ProjectSaveGuard() { + const { + state: { monacoService }, + } = useActiveProject(); + + const hasPendingWrites = useCallback( + () => jaclySaveCoordinator.hasPendingWrites() || Boolean(monacoService?.hasPendingWrites()), + [monacoService], + ); + + const flushPendingWrites = useCallback(async () => { + await Promise.all([jaclySaveCoordinator.flushPendingWrites(), monacoService?.flush()]); + }, [monacoService]); + + useBlocker({ + shouldBlockFn: async () => { + try { + await flushPendingWrites(); + return false; + } catch (error) { + console.error('Failed to flush project files before navigation:', error); + enqueueSnackbar(m.project_save_before_leave_error(), { variant: 'error' }); + return true; + } + }, + enableBeforeUnload: hasPendingWrites, + }); + + useEffect(() => { + const flushBestEffort = () => { + void flushPendingWrites().catch((error) => { + console.error('Failed to flush project files during page lifecycle change:', error); + }); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden' && hasPendingWrites()) { + flushBestEffort(); + } + }; + + window.addEventListener('pagehide', flushBestEffort); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + window.removeEventListener('pagehide', flushBestEffort); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [flushPendingWrites, hasPendingWrites]); + + return null; +} diff --git a/apps/web/tests/mocha/debounced-file-writer.test.ts b/apps/web/tests/mocha/debounced-file-writer.test.ts index 319c7552..89947586 100644 --- a/apps/web/tests/mocha/debounced-file-writer.test.ts +++ b/apps/web/tests/mocha/debounced-file-writer.test.ts @@ -1,6 +1,6 @@ import * as chai from 'chai'; import 'mocha'; -import { createLatestFileWriter } from '../../src/editor/jacly/latest-file-writer'; +import { createLatestFileWriter } from '../../src/project/services/latest-file-writer'; const expect = chai.expect; @@ -80,4 +80,27 @@ describe('createLatestFileWriter', () => { expect(writes).to.deep.equal(['one', 'three']); await writer.dispose(); }); + + it('reports asynchronous write failures and rejects later flushes', async () => { + const errors: unknown[] = []; + const writer = createLatestFileWriter({ + filePath: '/tmp/test.json', + writeFile: async () => { + throw new Error('disk failed'); + }, + onError: (error) => errors.push(error), + }); + + writer.schedule('content'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(errors).to.have.length(1); + let error: unknown; + try { + await writer.flushPending(); + } catch (caught) { + error = caught; + } + expect(error).to.be.an('error').with.property('message', 'disk failed'); + }); }); diff --git a/apps/web/tests/mocha/durable-file-write.test.ts b/apps/web/tests/mocha/durable-file-write.test.ts new file mode 100644 index 00000000..61158674 --- /dev/null +++ b/apps/web/tests/mocha/durable-file-write.test.ts @@ -0,0 +1,130 @@ +import * as chai from 'chai'; +import 'mocha'; +import { durableWriteFile } from '../../src/project/services/durable-file-write'; + +const expect = chai.expect; + +describe('durableWriteFile', () => { + it('finishes the temporary file before replacing the destination', async () => { + const operations: string[] = []; + const files = new Map(); + let temporaryPath = ''; + const fsp = { + writeFile: async (path: string, content: string) => { + temporaryPath = path; + files.set(path, content); + operations.push(`write:${path}:${content}`); + }, + readFile: async (path: string) => { + operations.push(`read:${path}`); + return files.get(path) ?? ''; + }, + rename: async (source: string, destination: string) => { + files.set(destination, files.get(source) ?? ''); + files.delete(source); + operations.push(`rename:${source}:${destination}`); + }, + unlink: async (path: string) => { + files.delete(path); + operations.push(`unlink:${path}`); + }, + }; + + await durableWriteFile(fsp, '/project/src/index.jacly', 'large content'); + + expect(temporaryPath).to.match(/^\/project\/src\/index\.jacly\.tmp-/); + expect(operations).to.deep.equal([ + `write:${temporaryPath}:large content`, + `read:${temporaryPath}`, + `rename:${temporaryPath}:/project/src/index.jacly`, + 'read:/project/src/index.jacly', + ]); + }); + + it('removes an incomplete temporary file when replacement fails', async () => { + const removed: string[] = []; + const fsp = { + writeFile: async () => undefined, + readFile: async () => 'content', + rename: async () => { + throw new Error('rename failed'); + }, + unlink: async (path: string) => { + removed.push(path); + }, + }; + + let error: unknown; + try { + await durableWriteFile(fsp, '/project/src/index.jacly', 'content'); + } catch (caught) { + error = caught; + } + expect(error).to.be.an('error').with.property('message', 'rename failed'); + expect(removed).to.have.length(3); + for (const path of removed) { + expect(path).to.match(/^\/project\/src\/index\.jacly\.tmp-/); + } + }); + + it('retries when ZenFS returns truncated large-file content', async () => { + const content = JSON.stringify({ data: 'x'.repeat(2_000_000) }); + const files = new Map(); + let writes = 0; + const fsp = { + writeFile: async (path: string, nextContent: string) => { + writes += 1; + files.set(path, writes === 1 ? nextContent.slice(0, -100) : nextContent); + }, + readFile: async (path: string) => files.get(path) ?? '', + rename: async (source: string, destination: string) => { + files.set(destination, files.get(source) ?? ''); + files.delete(source); + }, + unlink: async (path: string) => { + files.delete(path); + }, + }; + + await durableWriteFile(fsp, '/project/src/index.jacly', content); + + expect(writes).to.equal(2); + expect(files.get('/project/src/index.jacly')).to.equal(content); + }); + + it('serializes independent writers targeting the same file', async () => { + const files = new Map(); + const startedWrites: string[] = []; + let releaseFirstWrite: (() => void) | undefined; + const fsp = { + writeFile: async (path: string, content: string) => { + startedWrites.push(content); + if (content === 'first') { + await new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + } + files.set(path, content); + }, + readFile: async (path: string) => files.get(path) ?? '', + rename: async (source: string, destination: string) => { + files.set(destination, files.get(source) ?? ''); + files.delete(source); + }, + unlink: async (path: string) => { + files.delete(path); + }, + }; + + const firstWrite = durableWriteFile(fsp, '/project/src/index.jacly', 'first'); + const secondWrite = durableWriteFile(fsp, '/project/src/index.jacly', 'second'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(startedWrites).to.deep.equal(['first']); + releaseFirstWrite?.(); + await Promise.all([firstWrite, secondWrite]); + + expect(startedWrites).to.deep.equal(['first', 'second']); + expect(files.get('/project/src/index.jacly')).to.equal('second'); + }); +}); diff --git a/apps/web/tests/mocha/jacly-backup.test.ts b/apps/web/tests/mocha/jacly-backup.test.ts new file mode 100644 index 00000000..8b641592 --- /dev/null +++ b/apps/web/tests/mocha/jacly-backup.test.ts @@ -0,0 +1,43 @@ +import * as chai from 'chai'; +import 'mocha'; +import { findNewestValidBackup } from '../../src/editor/jacly/jacly-backup-recovery'; +import type { ProjectFsPromises } from '../../src/editor/jacly/jacly-files'; + +const expect = chai.expect; + +describe('findNewestValidBackup', () => { + it('skips a newer invalid backup and returns the newest valid JSON object', async () => { + const contents = new Map([ + ['/project/backup/index-A-new.jacly', '{"blocks":'], + ['/project/backup/index-S-middle.jacly', '{"blocks":{"languageVersion":0}}'], + ['/project/backup/index-A-old.jacly', '{"old":true}'], + ]); + const mtimes = new Map([ + ['/project/backup/index-A-new.jacly', 300], + ['/project/backup/index-S-middle.jacly', 200], + ['/project/backup/index-A-old.jacly', 100], + ]); + const fsp = { + readdir: async () => ['index-A-old.jacly', 'index-S-middle.jacly', 'index-A-new.jacly'], + stat: async (path: string) => ({ mtimeMs: mtimes.get(path) ?? 0 }), + readFile: async (path: string) => contents.get(path) ?? '', + } as unknown as ProjectFsPromises; + + const backup = await findNewestValidBackup(fsp, '/project', '/project/src/index.jacly'); + + expect(backup?.name).to.equal('index-S-middle.jacly'); + expect(backup?.json).to.deep.equal({ blocks: { languageVersion: 0 } }); + }); + + it('returns null when no backup contains a JSON object', async () => { + const fsp = { + readdir: async () => ['index-A-invalid.jacly'], + stat: async () => ({ mtimeMs: 100 }), + readFile: async () => '[1,2,3]', + } as unknown as ProjectFsPromises; + + const backup = await findNewestValidBackup(fsp, '/project', '/project/src/index.jacly'); + + expect(backup).to.equal(null); + }); +}); diff --git a/apps/web/tests/mocha/jacly-save-coordinator.test.ts b/apps/web/tests/mocha/jacly-save-coordinator.test.ts index 511be92e..59394b91 100644 --- a/apps/web/tests/mocha/jacly-save-coordinator.test.ts +++ b/apps/web/tests/mocha/jacly-save-coordinator.test.ts @@ -7,12 +7,18 @@ const expect = chai.expect; describe('jaclySaveCoordinator', () => { it('flushes all registered callbacks', async () => { const calls: string[] = []; - const unregisterOne = jaclySaveCoordinator.registerFlushCallback(async () => { - calls.push('one'); - }); - const unregisterTwo = jaclySaveCoordinator.registerFlushCallback(async () => { - calls.push('two'); - }); + const unregisterOne = jaclySaveCoordinator.registerFlushCallback( + async () => { + calls.push('one'); + }, + () => false, + ); + const unregisterTwo = jaclySaveCoordinator.registerFlushCallback( + async () => { + calls.push('two'); + }, + () => false, + ); await jaclySaveCoordinator.flushPendingWrites(); @@ -24,13 +30,31 @@ describe('jaclySaveCoordinator', () => { it('does not call callbacks after unregister', async () => { let callCount = 0; - const unregister = jaclySaveCoordinator.registerFlushCallback(async () => { - callCount += 1; - }); + const unregister = jaclySaveCoordinator.registerFlushCallback( + async () => { + callCount += 1; + }, + () => false, + ); unregister(); await jaclySaveCoordinator.flushPendingWrites(); expect(callCount).to.equal(0); }); + + it('reports pending writes from registered participants', () => { + let pending = false; + const unregister = jaclySaveCoordinator.registerFlushCallback( + async () => undefined, + () => pending, + ); + + expect(jaclySaveCoordinator.hasPendingWrites()).to.equal(false); + pending = true; + expect(jaclySaveCoordinator.hasPendingWrites()).to.equal(true); + + unregister(); + expect(jaclySaveCoordinator.hasPendingWrites()).to.equal(false); + }); }); diff --git a/apps/web/tests/mocha/project-fs-service.test.ts b/apps/web/tests/mocha/project-fs-service.test.ts new file mode 100644 index 00000000..28d9722f --- /dev/null +++ b/apps/web/tests/mocha/project-fs-service.test.ts @@ -0,0 +1,42 @@ +import * as chai from 'chai'; +import 'mocha'; +import { DeferredUnmountCoordinator } from '../../src/project/services/deferred-unmount'; + +const expect = chai.expect; + +describe('DeferredUnmountCoordinator', () => { + it('keeps the filesystem mounted until pending cleanup settles', async () => { + const unmounted: string[] = []; + let finishCleanup: (() => void) | undefined; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const coordinator = new DeferredUnmountCoordinator(); + + coordinator.schedule('project', cleanup, () => unmounted.push('project')); + await Promise.resolve(); + expect(unmounted).to.deep.equal([]); + + finishCleanup?.(); + await cleanup; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unmounted).to.deep.equal(['project']); + }); + + it('cancels an older deferred unmount when the project mounts again', async () => { + const unmounted: string[] = []; + let finishCleanup: (() => void) | undefined; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const coordinator = new DeferredUnmountCoordinator(); + + coordinator.schedule('project', cleanup, () => unmounted.push('project')); + coordinator.cancel('project'); + finishCleanup?.(); + await cleanup; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(unmounted).to.deep.equal([]); + }); +});