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([]);
+ });
+});