Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions electron/main/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe("ConfigStore", () => {
showModuleVariables: false,
showCallableVariables: false,
autoRefreshNamespace: false,
autoSaveIntervalSeconds: 300,
settings: {
appearance: {
themeName: "Dark+ (VSCode)",
Expand Down Expand Up @@ -91,6 +92,7 @@ describe("ConfigStore", () => {
showModuleVariables: true,
showCallableVariables: false,
autoRefreshNamespace: false,
autoSaveIntervalSeconds: 300,
theme: "dark",
settings: {
appearance: {
Expand All @@ -112,6 +114,7 @@ describe("ConfigStore", () => {
showModuleVariables: false,
showCallableVariables: false,
autoRefreshNamespace: false,
autoSaveIntervalSeconds: 300,
settings: {
appearance: {
themeName: "Dark+ (VSCode)",
Expand Down
9 changes: 9 additions & 0 deletions electron/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface PDVConfig {
defaultSaveLocation?: string;
/** Base directory for session working directories. Defaults to `~/.PDV/working/`. */
workingDirBase?: string;
/** Autosave interval in seconds. Default 300 (5 minutes). Minimum 30. */
autoSaveIntervalSeconds?: number;
/** Renderer settings blob persisted by Settings dialog. */
settings?: {
shortcuts?: Record<string, string>;
Expand Down Expand Up @@ -97,6 +99,7 @@ const CONFIG_DEFAULTS: PDVConfig = {
showModuleVariables: false,
showCallableVariables: false,
autoRefreshNamespace: false,
autoSaveIntervalSeconds: 300,
settings: {
appearance: {
themeName: "Dark+ (VSCode)",
Expand Down Expand Up @@ -205,6 +208,12 @@ function parseConfig(raw: string, filePath: string): Partial<PDVConfig> {
if (typeof val === "string") result[key] = val;
}
}
if ("autoSaveIntervalSeconds" in obj) {
const val = obj.autoSaveIntervalSeconds;
if (val !== null && val !== undefined && typeof val === "number" && val >= 30) {
result.autoSaveIntervalSeconds = val;
}
}
if ("projectRoot" in obj) {
const projectRoot = obj.projectRoot;
if (projectRoot !== null && projectRoot !== undefined && typeof projectRoot !== "string") {
Expand Down
11 changes: 10 additions & 1 deletion electron/main/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ vi.mock("fs/promises", () => ({
readFile: mocks.fsReadFile,
copyFile: mocks.fsCopyFile,
cp: mocks.fsCp,
rm: vi.fn().mockResolvedValue(undefined),
readdir: vi.fn().mockResolvedValue([]),
}));

vi.mock("./module-manager", () => ({
Expand Down Expand Up @@ -310,6 +312,13 @@ function setup() {
createWorkingDir: vi.fn(async () => "/tmp/pdv-test"),
deleteWorkingDir: vi.fn(async () => undefined),
clearCachedKernelResults: vi.fn(),
startAutosaveTimer: vi.fn(),
stopAutosaveTimer: vi.fn(),
resetAutosaveTimer: vi.fn(),
markAutosaveCacheDirty: vi.fn(),
setAutosavePending: vi.fn(),
consumeAutosavePending: vi.fn(() => false),
autosave: vi.fn(async () => null),
} as unknown as ProjectManager;

const configState: PDVConfig = {
Expand Down Expand Up @@ -1146,7 +1155,7 @@ describe("Step 5 IPC handlers", () => {
});
const load = getHandler(IPC.project.load);
const result = await load({}, "/tmp/project");
expect(projectManager.load).toHaveBeenCalledWith("/tmp/project");
expect(projectManager.load).toHaveBeenCalledWith("/tmp/project", undefined);
expect(result).toEqual({
codeCells: [{ id: "box1" }],
checksum: null,
Expand Down
71 changes: 70 additions & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
ModuleHealthWarning,
NamespaceQueryOptions,
PDVConfig,
type CodeCellData,
} from "./ipc";
import { PDVMessage, PDVMessageType, setAppVersion } from "./pdv-protocol";

Expand Down Expand Up @@ -150,6 +151,10 @@ const REGISTERED_CHANNELS: readonly string[] = [
IPC.environment.check,
IPC.environment.install,
IPC.environment.refresh,
IPC.autosave.run,
IPC.autosave.clear,
IPC.autosave.check,
IPC.autosave.scanWorkingDirs,
];

interface PushSubscription {
Expand Down Expand Up @@ -526,7 +531,16 @@ export function registerIpcHandlers(
guiEditorWindowManager.closeAll();
guiViewerWindowManager.closeAll();
},
setActiveKernelId: (id) => { activeKernelId = id; },
setActiveKernelId: (id) => {
activeKernelId = id;
if (id) {
const config = readConfig(configStore);
const intervalMs = (config.autoSaveIntervalSeconds ?? 300) * 1000;
projectManager.startAutosaveTimer(intervalMs, triggerAutosave);
} else {
projectManager.stopAutosaveTimer();
}
},
getActiveKernelId: () => activeKernelId,
getActiveProjectDir: () => activeProjectDir,
getWorkingDirBase: () => readConfig(configStore).workingDirBase,
Expand Down Expand Up @@ -609,6 +623,10 @@ export function registerIpcHandlers(
: "python";
return lang === "julia" ? config.juliaPath : config.pythonPath;
},
onExplicitSaveCompleted: (saveDir) => {
void ProjectManager.clearAutosave(saveDir);
projectManager.resetAutosaveTimer();
},
});

registerAppStateIpcHandlers({
Expand All @@ -618,6 +636,12 @@ export function registerIpcHandlers(
themesDir,
stateDir,
setAllowClose,
onConfigChanged: (prev, next) => {
if (prev.autoSaveIntervalSeconds !== next.autoSaveIntervalSeconds && activeKernelId) {
const intervalMs = (next.autoSaveIntervalSeconds ?? 300) * 1000;
projectManager.startAutosaveTimer(intervalMs, triggerAutosave);
}
},
});

registerModuleWindowIpcHandlers({
Expand All @@ -633,6 +657,51 @@ export function registerIpcHandlers(

registerEnvironmentIpcHandlers(win, configStore);

// ---- Autosave IPC handlers and lifecycle wiring --------------------------

function triggerAutosave(): void {
if (!activeKernelId) return;
const state = kernelManager.getExecutionState(activeKernelId);
if (state !== "idle") {
projectManager.setAutosavePending();
return;
}
win.webContents.send(IPC.push.autosaveTrigger);
}

ipcMain.handle(IPC.autosave.run, async (_event, codeCells: unknown) => {
const baseDir = activeProjectDir || kernelWorkingDirs.get(activeKernelId ?? "");
if (!baseDir) return;
const autosaveDir = path.join(baseDir, ".autosave");
await projectManager.autosave(autosaveDir, codeCells as CodeCellData);
});

ipcMain.handle(IPC.autosave.clear, async (_event, dir?: string) => {
const target = dir || activeProjectDir || kernelWorkingDirs.get(activeKernelId ?? "");
if (target) {
await ProjectManager.clearAutosave(target);
projectManager.markAutosaveCacheDirty();
}
});

ipcMain.handle(IPC.autosave.check, async (_event, dir: string) => {
return ProjectManager.checkForAutosave(dir);
});

ipcMain.handle(IPC.autosave.scanWorkingDirs, async () => {
const config = readConfig(configStore);
const base = config.workingDirBase || path.join(os.homedir(), ".PDV", "working");
return ProjectManager.scanForAutosaves(base);
});

// When kernel goes idle and an autosave was deferred, trigger it now.
kernelManager.on("kernel:executionState", (kernelId: string, state: string) => {
if (kernelId !== activeKernelId) return;
if (state === "idle" && projectManager.consumeAutosavePending()) {
triggerAutosave();
}
});

registerCommPushForwarding(win, commRouter, projectManager, moduleWindowManager, guiEditorWindowManager, guiViewerWindowManager);

/**
Expand Down
12 changes: 8 additions & 4 deletions electron/main/ipc-register-app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ interface RegisterAppStateIpcHandlersOptions {
stateDir: string;
/** Flips the close-guard flag in `app.ts` so the next `win.close()` proceeds. */
setAllowClose: (allow: boolean) => void;
/** Called after config:set with the old and new config values. */
onConfigChanged?: (prev: PDVConfig, next: PDVConfig) => void;
}

function getWindowChromePlatform(): WindowChromePlatform {
Expand Down Expand Up @@ -102,7 +104,7 @@ function loadThemesFromDisk(themesDir: string): void {
export function registerAppStateIpcHandlers(
options: RegisterAppStateIpcHandlersOptions
): void {
const { win, configStore, readConfig, themesDir, stateDir, setAllowClose } = options;
const { win, configStore, readConfig, themesDir, stateDir, setAllowClose, onConfigChanged } = options;

fs.mkdir(themesDir, { recursive: true }).catch((error) => {
console.warn(
Expand Down Expand Up @@ -142,15 +144,17 @@ export function registerAppStateIpcHandlers(
ipcMain.handle(IPC.updater.getStatus, async () => getUpdateStatus());

ipcMain.handle(IPC.config.set, async (_event, updates: Partial<PDVConfig>) => {
const current = readConfig(configStore);
const merged: PDVConfig = { ...current, ...updates };
const prev = readConfig(configStore);
const merged: PDVConfig = { ...prev, ...updates };
for (const key of Object.keys(updates) as Array<keyof PDVConfig>) {
const value = updates[key];
if (value !== undefined) {
configStore.set(key, value);
}
}
return { ...merged, ...configStore.getAll() };
const next = { ...merged, ...configStore.getAll() };
onConfigChanged?.(prev, next);
return next;
});

ipcMain.handle(IPC.themes.get, async () => savedThemes);
Expand Down
25 changes: 21 additions & 4 deletions electron/main/ipc-register-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ interface RegisterProjectIpcHandlersOptions {
runSerializedProjectManifestMutation: <T>(dir: string, task: () => Promise<T>) => Promise<T>;
getMainWindow: () => BrowserWindow | null;
getInterpreterPath: () => string | undefined;
/** Called after a successful explicit save to clean up autosave state. */
onExplicitSaveCompleted?: (saveDir: string) => void;
}

/**
Expand Down Expand Up @@ -238,6 +240,7 @@ export function registerProjectIpcHandlers(
runSerializedProjectManifestMutation,
getMainWindow,
getInterpreterPath,
onExplicitSaveCompleted,
} = options;

// Serialize concurrent saves so a rapid second call waits for the first to
Expand Down Expand Up @@ -312,6 +315,7 @@ export function registerProjectIpcHandlers(

setActiveProjectDir(saveDir);
await refreshProjectModuleHealth(saveDir);
onExplicitSaveCompleted?.(saveDir);

let savedProjectName: string | undefined;
try {
Expand All @@ -338,22 +342,32 @@ export function registerProjectIpcHandlers(
}
);

ipcMain.handle(IPC.project.load, async (_event, saveDir: string) => {
ipcMain.handle(IPC.project.load, async (_event, saveDir: string, options?: { restoreFromAutosave?: boolean }) => {
const restoreFromAutosave = options?.restoreFromAutosave ?? false;
const autosaveDir = path.join(saveDir, ".autosave");

// Copy file-backed node files from save dir into working dir before kernel load.
let loadFailedPaths: string[] = [];
const activeKernelId = getActiveKernelId();
if (activeKernelId) {
const workingDir = kernelWorkingDirs.get(activeKernelId);
if (workingDir) {
const win = getMainWindow();
loadFailedPaths = await copyFilesForLoad(saveDir, workingDir, win ? (current, total) => {
const onProgress = win ? (current: number, total: number) => {
win.webContents.send(IPC.push.progress, {
operation: "load",
phase: "Copying files",
current,
total,
});
} : undefined);
} : undefined;
// Baseline: copy from the main save dir
loadFailedPaths = await copyFilesForLoad(saveDir, workingDir, onProgress);
// Overlay: copy autosaved files on top (changed nodes take precedence)
if (restoreFromAutosave) {
const autosaveFailedPaths = await copyFilesForLoad(autosaveDir, workingDir, onProgress);
loadFailedPaths.push(...autosaveFailedPaths);
}
if (loadFailedPaths.length > 0) {
console.warn(
`[pdv] load: ${loadFailedPaths.length} file(s) could not be copied from save directory:`,
Expand Down Expand Up @@ -382,7 +396,10 @@ export function registerProjectIpcHandlers(
// Non-blocking — proceed with load even if manifest read fails
}

const { codeCells, postLoadChecksum } = await projectManager.load(saveDir);
const loadOptions = restoreFromAutosave
? { treeIndexDir: autosaveDir, codeCellsDir: autosaveDir }
: undefined;
const { codeCells, postLoadChecksum } = await projectManager.load(saveDir, loadOptions);

// Mirror the project's code-cells.json into the active kernel's working
// directory so the per-session autosave file is in sync with the loaded
Expand Down
47 changes: 46 additions & 1 deletion electron/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ export const IPC = {
get: "config:get",
set: "config:set",
},
/** Autosave management channels. */
autosave: {
run: "autosave:run",
clear: "autosave:clear",
check: "autosave:check",
scanWorkingDirs: "autosave:scanWorkingDirs",
},
/** App info channels. */
about: {
getVersion: "about:getVersion",
Expand Down Expand Up @@ -210,6 +217,7 @@ export const IPC = {
installOutput: "pdv.environment.installOutput",
updateStatus: "pdv.updater.status",
requestClose: "pdv.app.requestClose",
autosaveTrigger: "pdv.autosave.trigger",
},
/** App-level lifecycle channels (close confirmation, etc.). */
app: {
Expand Down Expand Up @@ -1846,7 +1854,7 @@ export interface PDVApi {
* @param saveDir - Source save directory.
* @returns Loaded code-cell state with checksum metadata.
*/
load(saveDir: string): Promise<ProjectLoadResult>;
load(saveDir: string, options?: { restoreFromAutosave?: boolean }): Promise<ProjectLoadResult>;
/**
* Start a new empty project session.
*
Expand Down Expand Up @@ -1958,6 +1966,43 @@ export interface PDVApi {
set(updates: Partial<PDVConfig>): Promise<PDVConfig>;
};

/** Autosave management. */
autosave: {
/**
* Execute an autosave with the given code-cell state.
* Called by the renderer in response to an autosave trigger push.
*
* @param codeCells - Current code-cell state.
*/
run(codeCells: unknown): Promise<void>;
/**
* Delete the .autosave/ directory for the given project directory.
*
* @param dir - Project save directory (or working dir for unsaved projects).
*/
clear(dir?: string): Promise<void>;
/**
* Check if autosave data exists for a given directory.
*
* @param dir - Project save directory to check.
* @returns Whether autosave data exists and its timestamp.
*/
check(dir: string): Promise<{ exists: boolean; timestamp?: string }>;
/**
* Scan working directories for orphaned autosave data (unsaved projects).
*
* @returns List of working dirs containing .autosave/ data.
*/
scanWorkingDirs(): Promise<{ dir: string; timestamp: string }[]>;
/**
* Subscribe to autosave trigger push notifications from the main process.
*
* @param callback - Invoked when the main process requests an autosave.
* @returns Unsubscribe function.
*/
onTrigger(callback: () => void): () => void;
};

/** App info accessors. */
about: {
/**
Expand Down
Loading
Loading