Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2f39daf
feat: UUID-based file storage for tree nodes
matt-pharr Apr 21, 2026
b4fca1f
chore: bump version to 0.0.12
matt-pharr Apr 21, 2026
edb4178
fix: preserve module entry_point and default_gui during project save
matt-pharr Apr 21, 2026
5b3fdeb
docs: document UUID-based file storage in ARCHITECTURE.md
matt-pharr Apr 21, 2026
a46b910
feat: implement add_file() method for importing files into the tree
matt-pharr Apr 21, 2026
d0f900a
fix: emit changes to renderer on intermediate paths when setting a lo…
matt-pharr Apr 21, 2026
3f90116
feat: implement project save/load functionality from python with IPC …
matt-pharr Apr 22, 2026
c3b5e06
fix: Attempt to fix CI python tests
matt-pharr Apr 22, 2026
3962351
feat: update CI to run Python tests across multiple versions with pre…
matt-pharr Apr 22, 2026
4f1815d
fix: fix CI tests and address claude review feedback
matt-pharr Apr 22, 2026
f2a9162
feat: enhance IPC handlers and improve UUID generation for script edi…
matt-pharr Apr 22, 2026
d2eacdd
fix: eliminate save_project deadlock and fix pdv.* autocomplete
matt-pharr Apr 23, 2026
6c0e0b1
feat: add unit tests for _early_module_setup() in project handlers
matt-pharr Apr 26, 2026
7edec19
feat: add treePath to script creation response and update related han…
matt-pharr Apr 26, 2026
04b3a5c
feat: add PR review checklist to CLAUDE.md for improved code review p…
matt-pharr Apr 26, 2026
d9c88be
refactor: remove working_dir_tree_path and update references to uuid_…
matt-pharr Apr 26, 2026
37f03c1
refactor: replace working directory scaffolding with UUID-based direc…
matt-pharr Apr 26, 2026
13527bc
refactor: remove PDVScriptRegisterPayload interface to streamline pay…
matt-pharr Apr 26, 2026
121247a
refactor: remove unused filename parameter from _relocate_files function
matt-pharr Apr 26, 2026
916da84
fix: address review nits — clear save cache on kernel stop, demote lo…
matt-pharr Apr 26, 2026
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
2 changes: 1 addition & 1 deletion .github/scripts/generate-release-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Generates release-notes.md for a published release.
#
# Required environment variables:
# TAG - the release tag (e.g. v0.0.11)
# TAG - the release tag (e.g. v0.0.12)
# REPO - "owner/repo"
# GH_TOKEN - GitHub token with read access
#
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,19 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
name: Python tests (${{ matrix.python-version }})
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: ${{ matrix.python-version }}
allow-prereleases: true

- name: Install Python dependencies
run: pip install ipykernel "pdv-python/[dev]"
Expand Down
94 changes: 62 additions & 32 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,21 @@ Every `.ts` file in `electron/main/` must have:
- A JSDoc file header describing purpose, responsibilities, and what the file does NOT do
- JSDoc on every exported function and class with `@param`, `@returns`, `@throws`
- No unguarded `any` types

---

## PR Review Checklist

When reviewing a pull request (including via `/review`), check every item below in addition to standard code-quality review:

- [ ] **IPC single source of truth** — All IPC channel names and types are defined in `ipc.ts`. No new strings introduced in preload, index.ts, or renderer code.
- [ ] **Process boundary respected** — Renderer imports types from `types/pdv.d.ts`, never from `../../main/ipc`. Renderer never accesses Node.js APIs or the filesystem directly.
- [ ] **No tree state caching in main** — The main process does not cache or duplicate tree data. The kernel's `PDVTree` remains the sole authority.
- [ ] **Script execution path** — No Python or Julia code strings in the renderer. Script execution goes through `window.pdv.script.run()`.
- [ ] **Theme CSS variables** — No hardcoded hex colors (`#fff`, `#007acc`) or `rgb(...)` literals. All colors use `var(--token)` from `themes.ts`. New tokens added to `themes.ts` with per-theme values if needed.
- [ ] **Preload bridge completeness** — Any new IPC channel exposed to the renderer has a corresponding method in `preload.ts` under `window.pdv`.
- [ ] **Comm protocol consistency** — New or changed comm messages between main and kernel follow the `pdv.*` protocol defined in `pdv-protocol.ts`.
- [ ] **Script signature** — New or modified PDV scripts define `run(pdv_tree: dict, **user_params) -> dict`.
- [ ] **Version parity** — If either `electron/package.json` or `pdv-python/pyproject.toml` version was bumped, both were bumped to the same value.
- [ ] **JSDoc coverage** — New or modified exports in `electron/main/` have JSDoc with `@param`, `@returns`, `@throws`. No unguarded `any` types introduced.
- [ ] **Documentation updated** — If the PR changes architecture, adds new IPC channels, modifies the comm protocol, introduces new tree node types, or alters any behavior described in `ARCHITECTURE.md` or `PLANNED_FEATURES.md`, those documents have been updated to match.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A desktop environment for computational and experimental physics analysis. PDV combines a tabbed code editor, an execution console, and a persistent, typed data hierarchy, the **Tree**, that lives inside a language kernel. The Tree is what separates PDV from a Jupyter notebook: it is a navigable, save/load-able data structure that persists across sessions, giving you structured data management and reproducible analysis workflows.

