Files
OpenNoodl/dev-docs/future-projects/MULTI-PROJECT.md

11 KiB

Multi-Project Support Scoping Document

Executive Summary

This document scopes the feature request to enable OpenNoodl to have multiple projects open simultaneously. Two approaches are analyzed: multi-project within a single Electron app, and multiple Electron app instances.

Recommendation: Start with Option B (Multiple Electron Instances) as Phase 1 due to significantly lower complexity and risk. Consider Option A as a future enhancement if user demand warrants the investment.


Current Architecture Analysis

Key Findings

The codebase has several architectural patterns that make multi-project support challenging:

1. Singleton Pattern Throughout

// ProjectModel is a strict singleton
public static get instance() {
  return ProjectModel._instance;
}
public static set instance(project: ProjectModel | undefined) {
  // Only one project at a time...
}

This pattern is referenced extensively across the codebase:

  • ProjectModel.instance - Core project data
  • NodeLibrary.instance - Node definitions (registers/unregisters per project)
  • CloudService.instance - Cloud backend per project
  • ViewerConnection.instance - Single preview connection
  • SidebarModel.instance, UndoQueue.instance, etc.

2. Router Enforces Single Project

The router explicitly disposes the old project when switching:

route(args: AppRouteOptions) {
  if (ProjectModel.instance && ProjectModel.instance !== args.project) {
    ProjectModel.instance.dispose();
    // ...
    ProjectModel.instance = undefined;
  }
}

3. IPC Assumes Single Project

Main process IPC events like project-opened and project-closed assume one active project:

ipcMain.on('project-opened', (e, newProjectName) => {
  projectName = newProjectName;  // Single name tracked
  // ...
});

4. Viewer Window is Tightly Coupled

The viewer window is a child of the main window with direct IPC communication assuming a single project context.


Option A: Multi-Project Within Single Electron App

Description

Transform the architecture to support multiple projects open as tabs or panels within a single application window.

Required Changes

Phase A1: Core Architecture Refactoring

Component Current State Required Change Complexity
ProjectModel Singleton Registry with active project tracking 🔴 High
NodeLibrary Singleton with project registration Per-project library instances 🔴 High
EventDispatcher Global events Project-scoped events 🟡 Medium
UndoQueue Singleton Per-project undo stacks 🟡 Medium
Router Single route Multi-route or tab system 🔴 High
ViewerConnection Single connection Connection pool by project 🟡 Medium

Phase A2: UI/UX Development

  • Tab bar or project switcher component
  • Visual indicators for active project
  • Window management (detach projects to separate windows)
  • Cross-project drag & drop considerations

Phase A3: Resource Management

  • Memory management for multiple loaded projects
  • Preview server port allocation per project
  • Cloud service connection pooling
  • File watcher consolidation

Effort Estimate

Phase Estimated Time Risk Level
A1: Core Architecture 8-12 weeks 🔴 High
A2: UI/UX 3-4 weeks 🟡 Medium
A3: Resource Management 2-3 weeks 🟡 Medium
Testing & Stabilization 3-4 weeks 🔴 High
Total 16-23 weeks High

Risks

  1. Regression Risk: Touching ProjectModel singleton affects nearly every feature
  2. Memory Pressure: Multiple full projects in RAM
  3. State Isolation: Ensuring complete isolation between projects
  4. Performance: Managing multiple preview servers
  5. Complexity Explosion: Every new feature must consider multi-project context

Benefits

  • Single dock icon / application instance
  • Potential for cross-project features (copy/paste between projects)
  • Professional multi-document interface
  • Shared resources (single node library load)

Option B: Multiple Electron App Instances

Description

Allow multiple independent Electron app instances, each with its own project. Minimal code changes required.

Required Changes

Phase B1: Enable Multi-Instance

// Current: Single instance lock (likely present)
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
  app.quit();
  return;
}

// Change to: Allow multiple instances
// Simply remove or conditionally bypass the single-instance lock

Phase B2: Instance Isolation

Component Change Required Complexity
Single-instance lock Remove or make conditional 🟢 Low
Preview server ports Dynamic port allocation 🟢 Low
UDP broadcast Include instance ID 🟢 Low
Window bounds storage Per-project storage key 🟢 Low
Design tool import server Instance-aware 🟡 Medium

Phase B3: UX Polish (Optional)

  • Menu item: "Open Project in New Window"
  • Keyboard shortcut support
  • Recent projects list per instance awareness

Implementation Details

Port Allocation:

// Instead of fixed port:
// const port = Config.PreviewServer.port;

// Use dynamic allocation:
const server = net.createServer();
server.listen(0);  // OS assigns available port
const port = server.address().port;

Window Bounds:

// Key by project directory or ID
const boundsKey = `windowBounds_${projectId}`;
jsonstorage.get(boundsKey, (bounds) => { /* ... */ });

Effort Estimate