**Status**: Alpha (`v0.0.11`) — under active development.
**Status**: Alpha (`v0.0.12`) — under active development.

![PDV screenshot](docs/assets/screenshot.png)

Expand Down
2 changes: 1 addition & 1 deletion electron/main/auto-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function initAutoUpdater(win: BrowserWindow, configStore: ConfigStore): v
autoUpdater.autoDownload = false;
// Install on quit so the next launch uses the new version.
autoUpdater.autoInstallOnAppQuit = true;
// Include prerelease tags (e.g. v0.0.11-alpha1) since the app is still in alpha.
// Include prerelease tags (e.g. v0.0.12-alpha1) since the app is still in alpha.
autoUpdater.allowPrerelease = true;

// -- Event wiring ----------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions electron/main/comm-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export class CommRouter {
return new Promise<PDVMessage>((resolve, reject) => {
const createTimer = (): ReturnType<typeof setTimeout> =>
setTimeout(() => {
console.error(`[CommRouter] TIMEOUT type=${type} msg_id=${msgId} after ${timeoutMs}ms (pending: ${this.pending.size})`);
if (keepAliveHandler) this.offPush(keepAlivePushType!, keepAliveHandler);
this.pending.delete(msgId);
reject(new PDVCommTimeoutError(`PDV request timed out: ${type}`, type));
Expand Down
48 changes: 24 additions & 24 deletions electron/main/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ function setup() {
}),
createWorkingDir: vi.fn(async () => "/tmp/pdv-test"),
deleteWorkingDir: vi.fn(async () => undefined),
clearCachedKernelResults: vi.fn(),
} as unknown as ProjectManager;

const configState: PDVConfig = {
Expand Down Expand Up @@ -495,13 +496,16 @@ describe("Step 5 IPC handlers", () => {
});

it("script:edit spawns the configured external editor process", async () => {
const { configStore } = setup();
const { configStore, commRouter } = setup();
(configStore.getAll as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
showPrivateVariables: false,
showModuleVariables: false,
showCallableVariables: false,
editorCommand: "code",
});
(commRouter.request as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
{ payload: { path: "/tmp/script.py", file_path: "/tmp/script.py" } }
);

const edit = getHandler(IPC.script.edit);
await edit({}, "kernel-1", "/tmp/script.py");
Expand All @@ -518,13 +522,16 @@ describe("Step 5 IPC handlers", () => {

if (process.platform === "darwin") {
it("script:edit launches terminal editors through Terminal.app on macOS", async () => {
const { configStore } = setup();
const { configStore, commRouter } = setup();
(configStore.getAll as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
showPrivateVariables: false,
showModuleVariables: false,
showCallableVariables: false,
pythonEditorCmd: "nvim {}",
});
(commRouter.request as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
{ payload: { path: "/tmp/script.py", file_path: "/tmp/script.py" } }
);

const edit = getHandler(IPC.script.edit);
await edit({}, "kernel-1", "/tmp/script.py");
Expand Down Expand Up @@ -609,7 +616,8 @@ describe("Step 5 IPC handlers", () => {
const { commRouter, webContentsSend } = setup();
registerCommPushForwarding(
{ webContents: { send: webContentsSend } } as unknown as BrowserWindow,
commRouter
commRouter,
{ cacheKernelSaveResults: vi.fn() } as unknown as ProjectManager,
);

const onPushCalls = (commRouter.onPush as unknown as ReturnType<typeof vi.fn>)
Expand Down Expand Up @@ -841,7 +849,8 @@ describe("Step 5 IPC handlers", () => {
expect.objectContaining({
parent_path: "scripts",
name: "analysis",
relative_path: expect.stringMatching(/analysis\.py$/),
uuid: expect.stringMatching(/^[0-9a-f]{12}$/),
filename: "analysis.py",
language: "python",
})
);
Expand Down Expand Up @@ -877,7 +886,8 @@ describe("Step 5 IPC handlers", () => {
name: "hello",
module_id: "toy",
source_rel_path: "scripts/hello.py",
relative_path: expect.stringMatching(/toy\/scripts\/hello\.py$/),
uuid: expect.stringMatching(/^[0-9a-f]{12}$/),
filename: "hello.py",
}),
);
});
Expand All @@ -903,7 +913,7 @@ describe("Step 5 IPC handlers", () => {
};

expect(result.success).toBe(true);
expect(result.libPath).toMatch(/toy\/lib\/helpers\.py$/);
expect(result.libPath).toMatch(/helpers\.py$/);
expect(result.treePath).toBe("toy.lib.helpers");

const fileRegisterCalls = (commRouter.request as unknown as ReturnType<typeof vi.fn>).mock.calls
Expand All @@ -914,6 +924,7 @@ describe("Step 5 IPC handlers", () => {
expect.objectContaining({
tree_path: "toy.lib",
filename: "helpers.py",
uuid: expect.stringMatching(/^[0-9a-f]{12}$/),
node_type: "lib",
module_id: "toy",
source_rel_path: "lib/helpers.py",
Expand Down Expand Up @@ -952,7 +963,8 @@ describe("Step 5 IPC handlers", () => {
expect.objectContaining({
parent_path: "notes",
name: "derivation",
relative_path: expect.stringMatching(/derivation\.md$/),
uuid: expect.stringMatching(/^[0-9a-f]{12}$/),
filename: "derivation.md",
})
);
expect(result.success).toBe(true);
Expand Down Expand Up @@ -1331,12 +1343,11 @@ describe("Step 5 IPC handlers", () => {
module_index: expect.arrayContaining([
expect.objectContaining({
id: "scripts.run",
// Option A: module-owned files live under
// <workdir>/tree/<alias>/<src_rel_path> so the stored
// relative_path gains the canonical ``tree/`` prefix.
uuid: expect.stringMatching(/^[0-9a-f]{12}$/),
storage: expect.objectContaining({
backend: "local_file",
relative_path: path.join("tree", "demo-module", "scripts/run.py"),
uuid: expect.stringMatching(/^[0-9a-f]{12}$/),
filename: "run.py",
}),
}),
]),
Expand Down Expand Up @@ -1375,19 +1386,8 @@ describe("Step 5 IPC handlers", () => {

expect(result.success).toBe(true);
expect(result.alias).toBe("toy");
// Working-dir scaffolding created for scripts/lib/plots.
expect(mocks.fsMkdir).toHaveBeenCalledWith(
expect.stringMatching(/toy\/scripts$/),
{ recursive: true },
);
expect(mocks.fsMkdir).toHaveBeenCalledWith(
expect.stringMatching(/toy\/lib$/),
{ recursive: true },
);
expect(mocks.fsMkdir).toHaveBeenCalledWith(
expect.stringMatching(/toy\/plots$/),
{ recursive: true },
);
// No alias-based scaffolding — UUID dirs are created when individual
// nodes (scripts, libs, etc.) are added via tree:create* handlers.
expect(commRouter.request).toHaveBeenCalledWith(
PDVMessageType.MODULE_CREATE_EMPTY,
expect.objectContaining({
Expand Down
75 changes: 47 additions & 28 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { ModuleManager } from "./module-manager";
import {
bindProjectModulesToTree,
} from "./module-runtime";
import { ProjectManager, type ProjectModuleImport } from "./project-manager";
import { ProjectManager, type ProjectModuleImport, type ModuleOwnedFile, type ModuleManifestBundle } from "./project-manager";
import { ConfigStore } from "./config";
import { registerAppStateIpcHandlers } from "./ipc-register-app-state";
import { registerGuiEditorIpcHandlers } from "./ipc-register-gui-editor";
Expand Down Expand Up @@ -281,31 +281,6 @@ function sanitizeScriptName(scriptName: string, language: "python" | "julia" = "
return withExt.replace(/[\\/]/g, "_");
}

function resolveScriptPath(
kernelId: string,
scriptPath: string,
kernelWorkingDirs: Map<string, string>,
language: "python" | "julia" = "python"
): string {
if (path.isAbsolute(scriptPath)) {
return scriptPath;
}
const workingDir = kernelWorkingDirs.get(kernelId);
if (!workingDir) {
throw new Error(`Kernel working directory not initialized: ${kernelId}`);
}
if (scriptPath.includes("/") || scriptPath.includes("\\")) {
return path.join(workingDir, scriptPath);
}
const parts = scriptPath.split(".").filter(Boolean);
if (parts.length === 0) {
throw new Error("Invalid script path");
}
const ext = language === "julia" ? ".jl" : ".py";
const leaf = parts[parts.length - 1];
return path.join(workingDir, ...parts.slice(0, -1), `${leaf}${ext}`);
}

/**
* Write a script stub if the file does not already exist.
*
Expand Down Expand Up @@ -580,7 +555,6 @@ export function registerIpcHandlers(
sanitizeScriptName,
ensureScriptFile,
ensureLibFile,
resolveScriptPath,
buildEditorSpawn,
resolveEditorSpawn,
});
Expand Down Expand Up @@ -655,7 +629,7 @@ export function registerIpcHandlers(

registerEnvironmentIpcHandlers(win, configStore);

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

/**
* Reset all in-session state. Called whenever the renderer reloads so that
Expand Down Expand Up @@ -733,6 +707,7 @@ function registerEnvironmentIpcHandlers(win: BrowserWindow, configStore: ConfigS
export function registerCommPushForwarding(
win: BrowserWindow,
commRouter: CommRouter,
projectManager: ProjectManager,
moduleWindowManager?: ModuleWindowManager,
guiEditorWindowManager?: GuiEditorWindowManager,
guiViewerWindowManager?: GuiViewerWindowManager
Expand All @@ -753,6 +728,50 @@ export function registerCommPushForwarding(
subscribe(PDVMessageType.TREE_CHANGED, IPC.push.treeChanged, true);
subscribe(PDVMessageType.PROJECT_LOADED, IPC.push.projectLoaded);
subscribe(PDVMessageType.PROGRESS, IPC.push.progress);

// Kernel-initiated project operations — forward as menu actions so the
// renderer drives the full save/load workflow (including code-cell
// serialization and UI state updates).
const forwardAsMenuAction = (
type: string,
action: "project:save" | "project:saveAs" | "project:openRecent",
): void => {
const handler = (msg: PDVMessage): void => {
const payload = msg.payload as { save_dir?: string };
console.log(`[forwardAsMenuAction] received push ${type} → forwarding as ${action} (path=${payload.save_dir ?? "none"})`);
win.webContents.send(IPC.push.menuAction, { action, path: payload.save_dir });
};
commRouter.onPush(type, handler);
pushSubscriptions.push({ commRouter, type, handler });
};
forwardAsMenuAction(PDVMessageType.PROJECT_SAVE_REQUEST, "project:save");
forwardAsMenuAction(PDVMessageType.PROJECT_SAVE_AS_REQUEST, "project:saveAs");
forwardAsMenuAction(PDVMessageType.PROJECT_OPEN_REQUEST, "project:openRecent");

// Kernel-initiated save (pdv.save_project()) — tree is already serialized.
// Cache the results so ProjectManager.save() skips the comm round-trip
// (which would deadlock while the kernel shell is still executing user code).
{
const handler = (msg: PDVMessage): void => {
const payload = msg.payload as Record<string, unknown>;
const saveDir = payload.save_dir as string | undefined;
if (!saveDir) return;
console.log(`[save_completed] kernel serialized tree to ${saveDir}, caching results`);
projectManager.cacheKernelSaveResults(saveDir, {
checksum: (payload.checksum as string) ?? "",
nodeCount: (payload.node_count as number) ?? 0,
moduleOwnedFiles: Array.isArray(payload.module_owned_files)
? (payload.module_owned_files as unknown as ModuleOwnedFile[])
: [],
moduleManifests: Array.isArray(payload.module_manifests)
? (payload.module_manifests as unknown as ModuleManifestBundle[])
: [],
});
win.webContents.send(IPC.push.menuAction, { action: "project:save", path: saveDir });
};
commRouter.onPush(PDVMessageType.PROJECT_SAVE_COMPLETED, handler);
pushSubscriptions.push({ commRouter, type: PDVMessageType.PROJECT_SAVE_COMPLETED, handler });
}
}

/**
Expand Down
Loading
Loading