Phase Estimated Time Risk Level
B1: Multi-Instance 1-2 days 🟢 Low
B2: Instance Isolation 3-5 days 🟢 Low
B3: UX Polish 3-5 days 🟢 Low
Testing 2-3 days 🟢 Low
Total 2-3 weeks Low

Risks

  1. Multiple dock icons: May confuse some users
  2. Memory duplication: Each instance loads full editor
  3. No cross-project features: Can't drag nodes between projects
  4. OS Integration: May complicate app bundling/signing

Benefits

  • Minimal code changes
  • Complete isolation (no state bleed)
  • Each project has dedicated resources
  • Can close one project without affecting others
  • Already supported pattern in many apps (VS Code, terminal apps)

Comparison Matrix

Criteria Option A (Single App) Option B (Multi-Instance)
Development Time 16-23 weeks 2-3 weeks
Risk Level 🔴 High 🟢 Low
Code Changes Extensive refactoring Minimal, isolated changes
Memory Usage Shared (more efficient) Duplicated (less efficient)
UX Polish Professional tabbed interface Multiple windows/dock icons
Cross-Project Features Possible Not possible
Isolation Requires careful engineering Automatic
Maintenance Burden Higher (ongoing complexity) Lower

Recommendation

Phase 1: Multiple Electron Instances (Option B)

Timeline: 2-3 weeks

Start here because:

  • Low risk, high value
  • Validates user need before major investment
  • Can ship quickly and gather feedback
  • Doesn't block future Option A work

Phase 2 (Future): Evaluate Single-App Approach

Timeline: After 6+ months of Phase 1 feedback

Consider Option A if:

  • Users strongly request tabbed interface
  • Cross-project features become a priority
  • Memory usage becomes a significant concern
  • User feedback indicates multiple windows is problematic

Implementation Plan for Option B

Week 1: Core Multi-Instance Support

Day 1-2: Single Instance Lock

  • Locate and understand current single-instance handling
  • Add configuration flag allowMultipleInstances
  • Test launching multiple instances manually

Day 3-4: Port Allocation

  • Modify preview server to use dynamic ports
  • Update ViewerConnection to handle dynamic ports
  • Test multiple instances with different projects

Day 5: Basic Testing

  • Test simultaneous editing of different projects
  • Verify no state leakage between instances
  • Check cloud service isolation

Week 2: Polish & Edge Cases

Day 1-2: Storage Isolation

  • Key window bounds by project ID/path
  • Handle recent projects list updates
  • UDP broadcast instance differentiation

Day 3-4: UX Improvements

  • Add "Open in New Window" to project context menu
  • Keyboard shortcut for opening new instance
  • Window title includes project name prominently

Day 5: Documentation & Testing

  • Update user documentation
  • Edge case testing (same project in two instances)
  • Memory and performance profiling

Week 3: Buffer & Release

  • Bug fixes from testing
  • Final QA pass
  • Release notes preparation
  • User feedback collection setup

Files to Modify (Option B)

Critical Path

  1. packages/noodl-editor/src/main/main.js - Single instance lock, port config
  2. packages/noodl-editor/src/main/src/preview-server.js (or equivalent) - Dynamic ports

Supporting Changes

  1. packages/noodl-editor/src/main/src/StorageApi.js - Keyed storage
  2. packages/noodl-editor/src/editor/src/pages/ProjectsPage/ - "Open in New Window" option
  3. UDP multicast function in main.js - Instance awareness

Open Questions

  1. Same project in multiple instances?

    • Recommend: Block with friendly message, or warn about conflicts
  2. Instance limit?

    • Recommend: No hard limit initially, monitor memory usage
  3. macOS app icon behavior?

    • Each instance shows in dock; standard behavior for multi-window apps
  4. File locking?

    • Noodl already handles project.json locking - verify behavior with multiple instances

Appendix: Code Snippets

Current Single-Instance Pattern (Likely)

// main.js - probable current implementation
const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
  app.quit();
} else {
  app.on('second-instance', (event, commandLine, workingDirectory) => {
    // Focus existing window when second instance attempted
    if (win) {
      if (win.isMinimized()) win.restore();
      win.focus();
    }
  });
}

Proposed Multi-Instance Support

// main.js - proposed modification
const allowMultipleInstances = true; // Could be a setting

if (!allowMultipleInstances) {
  const gotTheLock = app.requestSingleInstanceLock();
  if (!gotTheLock) {
    app.quit();
    return;
  }
  app.on('second-instance', (event, commandLine, workingDirectory) => {
    if (win) {
      if (win.isMinimized()) win.restore();
      win.focus();
    }
  });
}

// Rest of initialization continues for each instance...

Dynamic Port Allocation

const net = require('net');

function findAvailablePort(startPort = 8574) {
  return new Promise((resolve, reject) => {
    const server = net.createServer();
    server.listen(startPort, () => {
      const port = server.address().port;
      server.close(() => resolve(port));
    });
    server.on('error', () => {
      // Port in use, try next
      resolve(findAvailablePort(startPort + 1));
    });
  });
}