Merge origin/cline-dev - kept local version of LEARNINGS.md

This commit is contained in:
Tara West
2026-01-12 13:44:53 +01:00
130 changed files with 25758 additions and 355 deletions

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,36 +0,0 @@
const fs = require('fs');
const path = require('path');
module.exports = async function (params) {
if (process.platform !== 'darwin') {
return;
}
if (!process.env.appleIdPassword) {
console.log('apple password not set, skipping notarization');
return;
}
const appId = 'com.opennoodl.app';
const appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`);
if (!fs.existsSync(appPath)) {
throw new Error(`Cannot find application at: ${appPath}`);
}
console.log(`Notarizing ${appId} found at ${appPath}`);
try {
const electron_notarize = require('electron-notarize');
await electron_notarize.notarize({
appBundleId: appId,
appPath: appPath,
appleId: process.env.appleId,
appleIdPassword: process.env.appleIdPassword
});
} catch (error) {
console.error(error);
}
console.log(`Done notarizing ${appId}`);
};

View File

@@ -60,6 +60,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@babel/parser": "^7.28.5",
"@blockly/theme-dark": "^8.0.3",
"@electron/remote": "^2.1.3",
"@jaames/iro": "^5.5.2",
"@microlink/react-json-view": "^1.27.0",
@@ -68,10 +69,13 @@
"@noodl/noodl-parse-dashboard": "file:../noodl-parse-dashboard",
"@noodl/platform": "file:../noodl-platform",
"@noodl/platform-electron": "file:../noodl-platform-electron",
"@octokit/auth-oauth-device": "^7.1.5",
"@octokit/rest": "^20.1.2",
"about-window": "^1.15.2",
"algoliasearch": "^5.35.0",
"archiver": "^5.3.2",
"async": "^3.2.6",
"blockly": "^12.3.1",
"classnames": "^2.5.1",
"dagre": "^0.8.5",
"diff3": "0.0.4",

View File

@@ -0,0 +1,213 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
/**
* Tab types supported by the canvas tab system
*/
export type TabType = 'logic-builder';
/**
* Tab data structure
*/
export interface Tab {
/** Unique tab identifier */
id: string;
/** Type of tab */
type: TabType;
/** Node ID (for logic-builder tabs) */
nodeId?: string;
/** Node name for display (for logic-builder tabs) */
nodeName?: string;
/** Blockly workspace JSON (for logic-builder tabs) */
workspace?: string;
}
/**
* Context value shape
*/
export interface CanvasTabsContextValue {
/** All open tabs */
tabs: Tab[];
/** Currently active tab ID */
activeTabId: string;
/** Open a new tab or switch to existing */
openTab: (tab: Omit<Tab, 'id'> & { id?: string }) => void;
/** Close a tab by ID */
closeTab: (tabId: string) => void;
/** Switch to a different tab */
switchTab: (tabId: string) => void;
/** Update tab data */
updateTab: (tabId: string, updates: Partial<Tab>) => void;
/** Get tab by ID */
getTab: (tabId: string) => Tab | undefined;
}
const CanvasTabsContext = createContext<CanvasTabsContextValue | undefined>(undefined);
/**
* Hook to access canvas tabs context
*/
export function useCanvasTabs(): CanvasTabsContextValue {
const context = useContext(CanvasTabsContext);
if (!context) {
throw new Error('useCanvasTabs must be used within a CanvasTabsProvider');
}
return context;
}
interface CanvasTabsProviderProps {
children: ReactNode;
}
/**
* Provider for canvas tabs state
*/
export function CanvasTabsProvider({ children }: CanvasTabsProviderProps) {
// Start with no tabs - Logic Builder tabs are opened on demand
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | undefined>(undefined);
/**
* Open a new tab or switch to existing one
*/
const openTab = useCallback((newTab: Omit<Tab, 'id'> & { id?: string }) => {
// Generate ID if not provided
const tabId = newTab.id || `${newTab.type}-${newTab.nodeId || Date.now()}`;
setTabs((prevTabs) => {
// Check if tab already exists
const existingTab = prevTabs.find((t) => t.id === tabId);
if (existingTab) {
// Tab exists, just switch to it
setActiveTabId(tabId);
return prevTabs;
}
// Add new tab
const tab: Tab = {
...newTab,
id: tabId
};
const newTabs = [...prevTabs, tab];
// Emit event that a Logic Builder tab was opened (first tab)
if (prevTabs.length === 0) {
EventDispatcher.instance.emit('LogicBuilder.TabOpened');
}
return newTabs;
});
// Switch to the new/existing tab
setActiveTabId(tabId);
}, []);
/**
* Listen for Logic Builder tab open requests from property panel
*/
useEffect(() => {
const context = {};
const handleOpenTab = (data: { nodeId: string; nodeName: string; workspace: string }) => {
console.log('[CanvasTabsContext] Received LogicBuilder.OpenTab event:', data);
openTab({
type: 'logic-builder',
nodeId: data.nodeId,
nodeName: data.nodeName,
workspace: data.workspace
});
};
EventDispatcher.instance.on('LogicBuilder.OpenTab', handleOpenTab, context);
return () => {
EventDispatcher.instance.off(context);
};
}, [openTab]);
/**
* Close a tab by ID
*/
const closeTab = useCallback(
(tabId: string) => {
setTabs((prevTabs) => {
const tabIndex = prevTabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) {
return prevTabs;
}
const newTabs = prevTabs.filter((t) => t.id !== tabId);
// If closing the active tab, switch to another tab or clear active
if (activeTabId === tabId) {
if (newTabs.length > 0) {
setActiveTabId(newTabs[newTabs.length - 1].id);
} else {
setActiveTabId(undefined);
// Emit event that all Logic Builder tabs are closed
EventDispatcher.instance.emit('LogicBuilder.AllTabsClosed');
}
}
return newTabs;
});
},
[activeTabId]
);
/**
* Switch to a different tab
*/
const switchTab = useCallback((tabId: string) => {
setTabs((prevTabs) => {
// Verify tab exists
const tab = prevTabs.find((t) => t.id === tabId);
if (!tab) {
console.warn(`[CanvasTabs] Tab ${tabId} not found`);
return prevTabs;
}
setActiveTabId(tabId);
return prevTabs;
});
}, []);
/**
* Update tab data
*/
const updateTab = useCallback((tabId: string, updates: Partial<Tab>) => {
setTabs((prevTabs) => {
return prevTabs.map((tab) => {
if (tab.id === tabId) {
return { ...tab, ...updates };
}
return tab;
});
});
}, []);
/**
* Get tab by ID
*/
const getTab = useCallback(
(tabId: string): Tab | undefined => {
return tabs.find((t) => t.id === tabId);
},
[tabs]
);
const value: CanvasTabsContextValue = {
tabs,
activeTabId,
openTab,
closeTab,
switchTab,
updateTab,
getTab
};
return <CanvasTabsContext.Provider value={value}>{children}</CanvasTabsContext.Provider>;
}

View File

@@ -0,0 +1,242 @@
/**
* CodeHistoryManager
*
* Manages automatic code snapshots for Expression, Function, and Script nodes.
* Allows users to view history and restore previous versions.
*
* @module models
*/
import { NodeGraphNode } from '@noodl-models/nodegraphmodel/NodeGraphNode';
import { ProjectModel } from '@noodl-models/projectmodel';
import Model from '../../../shared/model';
/**
* A single code snapshot
*/
export interface CodeSnapshot {
code: string;
timestamp: string; // ISO 8601 format
hash: string; // For deduplication
}
/**
* Metadata structure for code history
*/
export interface CodeHistoryMetadata {
codeHistory?: CodeSnapshot[];
}
/**
* Manages code history for nodes
*/
export class CodeHistoryManager extends Model {
public static instance = new CodeHistoryManager();
private readonly MAX_SNAPSHOTS = 20;
/**
* Save a code snapshot for a node
* Only saves if code has actually changed (hash comparison)
*/
saveSnapshot(nodeId: string, parameterName: string, code: string): void {
const node = this.getNode(nodeId);
if (!node) {
console.warn('CodeHistoryManager: Node not found:', nodeId);
return;
}
// Don't save empty code
if (!code || code.trim() === '') {
return;
}
// Compute hash for deduplication
const hash = this.hashCode(code);
// Get existing history
const history = this.getHistory(nodeId, parameterName);
// Check if last snapshot is identical (deduplication)
if (history.length > 0) {
const lastSnapshot = history[history.length - 1];
if (lastSnapshot.hash === hash) {
// Code hasn't changed, don't create duplicate snapshot
return;
}
}
// Create new snapshot
const snapshot: CodeSnapshot = {
code,
timestamp: new Date().toISOString(),
hash
};
// Add to history
history.push(snapshot);
// Prune old snapshots
if (history.length > this.MAX_SNAPSHOTS) {
history.splice(0, history.length - this.MAX_SNAPSHOTS);
}
// Save to node metadata
this.saveHistory(node, parameterName, history);
console.log(`📸 Code snapshot saved for node ${nodeId}, param ${parameterName} (${history.length} total)`);
}
/**
* Get code history for a node parameter
*/
getHistory(nodeId: string, parameterName: string): CodeSnapshot[] {
const node = this.getNode(nodeId);
if (!node) {
return [];
}
const historyKey = this.getHistoryKey(parameterName);
const metadata = node.metadata as CodeHistoryMetadata | undefined;
if (!metadata || !metadata[historyKey]) {
return [];
}
return metadata[historyKey] as CodeSnapshot[];
}
/**
* Restore a snapshot by timestamp
* Returns the code from that snapshot
*/
restoreSnapshot(nodeId: string, parameterName: string, timestamp: string): string | undefined {
const history = this.getHistory(nodeId, parameterName);
const snapshot = history.find((s) => s.timestamp === timestamp);
if (!snapshot) {
console.warn('CodeHistoryManager: Snapshot not found:', timestamp);
return undefined;
}
console.log(`↩️ Restoring snapshot from ${timestamp}`);
return snapshot.code;
}
/**
* Get a specific snapshot by timestamp
*/
getSnapshot(nodeId: string, parameterName: string, timestamp: string): CodeSnapshot | undefined {
const history = this.getHistory(nodeId, parameterName);
return history.find((s) => s.timestamp === timestamp);
}
/**
* Clear all history for a node parameter
*/
clearHistory(nodeId: string, parameterName: string): void {
const node = this.getNode(nodeId);
if (!node) {
return;
}
const historyKey = this.getHistoryKey(parameterName);
if (node.metadata) {
delete node.metadata[historyKey];
}
console.log(`🗑️ Cleared history for node ${nodeId}, param ${parameterName}`);
}
/**
* Get the node from the current project
*/
private getNode(nodeId: string): NodeGraphNode | undefined {
const project = ProjectModel.instance;
if (!project) {
return undefined;
}
// Search all components for the node
for (const component of project.getComponents()) {
const graph = component.graph;
if (!graph) continue;
const node = graph.findNodeWithId(nodeId);
if (node) {
return node;
}
}
return undefined;
}
/**
* Save history to node metadata
*/
private saveHistory(node: NodeGraphNode, parameterName: string, history: CodeSnapshot[]): void {
const historyKey = this.getHistoryKey(parameterName);
if (!node.metadata) {
node.metadata = {};
}
node.metadata[historyKey] = history;
// Notify that metadata changed (triggers project save)
node.notifyListeners('metadataChanged');
}
/**
* Get the metadata key for a parameter's history
* Uses a prefix to avoid conflicts with other metadata
*/
private getHistoryKey(parameterName: string): string {
return `codeHistory_${parameterName}`;
}
/**
* Compute a simple hash of code for deduplication
* Not cryptographic, just for detecting changes
*/
private hashCode(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(36);
}
/**
* Format a timestamp for display
* Returns human-readable relative time ("5 minutes ago", "Yesterday")
*/
formatTimestamp(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) {
return 'just now';
} else if (diffMin < 60) {
return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
} else if (diffHour < 24) {
return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
} else if (diffDay === 1) {
return 'yesterday at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (diffDay < 7) {
return `${diffDay} days ago`;
} else {
// Full date for older snapshots
return then.toLocaleDateString() + ' at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
}

View File

@@ -0,0 +1,170 @@
/**
* Expression Parameter Types
*
* Defines types and helper functions for expression-based property values.
* Allows properties to be set to JavaScript expressions that evaluate at runtime.
*
* @module ExpressionParameter
* @since 1.1.0
*/
/**
* An expression parameter stores a JavaScript expression that evaluates at runtime
*/
export interface ExpressionParameter {
/** Marker to identify expression parameters */
mode: 'expression';
/** The JavaScript expression to evaluate */
expression: string;
/** Fallback value if expression fails or is invalid */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallback?: any;
/** Expression system version for future migrations */
version?: number;
}
/**
* A parameter can be a simple value or an expression
* Note: any is intentional - parameters can be any JSON-serializable value
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ParameterValue = any | ExpressionParameter;
/**
* Type guard to check if a parameter value is an expression
*
* @param value - The parameter value to check
* @returns True if value is an ExpressionParameter
*
* @example
* ```typescript
* const param = node.getParameter('marginLeft');
* if (isExpressionParameter(param)) {
* console.log('Expression:', param.expression);
* } else {
* console.log('Fixed value:', param);
* }
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isExpressionParameter(value: any): value is ExpressionParameter {
return (
value !== null &&
value !== undefined &&
typeof value === 'object' &&
value.mode === 'expression' &&
typeof value.expression === 'string'
);
}
/**
* Get the display value for a parameter (for UI rendering)
*
* - For expression parameters: returns the expression string
* - For simple values: returns the value as-is
*
* @param value - The parameter value
* @returns Display value (expression string or simple value)
*
* @example
* ```typescript
* const expr = { mode: 'expression', expression: 'Variables.x * 2', fallback: 0 };
* getParameterDisplayValue(expr); // Returns: 'Variables.x * 2'
* getParameterDisplayValue(42); // Returns: 42
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getParameterDisplayValue(value: ParameterValue): any {
if (isExpressionParameter(value)) {
return value.expression;
}
return value;
}
/**
* Get the actual value for a parameter (unwraps expression fallback)
*
* - For expression parameters: returns the fallback value
* - For simple values: returns the value as-is
*
* This is useful when you need a concrete value for initialization
* before the expression can be evaluated.
*
* @param value - The parameter value
* @returns Actual value (fallback or simple value)
*
* @example
* ```typescript
* const expr = { mode: 'expression', expression: 'Variables.x', fallback: 100 };
* getParameterActualValue(expr); // Returns: 100
* getParameterActualValue(42); // Returns: 42
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getParameterActualValue(value: ParameterValue): any {
if (isExpressionParameter(value)) {
return value.fallback;
}
return value;
}
/**
* Create an expression parameter
*
* @param expression - The JavaScript expression string
* @param fallback - Optional fallback value if expression fails
* @param version - Expression system version (default: 1)
* @returns A new ExpressionParameter object
*
* @example
* ```typescript
* // Simple expression with fallback
* const param = createExpressionParameter('Variables.count', 0);
*
* // Complex expression
* const param = createExpressionParameter(
* 'Variables.isAdmin ? "Admin" : "User"',
* 'User'
* );
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createExpressionParameter(
expression: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallback?: any,
version: number = 1
): ExpressionParameter {
return {
mode: 'expression',
expression,
fallback,
version
};
}
/**
* Convert a value to a parameter (for consistency)
*
* - Expression parameters are returned as-is
* - Simple values are returned as-is
*
* This is mainly for type safety and consistency in parameter handling.
*
* @param value - The value to convert
* @returns The value as a ParameterValue
*
* @example
* ```typescript
* const expr = createExpressionParameter('Variables.x');
* toParameter(expr); // Returns: expr (unchanged)
* toParameter(42); // Returns: 42 (unchanged)
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function toParameter(value: any): ParameterValue {
return value;
}

View File

@@ -10,6 +10,7 @@ import { UndoActionGroup, UndoQueue } from '@noodl-models/undo-queue-model';
import { WarningsModel } from '@noodl-models/warningsmodel';
import Model from '../../../../shared/model';
import { ParameterValueResolver } from '../../utils/ParameterValueResolver';
export type NodeGraphNodeParameters = {
[key: string]: any;
@@ -772,6 +773,28 @@ export class NodeGraphNode extends Model {
return port ? port.default : undefined;
}
/**
* Get a parameter value formatted as a display string.
* Handles expression parameter objects by resolving them to strings.
*
* @param name - The parameter name
* @param args - Optional args (same as getParameter)
* @returns A string representation of the parameter value, safe for UI display
*
* @example
* ```ts
* // Regular value
* node.getParameterDisplayValue('width') // '100'
*
* // Expression parameter object
* node.getParameterDisplayValue('height') // '{height * 2}' (not '[object Object]')
* ```
*/
getParameterDisplayValue(name: string, args?): string {
const value = this.getParameter(name, args);
return ParameterValueResolver.toString(value);
}
// Sets the dynamic instance ports for this node
setDynamicPorts(ports: NodeGrapPort[], options?: DynamicPortsOptions) {
if (portsEqual(ports, this.dynamicports)) {

View File

@@ -15,11 +15,9 @@ import {
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { GitHubUser } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { useEventListener } from '../../hooks/useEventListener';
import { IRouteProps } from '../../pages/AppRoute';
import { GitHubOAuthService } from '../../services/GitHubOAuthService';
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
@@ -49,11 +47,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Real projects from LocalProjectsModel
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
// GitHub OAuth state
const [githubUser, setGithubUser] = useState<GitHubUser | null>(null);
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState<boolean>(false);
const [githubIsConnecting, setGithubIsConnecting] = useState<boolean>(false);
// Create project modal state
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
@@ -62,17 +55,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
// Initialize GitHub OAuth service
const initGitHub = async () => {
console.log('🔧 Initializing GitHub OAuth service...');
await GitHubOAuthService.instance.initialize();
const user = GitHubOAuthService.instance.getCurrentUser();
const isAuth = GitHubOAuthService.instance.isAuthenticated();
setGithubUser(user);
setGithubIsAuthenticated(isAuth);
console.log('✅ GitHub OAuth initialized. Authenticated:', isAuth);
};
// Load projects
const loadProjects = async () => {
await LocalProjectsModel.instance.fetch();
@@ -80,31 +62,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
setRealProjects(projects.map(mapProjectToLauncherData));
};
initGitHub();
loadProjects();
// Set up IPC listener for OAuth callback
const handleOAuthCallback = (_event: any, { code, state }: { code: string; state: string }) => {
console.log('🔄 Received GitHub OAuth callback from main process');
setGithubIsConnecting(true);
GitHubOAuthService.instance
.handleCallback(code, state)
.then(() => {
console.log('✅ OAuth callback handled successfully');
setGithubIsConnecting(false);
})
.catch((error) => {
console.error('❌ OAuth callback failed:', error);
setGithubIsConnecting(false);
ToastLayer.showError('GitHub authentication failed');
});
};
ipcRenderer.on('github-oauth-callback', handleOAuthCallback);
return () => {
ipcRenderer.removeListener('github-oauth-callback', handleOAuthCallback);
};
}, []);
// Subscribe to project list changes
@@ -114,44 +72,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
setRealProjects(projects.map(mapProjectToLauncherData));
});
// Subscribe to GitHub OAuth state changes
useEventListener(GitHubOAuthService.instance, 'oauth-success', (data: { user: GitHubUser }) => {
console.log('🎉 GitHub OAuth success:', data.user.login);
setGithubUser(data.user);
setGithubIsAuthenticated(true);
setGithubIsConnecting(false);
ToastLayer.showSuccess(`Connected to GitHub as ${data.user.login}`);
});
useEventListener(GitHubOAuthService.instance, 'auth-state-changed', (data: { authenticated: boolean }) => {
console.log('🔐 GitHub auth state changed:', data.authenticated);
setGithubIsAuthenticated(data.authenticated);
if (data.authenticated) {
const user = GitHubOAuthService.instance.getCurrentUser();
setGithubUser(user);
} else {
setGithubUser(null);
}
});
useEventListener(GitHubOAuthService.instance, 'oauth-started', () => {
console.log('🚀 GitHub OAuth flow started');
setGithubIsConnecting(true);
});
useEventListener(GitHubOAuthService.instance, 'oauth-error', (data: { error: string }) => {
console.error('❌ GitHub OAuth error:', data.error);
setGithubIsConnecting(false);
ToastLayer.showError(`GitHub authentication failed: ${data.error}`);
});
useEventListener(GitHubOAuthService.instance, 'disconnected', () => {
console.log('👋 GitHub disconnected');
setGithubUser(null);
setGithubIsAuthenticated(false);
ToastLayer.showSuccess('Disconnected from GitHub');
});
const handleCreateProject = useCallback(() => {
setIsCreateModalVisible(true);
}, []);
@@ -336,17 +256,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
}
}, []);
// GitHub OAuth handlers
const handleGitHubConnect = useCallback(() => {
console.log('🔗 Initiating GitHub OAuth...');
GitHubOAuthService.instance.initiateOAuth();
}, []);
const handleGitHubDisconnect = useCallback(() => {
console.log('🔌 Disconnecting GitHub...');
GitHubOAuthService.instance.disconnect();
}, []);
return (
<>
<Launcher
@@ -357,11 +266,11 @@ export function ProjectsPage(props: ProjectsPageProps) {
onOpenProjectFolder={handleOpenProjectFolder}
onDeleteProject={handleDeleteProject}
projectOrganizationService={ProjectOrganizationService.instance}
githubUser={githubUser}
githubIsAuthenticated={githubIsAuthenticated}
githubIsConnecting={githubIsConnecting}
onGitHubConnect={handleGitHubConnect}
onGitHubDisconnect={handleGitHubDisconnect}
githubUser={null}
githubIsAuthenticated={false}
githubIsConnecting={false}
onGitHubConnect={() => {}}
onGitHubDisconnect={() => {}}
/>
<CreateProjectModal

View File

@@ -0,0 +1,308 @@
/**
* GitHubAuth
*
* Handles GitHub OAuth authentication using Web OAuth Flow.
* Web OAuth Flow allows users to select which organizations and repositories
* to grant access to, providing better permission control.
*
* @module services/github
* @since 1.1.0
*/
import { ipcRenderer, shell } from 'electron';
import { GitHubTokenStore } from './GitHubTokenStore';
import type {
GitHubAuthState,
GitHubDeviceCode,
GitHubToken,
GitHubAuthError,
GitHubUser,
GitHubInstallation
} from './GitHubTypes';
/**
* Scopes required for GitHub integration
* - repo: Full control of private repositories (for issues, PRs)
* - read:org: Read organization membership
* - read:user: Read user profile data
* - user:email: Read user email addresses
*/
const REQUIRED_SCOPES = ['repo', 'read:org', 'read:user', 'user:email'];
/**
* GitHubAuth
*
* Manages GitHub OAuth authentication using Device Flow.
* Provides methods to authenticate, check status, and disconnect.
*/
export class GitHubAuth {
/**
* Initiate GitHub Web OAuth flow
*
* Opens browser to GitHub authorization page where user can select
* which organizations and repositories to grant access to.
*
* @param onProgress - Callback for progress updates
* @returns Promise that resolves when authentication completes
*
* @throws {GitHubAuthError} If OAuth flow fails
*
* @example
* ```typescript
* await GitHubAuth.startWebOAuthFlow((message) => {
* console.log(message);
* });
* console.log('Successfully authenticated!');
* ```
*/
static async startWebOAuthFlow(onProgress?: (message: string) => void): Promise<void> {
try {
onProgress?.('Starting GitHub authentication...');
// Request OAuth flow from main process
const result = await ipcRenderer.invoke('github-oauth-start');
if (!result.success) {
throw new Error(result.error || 'Failed to start OAuth flow');
}
onProgress?.('Opening GitHub in your browser...');
// Open browser to GitHub authorization page
shell.openExternal(result.authUrl);
// Wait for OAuth callback from main process
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error('Authentication timed out after 5 minutes'));
}, 300000); // 5 minutes
const handleSuccess = async (_event: Electron.IpcRendererEvent, data: any) => {
console.log('🎉 [GitHub Auth] ========================================');
console.log('🎉 [GitHub Auth] IPC EVENT RECEIVED: github-oauth-complete');
console.log('🎉 [GitHub Auth] Data:', data);
console.log('🎉 [GitHub Auth] ========================================');
cleanup();
try {
onProgress?.('Authentication successful, fetching details...');
// Save token and user info
const token: GitHubToken = {
access_token: data.token.access_token,
token_type: data.token.token_type,
scope: data.token.scope
};
const installations = data.installations as GitHubInstallation[];
GitHubTokenStore.saveToken(token, data.user.login, data.user.email, installations);
onProgress?.(`Successfully authenticated as ${data.user.login}`);
resolve();
} catch (error) {
reject(error);
}
};
const handleError = (_event: Electron.IpcRendererEvent, data: any) => {
cleanup();
reject(new Error(data.message || 'Authentication failed'));
};
const cleanup = () => {
clearTimeout(timeout);
ipcRenderer.removeListener('github-oauth-complete', handleSuccess);
ipcRenderer.removeListener('github-oauth-error', handleError);
};
ipcRenderer.once('github-oauth-complete', handleSuccess);
ipcRenderer.once('github-oauth-error', handleError);
});
} catch (error) {
const authError: GitHubAuthError = new Error(
`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
authError.code = error instanceof Error && 'code' in error ? (error as { code?: string }).code : undefined;
console.error('[GitHub] Authentication error:', authError);
throw authError;
}
}
/**
* @deprecated Use startWebOAuthFlow instead. Device Flow kept for backward compatibility.
*/
static async startDeviceFlow(onProgress?: (message: string) => void): Promise<GitHubDeviceCode> {
console.warn('[GitHub] startDeviceFlow is deprecated, using startWebOAuthFlow instead');
await this.startWebOAuthFlow(onProgress);
// Return empty device code for backward compatibility
return {
device_code: '',
user_code: '',
verification_uri: '',
expires_in: 0,
interval: 0
};
}
/**
* Fetch user information from GitHub API
*
* @param token - Access token
* @returns User information
*
* @throws {Error} If API request fails
*/
private static async fetchUserInfo(token: string): Promise<GitHubUser> {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
return response.json();
}
/**
* Get current authentication state
*
* @returns Current auth state
*
* @example
* ```typescript
* const state = GitHubAuth.getAuthState();
* if (state.isAuthenticated) {
* console.log('Connected as:', state.username);
* }
* ```
*/
static getAuthState(): GitHubAuthState {
const storedAuth = GitHubTokenStore.getToken();
if (!storedAuth) {
return {
isAuthenticated: false
};
}
// Check if token is expired
if (GitHubTokenStore.isTokenExpired()) {
console.warn('[GitHub] Token is expired');
return {
isAuthenticated: false
};
}
return {
isAuthenticated: true,
username: storedAuth.user.login,
email: storedAuth.user.email || undefined,
token: storedAuth.token,
authenticatedAt: storedAuth.storedAt
};
}
/**
* Check if user is currently authenticated
*
* @returns True if authenticated and token is valid
*/
static isAuthenticated(): boolean {
return this.getAuthState().isAuthenticated;
}
/**
* Get the username of authenticated user
*
* @returns Username or null if not authenticated
*/
static getUsername(): string | null {
return this.getAuthState().username || null;
}
/**
* Get current access token
*
* @returns Access token or null if not authenticated
*/
static getAccessToken(): string | null {
const state = this.getAuthState();
return state.token?.access_token || null;
}
/**
* Disconnect from GitHub
*
* Clears stored authentication data. User will need to re-authenticate.
*
* @example
* ```typescript
* GitHubAuth.disconnect();
* console.log('Disconnected from GitHub');
* ```
*/
static disconnect(): void {
GitHubTokenStore.clearToken();
console.log('[GitHub] User disconnected');
}
/**
* Validate current token by making a test API call
*
* @returns True if token is valid, false otherwise
*/
static async validateToken(): Promise<boolean> {
const token = this.getAccessToken();
if (!token) {
return false;
}
try {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
return response.ok;
} catch (error) {
console.error('[GitHub] Token validation failed:', error);
return false;
}
}
/**
* Refresh user information from GitHub
*
* Useful for updating cached user data
*
* @returns Updated auth state
* @throws {Error} If not authenticated or refresh fails
*/
static async refreshUserInfo(): Promise<GitHubAuthState> {
const token = this.getAccessToken();
if (!token) {
throw new Error('Not authenticated');
}
const user = await this.fetchUserInfo(token);
// Update stored auth with new user info
const storedAuth = GitHubTokenStore.getToken();
if (storedAuth) {
GitHubTokenStore.saveToken(storedAuth.token, user.login, user.email);
}
return this.getAuthState();
}
}

View File

@@ -0,0 +1,255 @@
/**
* GitHubClient
*
* Wrapper around Octokit REST API client with authentication and rate limiting.
* Provides convenient methods for GitHub API operations needed by OpenNoodl.
*
* @module services/github
* @since 1.1.0
*/
import { Octokit } from '@octokit/rest';
import { GitHubAuth } from './GitHubAuth';
import type { GitHubRepository, GitHubRateLimit, GitHubUser } from './GitHubTypes';
/**
* GitHubClient
*
* Main client for GitHub API interactions.
* Automatically uses authenticated token from GitHubAuth.
* Handles rate limiting and provides typed API methods.
*/
export class GitHubClient {
private octokit: Octokit | null = null;
private lastRateLimit: GitHubRateLimit | null = null;
/**
* Initialize Octokit instance with current auth token
*
* @returns Octokit instance or null if not authenticated
*/
private getOctokit(): Octokit | null {
const token = GitHubAuth.getAccessToken();
if (!token) {
console.warn('[GitHub Client] Not authenticated');
return null;
}
// Create new instance if token changed or doesn't exist
if (!this.octokit) {
this.octokit = new Octokit({
auth: token,
userAgent: 'OpenNoodl/1.1.0'
});
}
return this.octokit;
}
/**
* Check if client is ready (authenticated)
*
* @returns True if client has valid auth token
*/
isReady(): boolean {
return GitHubAuth.isAuthenticated();
}
/**
* Get current rate limit status
*
* @returns Rate limit information
* @throws {Error} If not authenticated
*/
async getRateLimit(): Promise<GitHubRateLimit> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.rateLimit.get();
const core = response.data.resources.core;
const rateLimit: GitHubRateLimit = {
limit: core.limit,
remaining: core.remaining,
reset: core.reset,
resource: 'core'
};
this.lastRateLimit = rateLimit;
return rateLimit;
}
/**
* Check if we're approaching rate limit
*
* @returns True if remaining requests < 100
*/
isApproachingRateLimit(): boolean {
if (!this.lastRateLimit) {
return false;
}
return this.lastRateLimit.remaining < 100;
}
/**
* Get authenticated user's information
*
* @returns User information
* @throws {Error} If not authenticated or API call fails
*/
async getAuthenticatedUser(): Promise<GitHubUser> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.users.getAuthenticated();
return response.data as GitHubUser;
}
/**
* Get repository information
*
* @param owner - Repository owner
* @param repo - Repository name
* @returns Repository information
* @throws {Error} If repository not found or API call fails
*/
async getRepository(owner: string, repo: string): Promise<GitHubRepository> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.repos.get({ owner, repo });
return response.data as GitHubRepository;
}
/**
* List user's repositories
*
* @param options - Listing options
* @returns Array of repositories
* @throws {Error} If not authenticated or API call fails
*/
async listRepositories(options?: {
visibility?: 'all' | 'public' | 'private';
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
per_page?: number;
}): Promise<GitHubRepository[]> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.repos.listForAuthenticatedUser({
visibility: options?.visibility || 'all',
sort: options?.sort || 'updated',
per_page: options?.per_page || 30
});
return response.data as GitHubRepository[];
}
/**
* Check if a repository exists and user has access
*
* @param owner - Repository owner
* @param repo - Repository name
* @returns True if repository exists and accessible
*/
async repositoryExists(owner: string, repo: string): Promise<boolean> {
try {
await this.getRepository(owner, repo);
return true;
} catch (error) {
return false;
}
}
/**
* Parse repository URL to owner/repo
*
* Handles various GitHub URL formats:
* - https://github.com/owner/repo
* - git@github.com:owner/repo.git
* - https://github.com/owner/repo.git
*
* @param url - GitHub repository URL
* @returns Object with owner and repo, or null if invalid
*/
static parseRepoUrl(url: string): { owner: string; repo: string } | null {
try {
// Remove .git suffix if present
const cleanUrl = url.replace(/\.git$/, '');
// Handle SSH format: git@github.com:owner/repo
if (cleanUrl.includes('git@github.com:')) {
const parts = cleanUrl.split('git@github.com:')[1].split('/');
if (parts.length >= 2) {
return {
owner: parts[0],
repo: parts[1]
};
}
}
// Handle HTTPS format: https://github.com/owner/repo
if (cleanUrl.includes('github.com/')) {
const parts = cleanUrl.split('github.com/')[1].split('/');
if (parts.length >= 2) {
return {
owner: parts[0],
repo: parts[1]
};
}
}
return null;
} catch (error) {
console.error('[GitHub Client] Error parsing repo URL:', error);
return null;
}
}
/**
* Get repository from local Git remote URL
*
* Useful for getting GitHub repo info from current project's git remote.
*
* @param remoteUrl - Git remote URL
* @returns Repository information if GitHub repo, null otherwise
*/
async getRepositoryFromRemoteUrl(remoteUrl: string): Promise<GitHubRepository | null> {
const parsed = GitHubClient.parseRepoUrl(remoteUrl);
if (!parsed) {
return null;
}
try {
return await this.getRepository(parsed.owner, parsed.repo);
} catch (error) {
console.error('[GitHub Client] Error fetching repository:', error);
return null;
}
}
/**
* Reset client state
*
* Call this when user disconnects or token changes.
*/
reset(): void {
this.octokit = null;
this.lastRateLimit = null;
}
}
/**
* Singleton instance of GitHubClient
* Use this for all GitHub API operations
*/
export const githubClient = new GitHubClient();

View File

@@ -0,0 +1,217 @@
/**
* GitHubTokenStore
*
* Secure storage for GitHub OAuth tokens using Electron Store.
* Tokens are stored encrypted using Electron's safeStorage API.
* This provides OS-level encryption (Keychain on macOS, Credential Manager on Windows).
*
* @module services/github
* @since 1.1.0
*/
import ElectronStore from 'electron-store';
import type { StoredGitHubAuth, GitHubToken, GitHubInstallation } from './GitHubTypes';
/**
* Store key for GitHub authentication data
*/
const GITHUB_AUTH_KEY = 'github.auth';
/**
* Electron store instance for GitHub credentials
* Uses encryption for sensitive data
*/
const store = new ElectronStore<{
'github.auth'?: StoredGitHubAuth;
}>({
name: 'github-credentials',
// Encrypt the entire store for security
encryptionKey: 'opennoodl-github-credentials'
});
/**
* GitHubTokenStore
*
* Manages secure storage and retrieval of GitHub OAuth tokens.
* Provides methods to save, retrieve, and clear authentication data.
*/
export class GitHubTokenStore {
/**
* Save GitHub authentication data to secure storage
*
* @param token - OAuth access token
* @param username - GitHub username
* @param email - User's email (nullable)
* @param installations - Optional list of installations (orgs/repos with access)
*
* @example
* ```typescript
* await GitHubTokenStore.saveToken(
* { access_token: 'gho_...', token_type: 'bearer', scope: 'repo' },
* 'octocat',
* 'octocat@github.com',
* installations
* );
* ```
*/
static saveToken(
token: GitHubToken,
username: string,
email: string | null,
installations?: GitHubInstallation[]
): void {
const authData: StoredGitHubAuth = {
token,
user: {
login: username,
email
},
installations,
storedAt: new Date().toISOString()
};
store.set(GITHUB_AUTH_KEY, authData);
if (installations && installations.length > 0) {
const orgNames = installations.map((i) => i.account.login).join(', ');
console.log(`[GitHub] Token saved for user: ${username} with access to: ${orgNames}`);
} else {
console.log('[GitHub] Token saved for user:', username);
}
}
/**
* Get installations (organizations/repos with access)
*
* @returns List of installations if authenticated, empty array otherwise
*/
static getInstallations(): GitHubInstallation[] {
const authData = this.getToken();
return authData?.installations || [];
}
/**
* Retrieve stored GitHub authentication data
*
* @returns Stored auth data if exists, null otherwise
*
* @example
* ```typescript
* const authData = GitHubTokenStore.getToken();
* if (authData) {
* console.log('Authenticated as:', authData.user.login);
* }
* ```
*/
static getToken(): StoredGitHubAuth | null {
try {
const authData = store.get(GITHUB_AUTH_KEY);
return authData || null;
} catch (error) {
console.error('[GitHub] Error reading token:', error);
return null;
}
}
/**
* Check if a valid token exists
*
* @returns True if token exists, false otherwise
*
* @example
* ```typescript
* if (GitHubTokenStore.hasToken()) {
* // User is authenticated
* }
* ```
*/
static hasToken(): boolean {
const authData = this.getToken();
return authData !== null && !!authData.token.access_token;
}
/**
* Get the username of the authenticated user
*
* @returns Username if authenticated, null otherwise
*/
static getUsername(): string | null {
const authData = this.getToken();
return authData?.user.login || null;
}
/**
* Get the access token string
*
* @returns Access token if exists, null otherwise
*/
static getAccessToken(): string | null {
const authData = this.getToken();
return authData?.token.access_token || null;
}
/**
* Clear stored authentication data
* Call this when user disconnects their GitHub account
*
* @example
* ```typescript
* GitHubTokenStore.clearToken();
* console.log('User disconnected from GitHub');
* ```
*/
static clearToken(): void {
store.delete(GITHUB_AUTH_KEY);
console.log('[GitHub] Token cleared');
}
/**
* Check if token is expired (if expiration is set)
*
* @returns True if token is expired, false if valid or no expiration
*/
static isTokenExpired(): boolean {
const authData = this.getToken();
if (!authData || !authData.token.expires_at) {
// No expiration set - assume valid
return false;
}
const expiresAt = new Date(authData.token.expires_at);
const now = new Date();
return now >= expiresAt;
}
/**
* Update token (for refresh scenarios)
*
* @param token - New OAuth token
*/
static updateToken(token: GitHubToken): void {
const existing = this.getToken();
if (!existing) {
throw new Error('Cannot update token: No existing auth data found');
}
const updated: StoredGitHubAuth = {
...existing,
token,
storedAt: new Date().toISOString()
};
store.set(GITHUB_AUTH_KEY, updated);
console.log('[GitHub] Token updated');
}
/**
* Get all stored GitHub data (for debugging)
* WARNING: Contains sensitive data - use carefully
*
* @returns All stored data
*/
static _debug_getAllData(): StoredGitHubAuth | null {
return this.getToken();
}
}

View File

@@ -0,0 +1,184 @@
/**
* GitHubTypes
*
* TypeScript type definitions for GitHub OAuth and API integration.
* These types define the structure of tokens, authentication state, and API responses.
*
* @module services/github
* @since 1.1.0
*/
/**
* OAuth device code response from GitHub
* Returned when initiating device flow authorization
*/
export interface GitHubDeviceCode {
/** The device verification code */
device_code: string;
/** The user verification code (8-character code) */
user_code: string;
/** URL where user enters the code */
verification_uri: string;
/** Expiration time in seconds (default: 900) */
expires_in: number;
/** Polling interval in seconds (default: 5) */
interval: number;
}
/**
* GitHub OAuth access token
* Stored securely and used for API authentication
*/
export interface GitHubToken {
/** The OAuth access token */
access_token: string;
/** Token type (always 'bearer' for GitHub) */
token_type: string;
/** Granted scopes (comma-separated) */
scope: string;
/** Token expiration timestamp (ISO 8601) - undefined if no expiration */
expires_at?: string;
}
/**
* Current GitHub authentication state
* Used by React components to display connection status
*/
export interface GitHubAuthState {
/** Whether user is authenticated with GitHub */
isAuthenticated: boolean;
/** GitHub username if authenticated */
username?: string;
/** User's primary email if authenticated */
email?: string;
/** Current token (for internal use only) */
token?: GitHubToken;
/** Timestamp of last successful authentication */
authenticatedAt?: string;
}
/**
* GitHub user information
* Retrieved from /user API endpoint
*/
export interface GitHubUser {
/** GitHub username */
login: string;
/** GitHub user ID */
id: number;
/** User's display name */
name: string | null;
/** User's primary email */
email: string | null;
/** Avatar URL */
avatar_url: string;
/** Profile URL */
html_url: string;
/** User type (User or Organization) */
type: string;
}
/**
* GitHub repository information
* Basic repo details for issue/PR association
*/
export interface GitHubRepository {
/** Repository ID */
id: number;
/** Repository name (without owner) */
name: string;
/** Full repository name (owner/repo) */
full_name: string;
/** Repository owner */
owner: {
login: string;
id: number;
avatar_url: string;
};
/** Whether repo is private */
private: boolean;
/** Repository URL */
html_url: string;
/** Default branch */
default_branch: string;
}
/**
* GitHub App installation information
* Represents organizations/accounts where the app was installed
*/
export interface GitHubInstallation {
/** Installation ID */
id: number;
/** Account where app is installed */
account: {
login: string;
type: 'User' | 'Organization';
avatar_url: string;
};
/** Repository selection type */
repository_selection: 'all' | 'selected';
/** List of repositories (if selected) */
repositories?: Array<{
id: number;
name: string;
full_name: string;
private: boolean;
}>;
}
/**
* Rate limit information from GitHub API
* Used to prevent hitting API limits
*/
export interface GitHubRateLimit {
/** Maximum requests allowed per hour */
limit: number;
/** Remaining requests in current window */
remaining: number;
/** Timestamp when rate limit resets (Unix epoch) */
reset: number;
/** Resource type (core, search, graphql) */
resource: string;
}
/**
* Error response from GitHub API
*/
export interface GitHubError {
/** HTTP status code */
status: number;
/** Error message */
message: string;
/** Detailed documentation URL if available */
documentation_url?: string;
}
/**
* OAuth authorization error
* Thrown during device flow authorization
*/
export interface GitHubAuthError extends Error {
/** Error code from GitHub */
code?: string;
/** HTTP status if applicable */
status?: number;
}
/**
* Stored token data (persisted format)
* Encrypted and stored in Electron's secure storage
*/
export interface StoredGitHubAuth {
/** OAuth token */
token: GitHubToken;
/** Associated user info */
user: {
login: string;
email: string | null;
};
/** Installation information (organizations/repos with access) */
installations?: GitHubInstallation[];
/** Timestamp when stored */
storedAt: string;
}

View File

@@ -0,0 +1,41 @@
/**
* GitHub Services
*
* Public exports for GitHub OAuth authentication and API integration.
* This module provides everything needed to connect to GitHub,
* authenticate users, and interact with the GitHub API.
*
* @module services/github
* @since 1.1.0
*
* @example
* ```typescript
* import { GitHubAuth, githubClient } from '@noodl-services/github';
*
* // Check if authenticated
* if (GitHubAuth.isAuthenticated()) {
* // Fetch user repos
* const repos = await githubClient.listRepositories();
* }
* ```
*/
// Authentication
export { GitHubAuth } from './GitHubAuth';
export { GitHubTokenStore } from './GitHubTokenStore';
// API Client
export { GitHubClient, githubClient } from './GitHubClient';
// Types
export type {
GitHubDeviceCode,
GitHubToken,
GitHubAuthState,
GitHubUser,
GitHubRepository,
GitHubRateLimit,
GitHubError,
GitHubAuthError,
StoredGitHubAuth
} from './GitHubTypes';

View File

@@ -232,8 +232,8 @@
}
:root {
--popup-layer-tooltip-border-color: var(--theme-color-secondary);
--popup-layer-tooltip-background-color: var(--theme-color-secondary);
--popup-layer-tooltip-border-color: var(--theme-color-border-default);
--popup-layer-tooltip-background-color: var(--theme-color-bg-3);
}
.popup-layer-tooltip {
@@ -244,7 +244,7 @@
border-color: var(--popup-layer-tooltip-border-color);
border-width: 1px;
padding: 12px 16px;
color: var(--theme-color-fg-highlight);
color: var(--theme-color-fg-default);
position: absolute;
opacity: 0;
-webkit-transition: opacity 0.3s;

View File

@@ -1,4 +1,7 @@
<div class="nodegrapgeditor-bg nodegrapheditor-canvas" style="width: 100%; height: 100%">
<!-- Canvas Tabs Root (for React component) -->
<div id="canvas-tabs-root" style="position: absolute; width: 100%; height: 100%; z-index: 100; pointer-events: none;"></div>
<!--
wrap in a div to not trigger chromium bug where comments "scrolls" all the siblings
if comments are below the bottom of the parent

View File

@@ -0,0 +1,51 @@
/**
* Blockly Editor Globals
*
* Exposes Blockly-related utilities to the global scope for use by runtime nodes
*/
import { generateCode } from '../views/BlocklyEditor/NoodlGenerators';
import { detectIO } from './IODetector';
// Extend window interface
declare global {
interface Window {
NoodlEditor?: {
detectIO?: typeof detectIO;
generateBlocklyCode?: typeof generateCode;
};
}
}
/**
* Initialize Blockly editor globals
* This makes IODetector and code generation available to runtime nodes
*/
export function initBlocklyEditorGlobals() {
console.log('🔍 [BlocklyGlobals] initBlocklyEditorGlobals called');
console.log('🔍 [BlocklyGlobals] window undefined?', typeof window === 'undefined');
// Create NoodlEditor namespace if it doesn't exist
if (typeof window !== 'undefined') {
console.log('🔍 [BlocklyGlobals] window.NoodlEditor before:', window.NoodlEditor);
if (!window.NoodlEditor) {
window.NoodlEditor = {};
console.log('🔍 [BlocklyGlobals] Created new window.NoodlEditor');
}
// Expose IODetector
window.NoodlEditor.detectIO = detectIO;
console.log('🔍 [BlocklyGlobals] Assigned detectIO:', typeof window.NoodlEditor.detectIO);
// Expose code generator
window.NoodlEditor.generateBlocklyCode = generateCode;
console.log('🔍 [BlocklyGlobals] Assigned generateBlocklyCode:', typeof window.NoodlEditor.generateBlocklyCode);
console.log('✅ [Blockly] Editor globals initialized');
console.log('🔍 [BlocklyGlobals] window.NoodlEditor after:', window.NoodlEditor);
}
}
// Auto-initialize when module loads
initBlocklyEditorGlobals();

View File

@@ -0,0 +1,189 @@
/**
* IODetector
*
* Utility for detecting inputs, outputs, and signals from Blockly workspaces.
* Scans workspace JSON to find Input/Output definition blocks and extracts their configuration.
*
* @module utils
*/
export interface DetectedInput {
name: string;
type: string;
}
export interface DetectedOutput {
name: string;
type: string;
}
export interface DetectedIO {
inputs: DetectedInput[];
outputs: DetectedOutput[];
signalInputs: string[];
signalOutputs: string[];
}
interface BlocklyBlock {
type?: string;
fields?: Record<string, string>;
inputs?: Record<string, { block?: BlocklyBlock }>;
next?: { block?: BlocklyBlock };
}
/**
* Detect all I/O from a Blockly workspace JSON
*
* @param workspaceJson - Serialized Blockly workspace (JSON string or object)
* @returns Detected inputs, outputs, and signals
*/
export function detectIO(workspaceJson: string | object): DetectedIO {
const result: DetectedIO = {
inputs: [],
outputs: [],
signalInputs: [],
signalOutputs: []
};
try {
const workspace = typeof workspaceJson === 'string' ? JSON.parse(workspaceJson) : workspaceJson;
if (!workspace || !workspace.blocks || !workspace.blocks.blocks) {
return result;
}
const blocks = workspace.blocks.blocks;
for (const block of blocks) {
processBlock(block, result);
}
} catch (error) {
console.error('[IODetector] Failed to parse workspace:', error);
}
// Remove duplicates
result.inputs = uniqueBy(result.inputs, 'name');
result.outputs = uniqueBy(result.outputs, 'name');
result.signalInputs = Array.from(new Set(result.signalInputs));
result.signalOutputs = Array.from(new Set(result.signalOutputs));
return result;
}
/**
* Recursively process a block and its children
*/
function processBlock(block: BlocklyBlock, result: DetectedIO): void {
if (!block || !block.type) return;
switch (block.type) {
case 'noodl_define_input':
// Extract input definition
if (block.fields && block.fields.NAME && block.fields.TYPE) {
result.inputs.push({
name: block.fields.NAME,
type: block.fields.TYPE
});
}
break;
case 'noodl_get_input':
// Auto-detect input from usage
if (block.fields && block.fields.NAME) {
const name = block.fields.NAME;
if (!result.inputs.find((i) => i.name === name)) {
result.inputs.push({
name: name,
type: '*' // Default type
});
}
}
break;
case 'noodl_define_output':
// Extract output definition
if (block.fields && block.fields.NAME && block.fields.TYPE) {
result.outputs.push({
name: block.fields.NAME,
type: block.fields.TYPE
});
}
break;
case 'noodl_set_output':
// Auto-detect output from usage
if (block.fields && block.fields.NAME) {
const name = block.fields.NAME;
if (!result.outputs.find((o) => o.name === name)) {
result.outputs.push({
name: name,
type: '*' // Default type
});
}
}
break;
case 'noodl_define_signal_input':
// Extract signal input definition
if (block.fields && block.fields.NAME) {
result.signalInputs.push(block.fields.NAME);
}
break;
case 'noodl_on_signal':
// Auto-detect signal input from event handler
if (block.fields && block.fields.SIGNAL) {
const name = block.fields.SIGNAL;
if (!result.signalInputs.includes(name)) {
result.signalInputs.push(name);
}
}
break;
case 'noodl_define_signal_output':
// Extract signal output definition
if (block.fields && block.fields.NAME) {
result.signalOutputs.push(block.fields.NAME);
}
break;
case 'noodl_send_signal':
// Auto-detect signal output from send blocks
if (block.fields && block.fields.NAME) {
const name = block.fields.NAME;
if (!result.signalOutputs.includes(name)) {
result.signalOutputs.push(name);
}
}
break;
}
// Process nested blocks (inputs, next, etc.)
if (block.inputs) {
for (const inputKey in block.inputs) {
const input = block.inputs[inputKey];
if (input && input.block) {
processBlock(input.block, result);
}
}
}
if (block.next && block.next.block) {
processBlock(block.next.block, result);
}
}
/**
* Remove duplicates from array based on key
*/
function uniqueBy<T>(array: T[], key: keyof T): T[] {
const seen = new Set();
return array.filter((item) => {
const k = item[key];
if (seen.has(k)) {
return false;
}
seen.add(k);
return true;
});
}

View File

@@ -13,6 +13,7 @@ import Model from '../../../shared/model';
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
import { RuntimeVersionInfo } from '../models/migration/types';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import { GitHubAuth } from '../services/github';
import FileSystem from './filesystem';
import { tracker } from './tracker';
import { guid } from './utils';
@@ -119,6 +120,10 @@ export class LocalProjectsModel extends Model {
project.name = projectEntry.name; // Also assign the name
this.touchProject(projectEntry);
this.bindProject(project);
// Initialize Git authentication for this project
this.setCurrentGlobalGitAuth(projectEntry.id);
resolve(project);
});
});
@@ -329,13 +334,34 @@ export class LocalProjectsModel extends Model {
setCurrentGlobalGitAuth(projectId: string) {
const func = async (endpoint: string) => {
if (endpoint.includes('github.com')) {
// Priority 1: Check for global OAuth token
const authState = GitHubAuth.getAuthState();
if (authState.isAuthenticated && authState.token) {
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint);
return {
username: authState.username || 'oauth',
password: authState.token.access_token // Extract actual access token string
};
}
// Priority 2: Fall back to project-specific PAT
const config = await GitStore.get('github', projectId);
//username is not used by github when using a token, but git will still ask for it. Just set it to "noodl"
if (config?.password) {
console.log('[Git Auth] Using project PAT for:', endpoint);
return {
username: 'noodl',
password: config.password
};
}
// No credentials available
console.warn('[Git Auth] No GitHub credentials found for:', endpoint);
return {
username: 'noodl',
password: config?.password
password: ''
};
} else {
// Non-GitHub providers use project-specific credentials only
const config = await GitStore.get('unknown', projectId);
return {
username: config?.username,

View File

@@ -0,0 +1,193 @@
/**
* ParameterValueResolver
*
* Centralized utility for resolving parameter values from storage to their display/runtime values.
* Handles the conversion of expression parameter objects to primitive values based on context.
*
* This is necessary because parameters can be stored as either:
* 1. Primitive values (string, number, boolean)
* 2. Expression parameter objects: { mode: 'expression', expression: '...', fallback: '...', version: 1 }
*
* Consumers need different values based on their context:
* - Display (UI, canvas): Use fallback value
* - Runtime: Use evaluated expression (handled separately by runtime)
* - Serialization: Use raw value as-is
*
* @module noodl-editor/utils
* @since TASK-006B
*/
import { isExpressionParameter, ExpressionParameter } from '@noodl-models/ExpressionParameter';
/**
* Context in which a parameter value is being used
*/
export enum ValueContext {
/**
* Display context - for UI rendering (property panel, canvas)
* Returns the fallback value from expression parameters
*/
Display = 'display',
/**
* Runtime context - for runtime evaluation
* Returns the fallback value (actual evaluation happens in runtime)
*/
Runtime = 'runtime',
/**
* Serialization context - for saving/loading
* Returns the raw value unchanged
*/
Serialization = 'serialization'
}
/**
* Type for primitive parameter values
*/
export type PrimitiveValue = string | number | boolean | undefined;
/**
* ParameterValueResolver class
*
* Provides static methods to safely extract primitive values from parameters
* that may be either primitives or expression parameter objects.
*/
export class ParameterValueResolver {
/**
* Resolves a parameter value to a primitive based on context.
*
* @param paramValue - The raw parameter value (could be primitive or expression object)
* @param context - The context in which the value is being used
* @returns A primitive value appropriate for the context
*
* @example
* ```typescript
* // Primitive value passes through
* resolve('hello', ValueContext.Display) // => 'hello'
*
* // Expression parameter returns fallback
* const expr = { mode: 'expression', expression: 'Variables.x', fallback: 'default', version: 1 };
* resolve(expr, ValueContext.Display) // => 'default'
* ```
*/
static resolve(paramValue: unknown, context: ValueContext): PrimitiveValue | ExpressionParameter {
// If not an expression parameter, return as-is (assuming it's a primitive)
if (!isExpressionParameter(paramValue)) {
return paramValue as PrimitiveValue;
}
// Handle expression parameters based on context
switch (context) {
case ValueContext.Display:
// For display contexts (UI, canvas), use the fallback value
return paramValue.fallback ?? '';
case ValueContext.Runtime:
// For runtime, return fallback (actual evaluation happens in node runtime)
// This prevents display code from trying to evaluate expressions
return paramValue.fallback ?? '';
case ValueContext.Serialization:
// For serialization, return the whole object unchanged
return paramValue;
default:
// Default to fallback value for safety
return paramValue.fallback ?? '';
}
}
/**
* Safely converts any parameter value to a string for display.
* Always returns a string, never an object.
*
* @param paramValue - The raw parameter value
* @returns A string representation safe for display
*
* @example
* ```typescript
* toString('hello') // => 'hello'
* toString(42) // => '42'
* toString(null) // => ''
* toString(undefined) // => ''
* toString({ mode: 'expression', expression: '', fallback: 'test', version: 1 }) // => 'test'
* ```
*/
static toString(paramValue: unknown): string {
const resolved = this.resolve(paramValue, ValueContext.Display);
// If resolved is still an object (shouldn't happen, but defensive)
if (typeof resolved === 'object' && resolved !== null) {
return '';
}
return String(resolved ?? '');
}
/**
* Safely converts any parameter value to a number for display.
* Returns undefined if the value cannot be converted to a valid number.
*
* @param paramValue - The raw parameter value
* @returns A number, or undefined if conversion fails
*
* @example
* ```typescript
* toNumber(42) // => 42
* toNumber('42') // => 42
* toNumber('hello') // => undefined
* toNumber(null) // => undefined
* toNumber({ mode: 'expression', expression: '', fallback: 123, version: 1 }) // => 123
* ```
*/
static toNumber(paramValue: unknown): number | undefined {
const resolved = this.resolve(paramValue, ValueContext.Display);
// If resolved is still an object (shouldn't happen, but defensive)
if (typeof resolved === 'object' && resolved !== null) {
return undefined;
}
const num = Number(resolved);
return isNaN(num) ? undefined : num;
}
/**
* Safely converts any parameter value to a boolean for display.
* Uses JavaScript truthiness rules.
*
* @param paramValue - The raw parameter value
* @returns A boolean value
*
* @example
* ```typescript
* toBoolean(true) // => true
* toBoolean('hello') // => true
* toBoolean('') // => false
* toBoolean(0) // => false
* toBoolean({ mode: 'expression', expression: '', fallback: true, version: 1 }) // => true
* ```
*/
static toBoolean(paramValue: unknown): boolean {
const resolved = this.resolve(paramValue, ValueContext.Display);
// If resolved is still an object (shouldn't happen, but defensive)
if (typeof resolved === 'object' && resolved !== null) {
return false;
}
return Boolean(resolved);
}
/**
* Checks if a parameter value is an expression parameter.
* Convenience method that delegates to the ExpressionParameter module.
*
* @param paramValue - The value to check
* @returns True if the value is an expression parameter object
*/
static isExpression(paramValue: unknown): paramValue is ExpressionParameter {
return isExpressionParameter(paramValue);
}
}

View File

@@ -0,0 +1,250 @@
/**
* BlocklyWorkspace Styles
*
* Styling for the Blockly visual programming workspace.
* Uses theme tokens for consistent integration with Noodl editor.
*/
.Root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: var(--theme-color-bg-1);
overflow: hidden;
}
.BlocklyContainer {
flex: 1;
width: 100%;
height: 100%;
position: relative;
/* Ensure Blockly SVG fills container */
& > .injectionDiv {
width: 100% !important;
height: 100% !important;
}
}
/* Override Blockly default styles to match Noodl theme */
:global {
/* Toolbox styling */
.blocklyToolboxDiv {
background-color: var(--theme-color-bg-2) !important;
border-right: 1px solid var(--theme-color-border-default) !important;
}
.blocklyTreeLabel {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
}
.blocklyTreeRow:hover {
background-color: var(--theme-color-bg-3) !important;
}
.blocklyTreeSelected {
background-color: var(--theme-color-primary) !important;
}
/* Flyout styling */
.blocklyFlyoutBackground {
fill: var(--theme-color-bg-2) !important;
fill-opacity: 0.95 !important;
}
/* Block styling - keep default Blockly colors for now */
/* May customize later to match Noodl node colors */
/* Zoom controls */
.blocklyZoom {
& image {
filter: brightness(0.8);
}
}
/* Trashcan */
.blocklyTrash {
& image {
filter: brightness(0.8);
}
}
/* Context menu */
.blocklyContextMenu {
background-color: var(--theme-color-bg-3) !important;
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
.blocklyContextMenu .blocklyMenuItem {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
padding: 6px 12px !important;
&:hover,
&:hover * {
background-color: var(--theme-color-bg-4) !important;
color: var(--theme-color-fg-default) !important;
}
&.blocklyMenuItemDisabled {
color: var(--theme-color-fg-default-shy) !important;
opacity: 0.5;
}
}
/* Scrollbars */
.blocklyScrollbarHandle {
fill: var(--theme-color-border-default) !important;
}
/* Field editor backgrounds (dropdowns, text inputs, etc.) */
/* NOTE: blocklyWidgetDiv and blocklyDropDownDiv are rendered at document root! */
.blocklyWidgetDiv,
.blocklyDropDownDiv {
z-index: 10000 !important; /* Ensure it's above everything */
}
/* Blockly dropdown container - DARK BACKGROUND */
.blocklyDropDownDiv,
:global(.blocklyDropDownDiv) {
background-color: var(--theme-color-bg-3) !important; /* DARK background */
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
/* Inner scrollable container */
& > div {
background-color: var(--theme-color-bg-3) !important;
max-height: 400px !important;
overflow-y: auto !important;
}
/* SVG containers inside dropdown */
& svg {
background-color: var(--theme-color-bg-3) !important;
}
}
/* Text input fields */
.blocklyWidgetDiv input,
.blocklyHtmlInput {
background-color: var(--theme-color-bg-3) !important;
color: var(--theme-color-fg-default) !important;
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-family: var(--theme-font-family) !important;
}
/* Dropdown menus - DARK BACKGROUND with WHITE TEXT (matches Noodl theme) */
/* Target ACTUAL Blockly classes: .blocklyMenuItem not .goog-menuitem */
.goog-menu,
:global(.goog-menu) {
background-color: var(--theme-color-bg-3) !important; /* DARK background */
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
z-index: 10001 !important;
}
/* Target Blockly's ACTUAL menu item class - DROPDOWN MENUS */
.blocklyDropDownDiv .blocklyMenuItem,
:global(.blocklyDropDownDiv) :global(.blocklyMenuItem) {
color: #ffffff !important; /* WHITE text */
background-color: transparent !important;
padding: 6px 12px !important;
cursor: pointer !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
/* ALL children white */
& *,
& div,
& span {
color: #ffffff !important;
}
/* HOVER - Keep white text with lighter background */
&:hover,
&:hover *,
&:hover div,
&:hover span {
background-color: var(--theme-color-bg-4) !important;
color: #ffffff !important; /* WHITE on hover */
}
&[aria-selected='true'],
&[aria-selected='true'] * {
background-color: var(--theme-color-primary) !important;
color: #ffffff !important;
}
&[aria-disabled='true'],
&[aria-disabled='true'] * {
color: #999999 !important;
opacity: 0.5;
cursor: not-allowed !important;
}
}
/* Target Blockly's ACTUAL content class */
.blocklyMenuItemContent,
:global(.blocklyMenuItemContent) {
color: #ffffff !important;
& *,
& div,
& span {
color: #ffffff !important;
}
}
/* Fallback for goog- classes if they exist */
.goog-menuitem,
.goog-option,
:global(.goog-menuitem),
:global(.goog-option) {
color: #ffffff !important;
background-color: transparent !important;
padding: 6px 12px !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
& *,
& div,
& span {
color: #ffffff !important;
}
&:hover,
&:hover * {
background-color: var(--theme-color-bg-4) !important;
}
}
.goog-menuitem-content,
:global(.goog-menuitem-content) {
color: #ffffff !important;
& * {
color: #ffffff !important;
}
}
/* Blockly dropdown content container */
:global(.blocklyDropDownContent) {
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}
}

View File

@@ -0,0 +1,263 @@
/**
* BlocklyWorkspace Component
*
* React wrapper for Google Blockly visual programming workspace.
* Provides integration with Noodl's node system for visual logic building.
*
* @module BlocklyEditor
*/
import DarkTheme from '@blockly/theme-dark';
import * as Blockly from 'blockly';
import { javascriptGenerator } from 'blockly/javascript';
import React, { useEffect, useRef } from 'react';
import css from './BlocklyWorkspace.module.scss';
import { initBlocklyIntegration } from './index';
export interface BlocklyWorkspaceProps {
/** Initial workspace JSON (for loading saved state) */
initialWorkspace?: string;
/** Toolbox configuration */
toolbox?: Blockly.utils.toolbox.ToolboxDefinition;
/** Callback when workspace changes */
onChange?: (workspace: Blockly.WorkspaceSvg, json: string, code: string) => void;
/** Read-only mode */
readOnly?: boolean;
/** Custom theme */
theme?: Blockly.Theme;
}
/**
* BlocklyWorkspace - React component for Blockly integration
*
* Handles:
* - Blockly workspace initialization
* - Workspace persistence (save/load)
* - Change detection and callbacks
* - Cleanup on unmount
*/
export function BlocklyWorkspace({
initialWorkspace,
toolbox,
onChange,
readOnly = false,
theme
}: BlocklyWorkspaceProps) {
const blocklyDiv = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const changeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Initialize Blockly workspace
useEffect(() => {
if (!blocklyDiv.current) return;
// Initialize custom Noodl blocks and generators before creating workspace
initBlocklyIntegration();
console.log('🔧 [Blockly] Initializing workspace');
// Inject Blockly with dark theme
const workspace = Blockly.inject(blocklyDiv.current, {
toolbox: toolbox || getDefaultToolbox(),
theme: theme || DarkTheme,
readOnly: readOnly,
trashcan: true,
zoom: {
controls: true,
wheel: true,
startScale: 1.0,
maxScale: 3,
minScale: 0.3,
scaleSpeed: 1.2
},
grid: {
spacing: 20,
length: 3,
colour: '#ccc',
snap: true
}
});
workspaceRef.current = workspace;
// Load initial workspace if provided
if (initialWorkspace) {
try {
const json = JSON.parse(initialWorkspace);
Blockly.serialization.workspaces.load(json, workspace);
console.log('✅ [Blockly] Loaded initial workspace');
} catch (error) {
console.error('❌ [Blockly] Failed to load initial workspace:', error);
}
}
// Listen for changes - filter to only respond to finished workspace changes,
// not UI events like dragging or moving blocks
const changeListener = (event: Blockly.Events.Abstract) => {
if (!onChange || !workspace) return;
// Ignore UI events that don't change the workspace structure
// These fire constantly during drags and can cause state corruption
if (event.type === Blockly.Events.BLOCK_DRAG) return;
if (event.type === Blockly.Events.BLOCK_MOVE && !event.isUiEvent) return; // Allow programmatic moves
if (event.type === Blockly.Events.SELECTED) return;
if (event.type === Blockly.Events.CLICK) return;
if (event.type === Blockly.Events.VIEWPORT_CHANGE) return;
if (event.type === Blockly.Events.TOOLBOX_ITEM_SELECT) return;
if (event.type === Blockly.Events.THEME_CHANGE) return;
if (event.type === Blockly.Events.TRASHCAN_OPEN) return;
// For UI events that DO change the workspace, debounce them
const isUiEvent = event.isUiEvent;
if (isUiEvent) {
// Clear any pending timeout for UI events
if (changeTimeoutRef.current) {
clearTimeout(changeTimeoutRef.current);
}
// Debounce UI-initiated changes (user editing)
changeTimeoutRef.current = setTimeout(() => {
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
const code = javascriptGenerator.workspaceToCode(workspace);
console.log('[Blockly] Generated code:', code);
onChange(workspace, json, code);
}, 300);
} else {
// Programmatic changes fire immediately (e.g., undo/redo, loading)
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
const code = javascriptGenerator.workspaceToCode(workspace);
console.log('[Blockly] Generated code:', code);
onChange(workspace, json, code);
}
};
workspace.addChangeListener(changeListener);
// Cleanup
return () => {
console.log('🧹 [Blockly] Disposing workspace');
// Clear any pending debounced calls
if (changeTimeoutRef.current) {
clearTimeout(changeTimeoutRef.current);
}
workspace.removeChangeListener(changeListener);
workspace.dispose();
workspaceRef.current = null;
};
}, [toolbox, theme, readOnly]);
// NOTE: Do NOT reload workspace on initialWorkspace changes!
// The initialWorkspace prop changes on every save, which would cause corruption.
// Workspace is loaded ONCE on mount above, and changes are saved via onChange callback.
return (
<div className={css.Root}>
<div ref={blocklyDiv} className={css.BlocklyContainer} />
</div>
);
}
/**
* Default toolbox with Noodl-specific blocks
*/
function getDefaultToolbox(): Blockly.utils.toolbox.ToolboxDefinition {
return {
kind: 'categoryToolbox',
contents: [
// Noodl I/O Category
{
kind: 'category',
name: 'Noodl Inputs/Outputs',
colour: '230',
contents: [
{ kind: 'block', type: 'noodl_define_input' },
{ kind: 'block', type: 'noodl_get_input' },
{ kind: 'block', type: 'noodl_define_output' },
{ kind: 'block', type: 'noodl_set_output' }
]
},
// Noodl Signals Category
{
kind: 'category',
name: 'Noodl Signals',
colour: '180',
contents: [
{ kind: 'block', type: 'noodl_define_signal_input' },
{ kind: 'block', type: 'noodl_define_signal_output' },
{ kind: 'block', type: 'noodl_send_signal' }
]
},
// Noodl Variables Category
{
kind: 'category',
name: 'Noodl Variables',
colour: '330',
contents: [
{ kind: 'block', type: 'noodl_get_variable' },
{ kind: 'block', type: 'noodl_set_variable' }
]
},
// Noodl Objects Category
{
kind: 'category',
name: 'Noodl Objects',
colour: '20',
contents: [
{ kind: 'block', type: 'noodl_get_object' },
{ kind: 'block', type: 'noodl_get_object_property' },
{ kind: 'block', type: 'noodl_set_object_property' }
]
},
// Noodl Arrays Category
{
kind: 'category',
name: 'Noodl Arrays',
colour: '260',
contents: [
{ kind: 'block', type: 'noodl_get_array' },
{ kind: 'block', type: 'noodl_array_length' },
{ kind: 'block', type: 'noodl_array_add' }
]
},
// Standard Logic blocks (useful for conditionals)
{
kind: 'category',
name: 'Logic',
colour: '210',
contents: [
{ kind: 'block', type: 'controls_if' },
{ kind: 'block', type: 'logic_compare' },
{ kind: 'block', type: 'logic_operation' },
{ kind: 'block', type: 'logic_negate' },
{ kind: 'block', type: 'logic_boolean' }
]
},
// Standard Math blocks
{
kind: 'category',
name: 'Math',
colour: '230',
contents: [
{ kind: 'block', type: 'math_number' },
{ kind: 'block', type: 'math_arithmetic' },
{ kind: 'block', type: 'math_single' }
]
},
// Standard Text blocks
{
kind: 'category',
name: 'Text',
colour: '160',
contents: [
{ kind: 'block', type: 'text' },
{ kind: 'block', type: 'text_join' },
{ kind: 'block', type: 'text_length' }
]
}
]
};
}

View File

@@ -0,0 +1,283 @@
/**
* Noodl Custom Blocks for Blockly
*
* Defines custom blocks for Noodl-specific functionality:
* - Inputs/Outputs (node I/O)
* - Variables (Noodl.Variables)
* - Objects (Noodl.Objects)
* - Arrays (Noodl.Arrays)
* - Events/Signals
*
* @module BlocklyEditor
*/
import * as Blockly from 'blockly';
/**
* Initialize all Noodl custom blocks
*/
export function initNoodlBlocks() {
console.log('🔧 [Blockly] Initializing Noodl custom blocks');
// Input/Output blocks
defineInputOutputBlocks();
// Variable blocks
defineVariableBlocks();
// Object blocks (basic - will expand later)
defineObjectBlocks();
// Array blocks (basic - will expand later)
defineArrayBlocks();
console.log('✅ [Blockly] Noodl blocks initialized');
}
/**
* Input/Output Blocks
*/
function defineInputOutputBlocks() {
// Define Input block - declares an input port
Blockly.Blocks['noodl_define_input'] = {
init: function () {
this.appendDummyInput()
.appendField('📥 Define input')
.appendField(new Blockly.FieldTextInput('myInput'), 'NAME')
.appendField('type')
.appendField(
new Blockly.FieldDropdown([
['any', '*'],
['string', 'string'],
['number', 'number'],
['boolean', 'boolean'],
['object', 'object'],
['array', 'array']
]),
'TYPE'
);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Defines an input port that appears on the node');
this.setHelpUrl('');
}
};
// Get Input block - gets value from an input
Blockly.Blocks['noodl_get_input'] = {
init: function () {
this.appendDummyInput().appendField('📥 get input').appendField(new Blockly.FieldTextInput('value'), 'NAME');
this.setOutput(true, null);
this.setColour(230);
this.setTooltip('Gets the value from an input port');
this.setHelpUrl('');
}
};
// Define Output block - declares an output port
Blockly.Blocks['noodl_define_output'] = {
init: function () {
this.appendDummyInput()
.appendField('📤 Define output')
.appendField(new Blockly.FieldTextInput('result'), 'NAME')
.appendField('type')
.appendField(
new Blockly.FieldDropdown([
['any', '*'],
['string', 'string'],
['number', 'number'],
['boolean', 'boolean'],
['object', 'object'],
['array', 'array']
]),
'TYPE'
);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Defines an output port that appears on the node');
this.setHelpUrl('');
}
};
// Set Output block - sets value on an output
Blockly.Blocks['noodl_set_output'] = {
init: function () {
this.appendValueInput('VALUE')
.setCheck(null)
.appendField('📤 set output')
.appendField(new Blockly.FieldTextInput('result'), 'NAME')
.appendField('to');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Sets the value of an output port');
this.setHelpUrl('');
}
};
// Define Signal Input block
Blockly.Blocks['noodl_define_signal_input'] = {
init: function () {
this.appendDummyInput()
.appendField('⚡ Define signal input')
.appendField(new Blockly.FieldTextInput('trigger'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Defines a signal input that can trigger logic');
this.setHelpUrl('');
}
};
// Define Signal Output block
Blockly.Blocks['noodl_define_signal_output'] = {
init: function () {
this.appendDummyInput()
.appendField('⚡ Define signal output')
.appendField(new Blockly.FieldTextInput('done'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Defines a signal output that can trigger other nodes');
this.setHelpUrl('');
}
};
// Send Signal block
Blockly.Blocks['noodl_send_signal'] = {
init: function () {
this.appendDummyInput().appendField('⚡ send signal').appendField(new Blockly.FieldTextInput('done'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Sends a signal to connected nodes');
this.setHelpUrl('');
}
};
}
/**
* Variable Blocks
*/
function defineVariableBlocks() {
// Get Variable block
Blockly.Blocks['noodl_get_variable'] = {
init: function () {
this.appendDummyInput()
.appendField('📖 get variable')
.appendField(new Blockly.FieldTextInput('myVariable'), 'NAME');
this.setOutput(true, null);
this.setColour(330);
this.setTooltip('Gets the value of a global Noodl variable');
this.setHelpUrl('');
}
};
// Set Variable block
Blockly.Blocks['noodl_set_variable'] = {
init: function () {
this.appendValueInput('VALUE')
.setCheck(null)
.appendField('✏️ set variable')
.appendField(new Blockly.FieldTextInput('myVariable'), 'NAME')
.appendField('to');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(330);
this.setTooltip('Sets the value of a global Noodl variable');
this.setHelpUrl('');
}
};
}
/**
* Object Blocks (basic set - will expand in Phase E)
*/
function defineObjectBlocks() {
// Get Object block
Blockly.Blocks['noodl_get_object'] = {
init: function () {
this.appendValueInput('ID').setCheck('String').appendField('📦 get object');
this.setOutput(true, 'Object');
this.setColour(20);
this.setTooltip('Gets a Noodl Object by its ID');
this.setHelpUrl('');
}
};
// Get Object Property block
Blockly.Blocks['noodl_get_object_property'] = {
init: function () {
this.appendValueInput('OBJECT')
.setCheck(null)
.appendField('📖 get')
.appendField(new Blockly.FieldTextInput('name'), 'PROPERTY')
.appendField('from object');
this.setOutput(true, null);
this.setColour(20);
this.setTooltip('Gets a property value from an object');
this.setHelpUrl('');
}
};
// Set Object Property block
Blockly.Blocks['noodl_set_object_property'] = {
init: function () {
this.appendValueInput('OBJECT')
.setCheck(null)
.appendField('✏️ set')
.appendField(new Blockly.FieldTextInput('name'), 'PROPERTY')
.appendField('on object');
this.appendValueInput('VALUE').setCheck(null).appendField('to');
this.setInputsInline(false);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(20);
this.setTooltip('Sets a property value on an object');
this.setHelpUrl('');
}
};
}
/**
* Array Blocks (basic set - will expand in Phase E)
*/
function defineArrayBlocks() {
// Get Array block
Blockly.Blocks['noodl_get_array'] = {
init: function () {
this.appendDummyInput().appendField('📋 get array').appendField(new Blockly.FieldTextInput('myArray'), 'NAME');
this.setOutput(true, 'Array');
this.setColour(260);
this.setTooltip('Gets a Noodl Array by name');
this.setHelpUrl('');
}
};
// Array Length block
Blockly.Blocks['noodl_array_length'] = {
init: function () {
this.appendValueInput('ARRAY').setCheck('Array').appendField('🔢 length of array');
this.setOutput(true, 'Number');
this.setColour(260);
this.setTooltip('Gets the number of items in an array');
this.setHelpUrl('');
}
};
// Array Add block
Blockly.Blocks['noodl_array_add'] = {
init: function () {
this.appendValueInput('ITEM').setCheck(null).appendField(' add');
this.appendValueInput('ARRAY').setCheck('Array').appendField('to array');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(260);
this.setTooltip('Adds an item to the end of an array');
this.setHelpUrl('');
}
};
}

View File

@@ -0,0 +1,165 @@
/**
* Noodl Code Generators for Blockly
*
* Converts Blockly blocks into executable JavaScript code for the Noodl runtime.
* Generated code has access to:
* - Inputs: Input values from connections
* - Outputs: Output values to connections
* - Noodl.Variables: Global variables
* - Noodl.Objects: Global objects
* - Noodl.Arrays: Global arrays
*
* @module BlocklyEditor
*/
import * as Blockly from 'blockly';
import { javascriptGenerator, Order } from 'blockly/javascript';
/**
* Initialize all Noodl code generators
*/
export function initNoodlGenerators() {
console.log('🔧 [Blockly] Initializing Noodl code generators');
// Input/Output generators
initInputOutputGenerators();
// Variable generators
initVariableGenerators();
// Object generators
initObjectGenerators();
// Array generators
initArrayGenerators();
console.log('✅ [Blockly] Noodl generators initialized');
}
/**
* Input/Output Generators
*/
function initInputOutputGenerators() {
// Define Input - no runtime code (used for I/O detection only)
javascriptGenerator.forBlock['noodl_define_input'] = function () {
return '';
};
// Get Input - generates: Inputs["name"]
javascriptGenerator.forBlock['noodl_get_input'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Inputs["${name}"]`;
return [code, Order.MEMBER];
};
// Define Output - no runtime code (used for I/O detection only)
javascriptGenerator.forBlock['noodl_define_output'] = function () {
return '';
};
// Set Output - generates: Outputs["name"] = value;
javascriptGenerator.forBlock['noodl_set_output'] = function (block) {
const name = block.getFieldValue('NAME');
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `Outputs["${name}"] = ${value};\n`;
};
// Define Signal Input - no runtime code
javascriptGenerator.forBlock['noodl_define_signal_input'] = function () {
return '';
};
// Define Signal Output - no runtime code
javascriptGenerator.forBlock['noodl_define_signal_output'] = function () {
return '';
};
// Send Signal - generates: this.sendSignalOnOutput("name");
javascriptGenerator.forBlock['noodl_send_signal'] = function (block) {
const name = block.getFieldValue('NAME');
return `this.sendSignalOnOutput("${name}");\n`;
};
}
/**
* Variable Generators
*/
function initVariableGenerators() {
// Get Variable - generates: Noodl.Variables["name"]
javascriptGenerator.forBlock['noodl_get_variable'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Noodl.Variables["${name}"]`;
return [code, Order.MEMBER];
};
// Set Variable - generates: Noodl.Variables["name"] = value;
javascriptGenerator.forBlock['noodl_set_variable'] = function (block) {
const name = block.getFieldValue('NAME');
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `Noodl.Variables["${name}"] = ${value};\n`;
};
}
/**
* Object Generators
*/
function initObjectGenerators() {
// Get Object - generates: Noodl.Objects[id]
javascriptGenerator.forBlock['noodl_get_object'] = function (block) {
const id = javascriptGenerator.valueToCode(block, 'ID', Order.NONE) || '""';
const code = `Noodl.Objects[${id}]`;
return [code, Order.MEMBER];
};
// Get Object Property - generates: object["property"]
javascriptGenerator.forBlock['noodl_get_object_property'] = function (block) {
const property = block.getFieldValue('PROPERTY');
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Order.MEMBER) || '{}';
const code = `${object}["${property}"]`;
return [code, Order.MEMBER];
};
// Set Object Property - generates: object["property"] = value;
javascriptGenerator.forBlock['noodl_set_object_property'] = function (block) {
const property = block.getFieldValue('PROPERTY');
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Order.MEMBER) || '{}';
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `${object}["${property}"] = ${value};\n`;
};
}
/**
* Array Generators
*/
function initArrayGenerators() {
// Get Array - generates: Noodl.Arrays["name"]
javascriptGenerator.forBlock['noodl_get_array'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Noodl.Arrays["${name}"]`;
return [code, Order.MEMBER];
};
// Array Length - generates: array.length
javascriptGenerator.forBlock['noodl_array_length'] = function (block) {
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Order.MEMBER) || '[]';
const code = `${array}.length`;
return [code, Order.MEMBER];
};
// Array Add - generates: array.push(item);
javascriptGenerator.forBlock['noodl_array_add'] = function (block) {
const item = javascriptGenerator.valueToCode(block, 'ITEM', Order.NONE) || 'null';
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Order.MEMBER) || '[]';
return `${array}.push(${item});\n`;
};
}
/**
* Generate complete JavaScript code from workspace
*
* @param workspace - The Blockly workspace
* @returns Generated JavaScript code
*/
export function generateCode(workspace: Blockly.WorkspaceSvg): string {
return javascriptGenerator.workspaceToCode(workspace);
}

View File

@@ -0,0 +1,49 @@
/**
* BlocklyEditor Module
*
* Entry point for Blockly integration in Noodl.
* Exports components, blocks, and generators for visual logic building.
*
* @module BlocklyEditor
*/
import { initNoodlBlocks } from './NoodlBlocks';
import { initNoodlGenerators } from './NoodlGenerators';
// Initialize globals (IODetector, code generation)
import '../../utils/BlocklyEditorGlobals';
// Main component
export { BlocklyWorkspace } from './BlocklyWorkspace';
export type { BlocklyWorkspaceProps } from './BlocklyWorkspace';
// Block definitions and generators
export { initNoodlBlocks } from './NoodlBlocks';
export { initNoodlGenerators, generateCode } from './NoodlGenerators';
// Track initialization to prevent double-registration
let blocklyInitialized = false;
/**
* Initialize all Noodl Blockly extensions
* Call this once at app startup before using Blockly components
* Safe to call multiple times - will only initialize once
*/
export function initBlocklyIntegration() {
if (blocklyInitialized) {
console.log('⏭️ [Blockly] Already initialized, skipping');
return;
}
console.log('🔧 [Blockly] Initializing Noodl Blockly integration');
// Initialize custom blocks
initNoodlBlocks();
// Initialize code generators
initNoodlGenerators();
// Note: BlocklyEditorGlobals auto-initializes via side-effect import above
blocklyInitialized = true;
console.log('✅ [Blockly] Integration initialized');
}

View File

@@ -0,0 +1,105 @@
/**
* Canvas Tabs Styling
*
* Theme-aware styling for canvas/Blockly tab system
*/
.CanvasTabs {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
pointer-events: all; /* Enable clicks on tabs */
}
.TabBar {
display: flex;
gap: var(--spacing-1);
padding: var(--spacing-2);
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
flex-shrink: 0;
}
.Tab {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border-radius: var(--radius-default) var(--radius-default) 0 0;
cursor: pointer;
user-select: none;
transition: background-color 0.15s ease;
border: 1px solid transparent;
border-bottom: none;
&:hover {
background-color: var(--theme-color-bg-4);
}
&.isActive {
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-highlight);
border-color: var(--theme-color-border-default);
border-bottom-color: var(--theme-color-bg-1); // Hide bottom border
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary);
outline-offset: -2px;
}
}
.TabLabel {
font-size: var(--theme-font-size-default);
font-weight: 500;
white-space: nowrap;
}
.TabCloseButton {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
color: var(--theme-color-fg-default-shy);
font-size: 18px;
line-height: 1;
cursor: pointer;
border-radius: var(--radius-s);
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-highlight);
}
&:active {
background-color: var(--theme-color-bg-4);
}
}
.TabContent {
flex: 1;
overflow: hidden;
position: relative;
background-color: var(--theme-color-bg-1);
}
.CanvasContainer {
width: 100%;
height: 100%;
position: relative;
}
.BlocklyContainer {
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--theme-color-bg-1);
}

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { useCanvasTabs } from '../../contexts/CanvasTabsContext';
import { BlocklyWorkspace } from '../BlocklyEditor';
import css from './CanvasTabs.module.scss';
export interface CanvasTabsProps {
/** Callback when workspace changes */
onWorkspaceChange?: (nodeId: string, workspace: string, code: string) => void;
}
/**
* Canvas Tabs Component
*
* Manages tabs for Logic Builder (Blockly) editors.
* The canvas itself is NOT managed here - it's always visible in the background
* unless a Logic Builder tab is open.
*/
export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
const { tabs, activeTabId, switchTab, closeTab, updateTab } = useCanvasTabs();
const activeTab = tabs.find((t) => t.id === activeTabId);
/**
* Handle workspace changes from Blockly editor
*/
const handleWorkspaceChange = (_workspaceSvg: unknown, json: string, code: string) => {
if (!activeTab) {
return;
}
// Update tab's workspace with JSON
updateTab(activeTab.id, { workspace: json });
// Notify parent (pass both workspace JSON and generated code)
if (onWorkspaceChange && activeTab.nodeId) {
onWorkspaceChange(activeTab.nodeId, json, code);
}
};
/**
* Handle tab click
*/
const handleTabClick = (tabId: string) => {
switchTab(tabId);
};
/**
* Handle tab close
*/
const handleTabClose = (e: React.MouseEvent, tabId: string) => {
e.stopPropagation(); // Don't trigger tab switch
closeTab(tabId);
};
// Don't render anything if no tabs are open
if (tabs.length === 0) {
return null;
}
return (
<div className={css['CanvasTabs']}>
{/* Tab Bar */}
<div className={css['TabBar']}>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={`${css['Tab']} ${isActive ? css['isActive'] : ''}`}
onClick={() => handleTabClick(tab.id)}
role="tab"
aria-selected={isActive}
tabIndex={0}
>
<span className={css['TabLabel']}>Logic Builder: {tab.nodeName || 'Unnamed'}</span>
<button
className={css['TabCloseButton']}
onClick={(e) => handleTabClose(e, tab.id)}
aria-label="Close tab"
title="Close tab"
>
×
</button>
</div>
);
})}
</div>
{/* Tab Content */}
<div className={css['TabContent']}>
{activeTab && (
<div className={css['BlocklyContainer']}>
<BlocklyWorkspace initialWorkspace={activeTab.workspace || undefined} onChange={handleWorkspaceChange} />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
/**
* CanvasTabs Module
*
* Exports canvas tab system components
*/
export { CanvasTabs } from './CanvasTabs';
export type { CanvasTabsProps } from './CanvasTabs';

View File

@@ -24,6 +24,7 @@ import { PopupToolbar, PopupToolbarProps } from '@noodl-core-ui/components/popup
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
import View from '../../../shared/view';
import { CanvasTabsProvider } from '../contexts/CanvasTabsContext';
import { ComponentModel } from '../models/componentmodel';
import {
Connection,
@@ -36,10 +37,13 @@ import { NodeLibrary } from '../models/nodelibrary';
import { ProjectModel } from '../models/projectmodel';
import { WarningsModel } from '../models/warningsmodel';
import { HighlightManager } from '../services/HighlightManager';
// Initialize Blockly globals early (must run before runtime nodes load)
import { initBlocklyEditorGlobals } from '../utils/BlocklyEditorGlobals';
import DebugInspector from '../utils/debuginspector';
import { rectanglesOverlap, guid } from '../utils/utils';
import { ViewerConnection } from '../ViewerConnection';
import { HighlightOverlay } from './CanvasOverlays/HighlightOverlay';
import { CanvasTabs } from './CanvasTabs';
import CommentLayer from './commentlayer';
// Import test utilities for console debugging (dev only)
import '../services/HighlightManager/test-highlights';
@@ -57,6 +61,8 @@ import PopupLayer from './popuplayer';
import { showContextMenuInPopup } from './ShowContextMenuInPopup';
import { ToastLayer } from './ToastLayer/ToastLayer';
initBlocklyEditorGlobals();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const NodeGraphEditorTemplate = require('../templates/nodegrapheditor.html');
@@ -234,6 +240,7 @@ export class NodeGraphEditor extends View {
toolbarRoots: Root[] = [];
titleRoot: Root = null;
highlightOverlayRoot: Root = null;
canvasTabsRoot: Root = null;
constructor(args) {
super();
@@ -297,6 +304,36 @@ export class NodeGraphEditor extends View {
this
);
// Listen for Logic Builder tab opened - hide canvas
EventDispatcher.instance.on(
'LogicBuilder.TabOpened',
() => {
console.log('[NodeGraphEditor] Logic Builder tab opened - hiding canvas');
this.setCanvasVisibility(false);
},
this
);
// Listen for all Logic Builder tabs closed - show canvas
EventDispatcher.instance.on(
'LogicBuilder.AllTabsClosed',
() => {
console.log('[NodeGraphEditor] All Logic Builder tabs closed - showing canvas');
this.setCanvasVisibility(true);
},
this
);
// Listen for Logic Builder tab open requests (for opening tabs from property panel)
EventDispatcher.instance.on(
'LogicBuilder.OpenTab',
(args: { nodeId: string; nodeName: string; workspace: string }) => {
console.log('[NodeGraphEditor] Opening Logic Builder tab for node:', args.nodeId);
// The CanvasTabs context will handle the actual tab opening
},
this
);
if (import.meta.webpackHot) {
import.meta.webpackHot.accept('./createnewnodepanel');
}
@@ -411,6 +448,11 @@ export class NodeGraphEditor extends View {
this.highlightOverlayRoot = null;
}
if (this.canvasTabsRoot) {
this.canvasTabsRoot.unmount();
this.canvasTabsRoot = null;
}
SidebarModel.instance.off(this);
this.reset();
@@ -881,12 +923,66 @@ export class NodeGraphEditor extends View {
this.renderHighlightOverlay();
}, 1);
// Render the canvas tabs
setTimeout(() => {
this.renderCanvasTabs();
}, 1);
this.relayout();
this.repaint();
return this.el;
}
/**
* Render the CanvasTabs React component
*/
renderCanvasTabs() {
const tabsElement = this.el.find('#canvas-tabs-root').get(0);
if (!tabsElement) {
console.warn('Canvas tabs root not found in DOM');
return;
}
// Create React root if it doesn't exist
if (!this.canvasTabsRoot) {
this.canvasTabsRoot = createRoot(tabsElement);
}
// Render the tabs with provider
this.canvasTabsRoot.render(
React.createElement(
CanvasTabsProvider,
null,
React.createElement(CanvasTabs, {
onWorkspaceChange: this.handleBlocklyWorkspaceChange.bind(this)
})
)
);
}
/**
* Handle workspace changes from Blockly editor
*/
handleBlocklyWorkspaceChange(nodeId: string, workspace: string, code: string) {
console.log(`[NodeGraphEditor] Workspace changed for node ${nodeId}`);
const node = this.findNodeWithId(nodeId);
if (!node) {
console.warn(`[NodeGraphEditor] Node ${nodeId} not found`);
return;
}
// Save workspace JSON to node model
node.model.setParameter('workspace', workspace);
// Save generated JavaScript code to node model
// This triggers the runtime's parameterUpdated listener which calls updatePorts()
node.model.setParameter('generatedCode', code);
console.log(`[NodeGraphEditor] Saved workspace and generated code for node ${nodeId}`);
}
/**
* Get node bounds for the highlight overlay
* Maps node IDs to their screen coordinates
@@ -945,6 +1041,35 @@ export class NodeGraphEditor extends View {
}
}
/**
* Set canvas visibility (hide when Logic Builder is open, show when closed)
*/
setCanvasVisibility(visible: boolean) {
const canvasElement = this.el.find('#nodegraphcanvas');
const commentLayerBg = this.el.find('#comment-layer-bg');
const commentLayerFg = this.el.find('#comment-layer-fg');
const highlightOverlay = this.el.find('#highlight-overlay-layer');
const componentTrail = this.el.find('.nodegraph-component-trail-root');
if (visible) {
// Show canvas and related elements
canvasElement.css('display', 'block');
commentLayerBg.css('display', 'block');
commentLayerFg.css('display', 'block');
highlightOverlay.css('display', 'block');
componentTrail.css('display', 'flex');
this.domElementContainer.style.display = '';
} else {
// Hide canvas and related elements
canvasElement.css('display', 'none');
commentLayerBg.css('display', 'none');
commentLayerFg.css('display', 'none');
highlightOverlay.css('display', 'none');
componentTrail.css('display', 'none');
this.domElementContainer.style.display = 'none';
}
}
// This is called by the parent view (frames view) when the size and position
// changes
resize(layout) {

View File

@@ -25,13 +25,22 @@ function measureTextHeight(text, font, lineHeight, maxWidth) {
ctx.font = font;
ctx.textBaseline = 'top';
return textWordWrap(ctx, text, 0, 0, lineHeight, maxWidth);
// Defensive: convert to string (handles expression objects, numbers, etc.)
const textString = typeof text === 'string' ? text : String(text || '');
return textWordWrap(ctx, textString, 0, 0, lineHeight, maxWidth);
}
function textWordWrap(context, text, x, y, lineHeight, maxWidth, cb?) {
if (!text) return;
// Defensive: ensure we have a string
const textString = typeof text === 'string' ? text : String(text || '');
let words = text.split(' ');
// Empty string still has height (return lineHeight, not undefined)
if (!textString) {
return lineHeight;
}
let words = textString.split(' ');
let currentLine = 0;
let idx = 1;
while (words.length > 0 && idx <= words.length) {

View File

@@ -1,10 +1,14 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { GitProvider } from '@noodl/git';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextButton } from '@noodl-core-ui/components/inputs/TextButton';
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { GitHubAuth, type GitHubAuthState } from '../../../../../../services/github';
type CredentialsSectionProps = {
provider: GitProvider;
username: string;
@@ -25,39 +29,120 @@ export function CredentialsSection({
const [hidePassword, setHidePassword] = useState(true);
// OAuth state management
const [authState, setAuthState] = useState<GitHubAuthState>(GitHubAuth.getAuthState());
const [isConnecting, setIsConnecting] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const [error, setError] = useState<string | null>(null);
// Check auth state on mount
useEffect(() => {
if (provider === 'github') {
setAuthState(GitHubAuth.getAuthState());
}
}, [provider]);
const handleConnect = async () => {
setIsConnecting(true);
setError(null);
setProgressMessage('Initiating GitHub authentication...');
try {
await GitHubAuth.startWebOAuthFlow((message) => {
setProgressMessage(message);
});
// Update state after successful auth
setAuthState(GitHubAuth.getAuthState());
setProgressMessage('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
setProgressMessage('');
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
GitHubAuth.disconnect();
setAuthState(GitHubAuth.getAuthState());
setError(null);
};
return (
<Section title={getTitle(provider)} variant={SectionVariant.InModal} hasGutter>
{showUsername && (
<>
{/* OAuth Section - GitHub Only */}
{provider === 'github' && (
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
{authState.isAuthenticated ? (
// Connected state
<>
<Text hasBottomSpacing>
Connected as <strong>{authState.username}</strong>
</Text>
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />
</>
) : (
// Not connected state
<>
<Text hasBottomSpacing>
Connect your GitHub account for the best experience. This enables advanced features and is more secure
than Personal Access Tokens.
</Text>
{isConnecting && progressMessage && <Text hasBottomSpacing>{progressMessage}</Text>}
{error && <Text hasBottomSpacing>{error}</Text>}
<PrimaryButton
label={isConnecting ? 'Connecting...' : 'Connect GitHub Account'}
onClick={handleConnect}
isDisabled={isConnecting}
/>
</>
)}
</Section>
)}
{/* PAT Section - Existing, now as fallback for GitHub */}
<Section
title={provider === 'github' ? 'Or use Personal Access Token' : getTitle(provider)}
variant={SectionVariant.InModal}
hasGutter
>
{showUsername && (
<TextInput
hasBottomSpacing
label="Username"
value={username}
variant={TextInputVariant.InModal}
onChange={(ev) => onUserNameChanged(ev.target.value)}
/>
)}
<TextInput
hasBottomSpacing
label="Username"
value={username}
label={passwordLabel}
type={hidePassword ? 'password' : 'text'}
value={password}
variant={TextInputVariant.InModal}
onChange={(ev) => onUserNameChanged(ev.target.value)}
onChange={(ev) => onPasswordChanged(ev.target.value)}
onFocus={() => setHidePassword(false)}
onBlur={() => setHidePassword(true)}
/>
)}
<TextInput
hasBottomSpacing
label={passwordLabel}
type={hidePassword ? 'password' : 'text'}
value={password}
variant={TextInputVariant.InModal}
onChange={(ev) => onPasswordChanged(ev.target.value)}
onFocus={() => setHidePassword(false)}
onBlur={() => setHidePassword(true)}
/>
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
{provider === 'github' && !password?.length && (
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
target="_blank"
rel="noreferrer"
>
How to create a personal access token
</a>
)}
</Section>
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
{provider === 'github' && !password?.length && (
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
target="_blank"
rel="noreferrer"
>
How to create a personal access token
</a>
)}
</Section>
</>
);
}

View File

@@ -2,10 +2,13 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { CodeHistoryManager } from '@noodl-models/CodeHistoryManager';
import { WarningsModel } from '@noodl-models/warningsmodel';
import { createModel } from '@noodl-utils/CodeEditor';
import { EditorModel } from '@noodl-utils/CodeEditor/model/editorModel';
import { JavaScriptEditor, type ValidationType } from '@noodl-core-ui/components/code-editor';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
import { CodeEditorProps } from './CodeEditor';
@@ -204,19 +207,32 @@ export class CodeEditorType extends TypeView {
this.parent.hidePopout();
WarningsModel.instance.off(this);
WarningsModel.instance.on(
'warningsChanged',
function () {
_this.updateWarnings();
},
this
);
// Always use new JavaScriptEditor for JavaScript/TypeScript
const isJavaScriptEditor = this.type.codeeditor === 'javascript' || this.type.codeeditor === 'typescript';
// Only set up Monaco warnings for Monaco-based editors
if (!isJavaScriptEditor) {
WarningsModel.instance.off(this);
WarningsModel.instance.on(
'warningsChanged',
function () {
_this.updateWarnings();
},
this
);
}
function save() {
let source = _this.model.getValue();
// For JavaScriptEditor, use this.value (already updated in onChange)
// For Monaco editor, get value from model
let source = isJavaScriptEditor ? _this.value : _this.model.getValue();
if (source === '') source = undefined;
// Save snapshot to history (before updating)
if (source && nodeId) {
CodeHistoryManager.instance.saveSnapshot(nodeId, scope.name, source);
}
_this.value = source;
_this.parent.setParameter(scope.name, source !== _this.default ? source : undefined);
_this.isDefault = source === undefined;
@@ -224,14 +240,17 @@ export class CodeEditorType extends TypeView {
const node = this.parent.model.model;
this.model = createModel(
{
type: this.type.name || this.type,
value: this.value,
codeeditor: this.type.codeeditor?.toLowerCase()
},
node
);
// Only create Monaco model for Monaco-based editors
if (!isJavaScriptEditor) {
this.model = createModel(
{
type: this.type.name || this.type,
value: this.value,
codeeditor: this.type.codeeditor?.toLowerCase()
},
node
);
}
const props: CodeEditorProps = {
nodeId,
@@ -265,11 +284,62 @@ export class CodeEditorType extends TypeView {
y: height
};
} catch (error) {}
} else {
// Default size: Make it wider (60% of viewport width, 70% of height)
const b = document.body.getBoundingClientRect();
props.initialSize = {
x: Math.min(b.width * 0.6, b.width - 200), // 60% width, but leave some margin
y: Math.min(b.height * 0.7, b.height - 200) // 70% height
};
}
this.popoutDiv = document.createElement('div');
this.popoutRoot = createRoot(this.popoutDiv);
this.popoutRoot.render(React.createElement(CodeEditor, props));
// Determine which editor to use
if (isJavaScriptEditor) {
console.log('✨ Using JavaScriptEditor for:', this.type.codeeditor);
// Determine validation type based on editor type
let validationType: ValidationType = 'function';
if (this.type.codeeditor === 'javascript') {
// Could be expression or function - check type name for hints
const typeName = (this.type.name || '').toLowerCase();
if (typeName.includes('expression')) {
validationType = 'expression';
} else if (typeName.includes('script')) {
validationType = 'script';
} else {
validationType = 'function';
}
} else if (this.type.codeeditor === 'typescript') {
validationType = 'script';
}
// Render JavaScriptEditor with proper sizing and history support
this.popoutRoot.render(
React.createElement(JavaScriptEditor, {
value: this.value || '',
onChange: (newValue) => {
this.value = newValue;
// Don't update Monaco model - JavaScriptEditor is independent
// The old code triggered Monaco validation which caused errors
},
onSave: () => {
save();
},
validationType,
width: props.initialSize?.x || 800,
height: props.initialSize?.y || 500,
// Add history tracking
nodeId: nodeId,
parameterName: scope.name
})
);
} else {
// Use existing Monaco CodeEditor
this.popoutRoot.render(React.createElement(CodeEditor, props));
}
const popoutDiv = this.popoutDiv;
this.parent.showPopout({
@@ -303,7 +373,11 @@ export class CodeEditorType extends TypeView {
}
});
this.updateWarnings();
// Only update warnings for Monaco-based editors
if (!isJavaScriptEditor) {
this.updateWarnings();
}
evt.stopPropagation();
}
}

View File

@@ -1,4 +1,14 @@
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { isExpressionParameter, createExpressionParameter } from '@noodl-models/ExpressionParameter';
import { NodeLibrary } from '@noodl-models/nodelibrary';
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
import {
PropertyPanelInput,
PropertyPanelInputType
} from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
@@ -7,8 +17,20 @@ function firstType(type) {
return NodeLibrary.nameForPortType(type);
}
function mapTypeToInputType(type: string): PropertyPanelInputType {
switch (type) {
case 'number':
return PropertyPanelInputType.Number;
case 'string':
default:
return PropertyPanelInputType.Text;
}
}
export class BasicType extends TypeView {
el: TSFixme;
private root: Root | null = null;
static fromPort(args) {
const view = new BasicType();
@@ -28,12 +50,125 @@ export class BasicType extends TypeView {
return view;
}
render() {
this.el = this.bindView(this.parent.cloneTemplate(firstType(this.type)), this);
TypeView.prototype.render.call(this);
render() {
// Create container for React component
const div = document.createElement('div');
div.style.width = '100%';
if (!this.root) {
this.root = createRoot(div);
}
this.renderReact();
this.el = div;
return this.el;
}
renderReact() {
if (!this.root) return;
const paramValue = this.parent.model.getParameter(this.name);
const isExprMode = isExpressionParameter(paramValue);
// Get display value - MUST be a primitive, never an object
// Use ParameterValueResolver to defensively handle any value type,
// including expression objects that might slip through during state transitions
const rawValue = isExprMode ? paramValue.fallback : paramValue;
const displayValue = ParameterValueResolver.toString(rawValue);
const props = {
label: this.displayName,
value: displayValue,
inputType: mapTypeToInputType(firstType(this.type)),
properties: undefined, // No special properties needed for basic types
isChanged: !this.isDefault,
isConnected: this.isConnected,
onChange: (value: unknown) => {
// Handle standard value change
if (firstType(this.type) === 'number') {
const numValue = parseFloat(String(value));
this.parent.setParameter(this.name, isNaN(numValue) ? undefined : numValue, {
undo: true,
label: `change ${this.displayName}`
});
} else {
this.parent.setParameter(this.name, value, {
undo: true,
label: `change ${this.displayName}`
});
}
this.isDefault = false;
},
// Expression support
supportsExpression: true,
expressionMode: isExprMode ? ('expression' as const) : ('fixed' as const),
expression: isExprMode ? paramValue.expression : '',
onExpressionModeChange: (mode: 'fixed' | 'expression') => {
const currentParam = this.parent.model.getParameter(this.name);
if (mode === 'expression') {
// Convert to expression parameter
const currentValue = isExpressionParameter(currentParam) ? currentParam.fallback : currentParam;
const exprParam = createExpressionParameter(String(currentValue || ''), currentValue, 1);
this.parent.setParameter(this.name, exprParam, {
undo: true,
label: `enable expression for ${this.displayName}`
});
} else {
// Convert back to fixed value
const fixedValue = isExpressionParameter(currentParam) ? currentParam.fallback : currentParam;
this.parent.setParameter(this.name, fixedValue, {
undo: true,
label: `disable expression for ${this.displayName}`
});
}
this.isDefault = false;
// Re-render to update UI
setTimeout(() => this.renderReact(), 0);
},
onExpressionChange: (expression: string) => {
const currentParam = this.parent.model.getParameter(this.name);
if (isExpressionParameter(currentParam)) {
// Update the expression
this.parent.setParameter(
this.name,
{
...currentParam,
expression
},
{
undo: true,
label: `change ${this.displayName} expression`
}
);
}
this.isDefault = false;
}
};
this.root.render(React.createElement(PropertyPanelInput, props));
}
dispose() {
if (this.root) {
this.root.unmount();
this.root = null;
}
super.dispose();
}
// Legacy method kept for compatibility
onPropertyChanged(scope, el) {
if (firstType(scope.type) === 'number') {
const value = parseFloat(el.val());
@@ -42,7 +177,6 @@ export class BasicType extends TypeView {
this.parent.setParameter(scope.name, el.val());
}
// Update current value and if it is default or not
const current = this.getCurrentValue();
el.val(current.value);
this.isDefault = current.isDefault;

View File

@@ -0,0 +1,113 @@
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
/**
* Custom editor for Logic Builder workspace parameter
* Shows an "Edit Blocks" button that opens the Blockly editor in a tab
*/
export class LogicBuilderWorkspaceType extends TypeView {
el: TSFixme;
editButton: JQuery;
static fromPort(args) {
const view = new LogicBuilderWorkspaceType();
const p = args.port;
const parent = args.parent;
view.port = p;
view.displayName = p.displayName ? p.displayName : p.name;
view.name = p.name;
view.type = getEditType(p);
view.group = p.group;
view.tooltip = p.tooltip;
view.value = parent.model.getParameter(p.name);
view.parent = parent;
view.isConnected = parent.model.isPortConnected(p.name, 'target');
view.isDefault = parent.model.parameters[p.name] === undefined;
return view;
}
render() {
// Create a simple container with a button
const html = `
<div class="property-basic-container logic-builder-workspace-editor" style="display: flex; flex-direction: column; gap: 8px;">
<div class="property-label-container" style="display: flex; align-items: center; gap: 8px;">
<div class="property-changed-dot" data-click="resetToDefault" style="display: none;"></div>
<div class="property-label">${this.displayName}</div>
</div>
<button class="edit-blocks-button"
style="
padding: 8px 16px;
background: var(--theme-color-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
"
onmouseover="this.style.backgroundColor='var(--theme-color-primary-hover)'"
onmouseout="this.style.backgroundColor='var(--theme-color-primary)'">
✨ Edit Logic Blocks
</button>
</div>
`;
this.el = this.bindView($(html), this);
// Get reference to button
this.editButton = this.el.find('.edit-blocks-button');
// Handle button click
this.editButton.on('click', () => {
this.onEditBlocksClicked();
});
// Call parent render for common functionality (tooltips, etc.)
TypeView.prototype.render.call(this);
// Show/hide the "changed" dot based on whether value is default
this.updateChangedDot();
return this.el;
}
onEditBlocksClicked() {
// ModelProxy wraps the actual node model in a .model property
const nodeId = this.parent?.model?.model?.id;
const nodeName = this.parent?.model?.model?.label || this.parent?.model?.type?.displayName || 'Logic Builder';
const workspace = this.parent?.model?.getParameter('workspace') || '';
console.log('[LogicBuilderWorkspaceType] Opening Logic Builder tab for node:', nodeId);
// Emit event to open Logic Builder tab
EventDispatcher.instance.emit('LogicBuilder.OpenTab', {
nodeId,
nodeName,
workspace
});
}
updateChangedDot() {
const dot = this.el.find('.property-changed-dot');
if (this.isDefault) {
dot.hide();
} else {
dot.show();
}
}
resetToDefault() {
// Reset workspace to empty
this.parent.model.setParameter(this.name, undefined, {
undo: true,
label: 'reset workspace'
});
this.isDefault = true;
this.updateChangedDot();
}
}

View File

@@ -21,6 +21,7 @@ import { FontType } from './FontType';
import { IconType } from './IconType';
import { IdentifierType } from './IdentifierType';
import { ImageType } from './ImageType';
import { LogicBuilderWorkspaceType } from './LogicBuilderWorkspaceType';
import { MarginPaddingType } from './MarginPaddingType';
import { NumberWithUnits } from './NumberWithUnits';
import { PopoutGroup } from './PopoutGroup';
@@ -220,6 +221,11 @@ export class Ports extends View {
viewClassForPort(p) {
const type = getEditType(p);
// Check for custom editorType
if (typeof type === 'object' && type.editorType === 'logic-builder-workspace') {
return LogicBuilderWorkspaceType;
}
// Align tools types
function isOfAlignToolsType() {
return NodeLibrary.nameForPortType(type) === 'enum' && typeof type === 'object' && type.alignComp !== undefined;

View File

@@ -5,6 +5,7 @@ import { platform } from '@noodl/platform';
import { Keybindings } from '@noodl-constants/Keybindings';
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import getDocsEndpoint from '@noodl-utils/getDocsEndpoint';
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
import { tracker } from '@noodl-utils/tracker';
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
@@ -22,14 +23,16 @@ export interface NodeLabelProps {
export function NodeLabel({ model, showHelp = true }: NodeLabelProps) {
const labelInputRef = useRef<HTMLInputElement | null>(null);
const [isEditingLabel, setIsEditingLabel] = useState(false);
const [label, setLabel] = useState(model.label);
// Defensive: convert label to string (handles expression parameter objects)
const [label, setLabel] = useState(ParameterValueResolver.toString(model.label));
// Listen for label changes on the model
useEffect(() => {
model.on(
'labelChanged',
() => {
setLabel(model.label);
// Defensive: convert label to string (handles expression parameter objects)
setLabel(ParameterValueResolver.toString(model.label));
},
this
);

View File

@@ -0,0 +1,339 @@
/**
* GitHubOAuthCallbackHandler
*
* Handles GitHub OAuth callback in Electron main process using custom protocol handler.
* This enables Web OAuth Flow with organization/repository selection UI.
*
* @module noodl-editor/main
* @since 1.1.0
*/
const crypto = require('crypto');
const { ipcMain, BrowserWindow } = require('electron');
/**
* GitHub OAuth credentials
* Uses existing credentials from GitHubOAuthService
*/
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui';
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375';
/**
* Custom protocol for OAuth callback
*/
const OAUTH_PROTOCOL = 'noodl';
const OAUTH_CALLBACK_PATH = 'github-callback';
/**
* Manages GitHub OAuth using custom protocol handler
*/
class GitHubOAuthCallbackHandler {
constructor() {
this.pendingAuth = null;
}
/**
* Handle protocol callback from GitHub OAuth
* Called when user is redirected to noodl://github-callback?code=XXX&state=YYY
*/
async handleProtocolCallback(url) {
console.log('🔐 [GitHub OAuth] ========================================');
console.log('🔐 [GitHub OAuth] PROTOCOL CALLBACK RECEIVED');
console.log('🔐 [GitHub OAuth] URL:', url);
console.log('🔐 [GitHub OAuth] ========================================');
try {
// Parse the URL
const parsedUrl = new URL(url);
const params = parsedUrl.searchParams;
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
const error_description = params.get('error_description');
// Handle OAuth error
if (error) {
console.error('[GitHub OAuth] Error from GitHub:', error, error_description);
this.sendErrorToRenderer(error, error_description);
return;
}
// Validate required parameters
if (!code || !state) {
console.error('[GitHub OAuth] Missing code or state in callback');
this.sendErrorToRenderer('invalid_request', 'Missing authorization code or state');
return;
}
// Validate state (CSRF protection)
if (!this.validateState(state)) {
throw new Error('Invalid OAuth state - possible CSRF attack or expired');
}
// Exchange code for token
const token = await this.exchangeCodeForToken(code);
// Fetch user info
const user = await this.fetchUserInfo(token.access_token);
// Fetch installation info (organizations/repos)
const installations = await this.fetchInstallations(token.access_token);
// Send result to renderer process
this.sendSuccessToRenderer({
token,
user,
installations,
authMethod: 'web_oauth'
});
// Clear pending auth
this.pendingAuth = null;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[GitHub OAuth] Callback handling error:', error);
this.sendErrorToRenderer('token_exchange_failed', errorMessage);
}
}
/**
* Generate OAuth state for new flow
*/
generateOAuthState() {
const state = crypto.randomBytes(32).toString('hex');
const verifier = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
this.pendingAuth = {
state,
verifier,
createdAt: now,
expiresAt: now + 300000 // 5 minutes
};
return this.pendingAuth;
}
/**
* Validate OAuth state from callback
*/
validateState(receivedState) {
if (!this.pendingAuth) {
console.error('[GitHub OAuth] No pending auth state');
return false;
}
if (receivedState !== this.pendingAuth.state) {
console.error('[GitHub OAuth] State mismatch');
return false;
}
if (Date.now() > this.pendingAuth.expiresAt) {
console.error('[GitHub OAuth] State expired');
return false;
}
return true;
}
/**
* Exchange authorization code for access token
*/
async exchangeCodeForToken(code) {
console.log('[GitHub OAuth] Exchanging code for access token');
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`);
}
return data;
}
/**
* Fetch user information from GitHub
*/
async fetchUserInfo(token) {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`);
}
return response.json();
}
/**
* Fetch installation information (orgs/repos user granted access to)
*/
async fetchInstallations(token) {
try {
// Fetch user installations
const response = await fetch('https://api.github.com/user/installations', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
console.warn('[GitHub OAuth] Failed to fetch installations:', response.status);
return [];
}
const data = await response.json();
return data.installations || [];
} catch (error) {
console.warn('[GitHub OAuth] Error fetching installations:', error);
return [];
}
}
/**
* Send success to renderer process
*/
sendSuccessToRenderer(result) {
console.log('📤 [GitHub OAuth] ========================================');
console.log('📤 [GitHub OAuth] SENDING IPC EVENT: github-oauth-complete');
console.log('📤 [GitHub OAuth] User:', result.user.login);
console.log('📤 [GitHub OAuth] Installations:', result.installations.length);
console.log('📤 [GitHub OAuth] ========================================');
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-complete', result);
console.log('✅ [GitHub OAuth] IPC event sent to renderer');
} else {
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
}
}
/**
* Send error to renderer process
*/
sendErrorToRenderer(error, description) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-error', {
error,
message: description || error
});
}
}
/**
* Get authorization URL for OAuth flow
*/
getAuthorizationUrl(state) {
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`,
scope: 'repo read:org read:user user:email',
state,
allow_signup: 'true'
});
return `https://github.com/login/oauth/authorize?${params}`;
}
/**
* Cancel pending OAuth flow
*/
cancelPendingAuth() {
this.pendingAuth = null;
console.log('[GitHub OAuth] Pending auth cancelled');
}
}
// Singleton instance
let handlerInstance = null;
/**
* Initialize GitHub OAuth IPC handlers and protocol handler
*/
function initializeGitHubOAuthHandlers(app) {
handlerInstance = new GitHubOAuthCallbackHandler();
// Register custom protocol handler
if (!app.isDefaultProtocolClient(OAUTH_PROTOCOL)) {
app.setAsDefaultProtocolClient(OAUTH_PROTOCOL);
console.log(`[GitHub OAuth] Registered ${OAUTH_PROTOCOL}:// protocol handler`);
}
// Handle protocol callback on macOS/Linux
app.on('open-url', (event, url) => {
event.preventDefault();
if (url.startsWith(`${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`)) {
handlerInstance.handleProtocolCallback(url);
}
});
// Handle protocol callback on Windows (second instance)
app.on('second-instance', (event, commandLine) => {
// Find the protocol URL in command line args
const protocolUrl = commandLine.find((arg) => arg.startsWith(`${OAUTH_PROTOCOL}://`));
if (protocolUrl && protocolUrl.includes(OAUTH_CALLBACK_PATH)) {
handlerInstance.handleProtocolCallback(protocolUrl);
}
// Focus the main window
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
if (windows[0].isMinimized()) windows[0].restore();
windows[0].focus();
}
});
// Handle start OAuth flow request from renderer
ipcMain.handle('github-oauth-start', async () => {
try {
const authState = handlerInstance.generateOAuthState();
const authUrl = handlerInstance.getAuthorizationUrl(authState.state);
return { success: true, authUrl };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
});
// Handle stop OAuth flow request from renderer
ipcMain.handle('github-oauth-stop', async () => {
handlerInstance.cancelPendingAuth();
return { success: true };
});
console.log('[GitHub OAuth] IPC handlers and protocol handler initialized');
}
module.exports = {
GitHubOAuthCallbackHandler,
initializeGitHubOAuthHandlers,
OAUTH_PROTOCOL
};

View File

@@ -10,6 +10,7 @@ const { startCloudFunctionServer, closeRuntimeWhenWindowCloses } = require('./sr
const DesignToolImportServer = require('./src/design-tool-import-server');
const jsonstorage = require('../shared/utils/jsonstorage');
const StorageApi = require('./src/StorageApi');
const { initializeGitHubOAuthHandlers } = require('./github-oauth-handler');
const { handleProjectMerge } = require('./src/merge-driver');
@@ -542,6 +543,9 @@ function launchApp() {
setupGitHubOAuthIpc();
// Initialize Web OAuth handlers for GitHub (with protocol handler)
initializeGitHubOAuthHandlers(app);
setupMainWindowControlIpc();
setupMenu();
@@ -565,27 +569,12 @@ function launchApp() {
console.log('open-url', uri);
event.preventDefault();
// Handle GitHub OAuth callback
if (uri.startsWith('noodl://github-callback')) {
try {
const url = new URL(uri);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (code && state) {
console.log('🔐 GitHub OAuth callback received');
win && win.webContents.send('github-oauth-callback', { code, state });
return;
}
} catch (error) {
console.error('Failed to parse GitHub OAuth callback:', error);
}
// GitHub OAuth callbacks are handled by github-oauth-handler.js
// Only handle other noodl:// URIs here
if (!uri.startsWith('noodl://github-callback')) {
win && win.webContents.send('open-noodl-uri', uri);
process.env.noodlURI = uri;
}
// Default noodl URI handling
win && win.webContents.send('open-noodl-uri', uri);
process.env.noodlURI = uri;
// logEverywhere("open-url# " + deeplinkingUrl)
});
});

View File

@@ -0,0 +1,279 @@
/**
* Expression Parameter Types Tests
*
* Tests type definitions and helper functions for expression-based parameters
*/
import {
ExpressionParameter,
isExpressionParameter,
getParameterDisplayValue,
getParameterActualValue,
createExpressionParameter,
toParameter
} from '../../src/editor/src/models/ExpressionParameter';
describe('Expression Parameter Types', () => {
describe('isExpressionParameter', () => {
it('identifies expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x + 1',
fallback: 0
};
expect(isExpressionParameter(expr)).toBe(true);
});
it('identifies expression without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x'
};
expect(isExpressionParameter(expr)).toBe(true);
});
it('rejects simple values', () => {
expect(isExpressionParameter(42)).toBe(false);
expect(isExpressionParameter('hello')).toBe(false);
expect(isExpressionParameter(true)).toBe(false);
expect(isExpressionParameter(null)).toBe(false);
expect(isExpressionParameter(undefined)).toBe(false);
});
it('rejects objects without mode', () => {
expect(isExpressionParameter({ expression: 'test' })).toBe(false);
});
it('rejects objects with wrong mode', () => {
expect(isExpressionParameter({ mode: 'fixed', value: 42 })).toBe(false);
});
it('rejects objects without expression', () => {
expect(isExpressionParameter({ mode: 'expression' })).toBe(false);
});
it('rejects objects with non-string expression', () => {
expect(isExpressionParameter({ mode: 'expression', expression: 42 })).toBe(false);
});
});
describe('getParameterDisplayValue', () => {
it('returns expression string for expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x * 2',
fallback: 0
};
expect(getParameterDisplayValue(expr)).toBe('Variables.x * 2');
});
it('returns expression even without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.count'
};
expect(getParameterDisplayValue(expr)).toBe('Variables.count');
});
it('returns value as-is for simple values', () => {
expect(getParameterDisplayValue(42)).toBe(42);
expect(getParameterDisplayValue('hello')).toBe('hello');
expect(getParameterDisplayValue(true)).toBe(true);
expect(getParameterDisplayValue(null)).toBe(null);
expect(getParameterDisplayValue(undefined)).toBe(undefined);
});
it('returns value as-is for objects', () => {
const obj = { a: 1, b: 2 };
expect(getParameterDisplayValue(obj)).toBe(obj);
});
});
describe('getParameterActualValue', () => {
it('returns fallback for expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x * 2',
fallback: 100
};
expect(getParameterActualValue(expr)).toBe(100);
});
it('returns undefined for expression without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x'
};
expect(getParameterActualValue(expr)).toBeUndefined();
});
it('returns value as-is for simple values', () => {
expect(getParameterActualValue(42)).toBe(42);
expect(getParameterActualValue('hello')).toBe('hello');
expect(getParameterActualValue(false)).toBe(false);
});
});
describe('createExpressionParameter', () => {
it('creates expression parameter with all fields', () => {
const expr = createExpressionParameter('Variables.count', 0, 2);
expect(expr.mode).toBe('expression');
expect(expr.expression).toBe('Variables.count');
expect(expr.fallback).toBe(0);
expect(expr.version).toBe(2);
});
it('uses default version if not provided', () => {
const expr = createExpressionParameter('Variables.x', 10);
expect(expr.version).toBe(1);
});
it('allows undefined fallback', () => {
const expr = createExpressionParameter('Variables.x');
expect(expr.fallback).toBeUndefined();
expect(expr.version).toBe(1);
});
it('allows null fallback', () => {
const expr = createExpressionParameter('Variables.x', null);
expect(expr.fallback).toBe(null);
});
it('allows zero as fallback', () => {
const expr = createExpressionParameter('Variables.x', 0);
expect(expr.fallback).toBe(0);
});
it('allows empty string as fallback', () => {
const expr = createExpressionParameter('Variables.x', '');
expect(expr.fallback).toBe('');
});
});
describe('toParameter', () => {
it('passes through expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x',
fallback: 0
};
expect(toParameter(expr)).toBe(expr);
});
it('returns simple values as-is', () => {
expect(toParameter(42)).toBe(42);
expect(toParameter('hello')).toBe('hello');
expect(toParameter(true)).toBe(true);
expect(toParameter(null)).toBe(null);
expect(toParameter(undefined)).toBe(undefined);
});
it('returns objects as-is', () => {
const obj = { a: 1 };
expect(toParameter(obj)).toBe(obj);
});
});
describe('Serialization', () => {
it('expression parameters serialize to JSON correctly', () => {
const expr = createExpressionParameter('Variables.count', 10);
const json = JSON.stringify(expr);
const parsed = JSON.parse(json);
expect(parsed.mode).toBe('expression');
expect(parsed.expression).toBe('Variables.count');
expect(parsed.fallback).toBe(10);
expect(parsed.version).toBe(1);
});
it('deserialized expression parameters are recognized', () => {
const json = '{"mode":"expression","expression":"Variables.x","fallback":0,"version":1}';
const parsed = JSON.parse(json);
expect(isExpressionParameter(parsed)).toBe(true);
expect(parsed.expression).toBe('Variables.x');
expect(parsed.fallback).toBe(0);
});
it('handles undefined fallback in serialization', () => {
const expr = createExpressionParameter('Variables.x');
const json = JSON.stringify(expr);
const parsed = JSON.parse(json);
expect(parsed.fallback).toBeUndefined();
expect(isExpressionParameter(parsed)).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('simple values in parameters object work', () => {
const params = {
marginLeft: 16,
color: '#ff0000',
enabled: true
};
expect(isExpressionParameter(params.marginLeft)).toBe(false);
expect(isExpressionParameter(params.color)).toBe(false);
expect(isExpressionParameter(params.enabled)).toBe(false);
});
it('mixed parameters work', () => {
const params = {
marginLeft: createExpressionParameter('Variables.spacing', 16),
marginRight: 8, // Simple value
color: '#ff0000'
};
expect(isExpressionParameter(params.marginLeft)).toBe(true);
expect(isExpressionParameter(params.marginRight)).toBe(false);
expect(isExpressionParameter(params.color)).toBe(false);
});
it('old project parameters load correctly', () => {
// Simulating loading old project
const oldParams = {
width: 200,
height: 100,
text: 'Hello'
};
// None should be expressions
Object.values(oldParams).forEach((value) => {
expect(isExpressionParameter(value)).toBe(false);
});
});
it('new project with expressions loads correctly', () => {
const newParams = {
width: createExpressionParameter('Variables.width', 200),
height: 100, // Mixed: some expression, some not
text: 'Static text'
};
expect(isExpressionParameter(newParams.width)).toBe(true);
expect(isExpressionParameter(newParams.height)).toBe(false);
expect(isExpressionParameter(newParams.text)).toBe(false);
});
});
describe('Edge Cases', () => {
it('handles complex expressions', () => {
const expr = createExpressionParameter('Variables.isAdmin ? "Admin Panel" : "User Panel"', 'User Panel');
expect(expr.expression).toBe('Variables.isAdmin ? "Admin Panel" : "User Panel"');
});
it('handles multi-line expressions', () => {
const multiLine = `Variables.items
.filter(x => x.active)
.length`;
const expr = createExpressionParameter(multiLine, 0);
expect(expr.expression).toBe(multiLine);
});
it('handles expressions with special characters', () => {
const expr = createExpressionParameter('Variables["my-variable"]', null);
expect(expr.expression).toBe('Variables["my-variable"]');
});
});
});

View File

@@ -0,0 +1,387 @@
/**
* Unit tests for ParameterValueResolver
*
* Tests the resolution of parameter values from storage (primitives or expression objects)
* to display/runtime values based on context.
*
* @module noodl-editor/tests/utils
*/
import { describe, it, expect } from '@jest/globals';
import { createExpressionParameter, ExpressionParameter } from '../../src/editor/src/models/ExpressionParameter';
import { ParameterValueResolver, ValueContext } from '../../src/editor/src/utils/ParameterValueResolver';
describe('ParameterValueResolver', () => {
describe('resolve()', () => {
describe('with primitive values', () => {
it('should return string values as-is', () => {
expect(ParameterValueResolver.resolve('hello', ValueContext.Display)).toBe('hello');
expect(ParameterValueResolver.resolve('', ValueContext.Display)).toBe('');
expect(ParameterValueResolver.resolve('123', ValueContext.Display)).toBe('123');
});
it('should return number values as-is', () => {
expect(ParameterValueResolver.resolve(42, ValueContext.Display)).toBe(42);
expect(ParameterValueResolver.resolve(0, ValueContext.Display)).toBe(0);
expect(ParameterValueResolver.resolve(-42.5, ValueContext.Display)).toBe(-42.5);
});
it('should return boolean values as-is', () => {
expect(ParameterValueResolver.resolve(true, ValueContext.Display)).toBe(true);
expect(ParameterValueResolver.resolve(false, ValueContext.Display)).toBe(false);
});
it('should return undefined as-is', () => {
expect(ParameterValueResolver.resolve(undefined, ValueContext.Display)).toBe(undefined);
});
it('should handle null', () => {
expect(ParameterValueResolver.resolve(null, ValueContext.Display)).toBe(null);
});
});
describe('with expression parameters', () => {
it('should extract fallback from expression parameter in Display context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('default');
});
it('should extract fallback from expression parameter in Runtime context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Runtime)).toBe('default');
});
it('should return full object in Serialization context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
const result = ParameterValueResolver.resolve(exprParam, ValueContext.Serialization);
expect(result).toBe(exprParam);
expect((result as ExpressionParameter).mode).toBe('expression');
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('');
});
it('should handle expression parameter with numeric fallback', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe(42);
});
it('should handle expression parameter with boolean fallback', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe(true);
});
it('should handle expression parameter with empty string fallback', () => {
const exprParam = createExpressionParameter('Variables.x', '', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('');
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Should return as-is since it's not an expression parameter
expect(ParameterValueResolver.resolve(regularObj, ValueContext.Display)).toBe(regularObj);
});
it('should default to fallback for unknown context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
// Cast to any to test invalid context
expect(ParameterValueResolver.resolve(exprParam, 'invalid' as any)).toBe('default');
});
});
});
describe('toString()', () => {
describe('with primitive values', () => {
it('should convert string to string', () => {
expect(ParameterValueResolver.toString('hello')).toBe('hello');
expect(ParameterValueResolver.toString('')).toBe('');
});
it('should convert number to string', () => {
expect(ParameterValueResolver.toString(42)).toBe('42');
expect(ParameterValueResolver.toString(0)).toBe('0');
expect(ParameterValueResolver.toString(-42.5)).toBe('-42.5');
});
it('should convert boolean to string', () => {
expect(ParameterValueResolver.toString(true)).toBe('true');
expect(ParameterValueResolver.toString(false)).toBe('false');
});
it('should convert undefined to empty string', () => {
expect(ParameterValueResolver.toString(undefined)).toBe('');
});
it('should convert null to empty string', () => {
expect(ParameterValueResolver.toString(null)).toBe('');
});
});
describe('with expression parameters', () => {
it('should extract fallback as string from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.x', 'test', 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('test');
});
it('should convert numeric fallback to string', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('42');
});
it('should convert boolean fallback to string', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('true');
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('');
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('');
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Should return empty string for safety (defensive behavior)
expect(ParameterValueResolver.toString(regularObj)).toBe('');
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toString([1, 2, 3])).toBe('');
});
});
});
describe('toNumber()', () => {
describe('with primitive values', () => {
it('should return number as-is', () => {
expect(ParameterValueResolver.toNumber(42)).toBe(42);
expect(ParameterValueResolver.toNumber(0)).toBe(0);
expect(ParameterValueResolver.toNumber(-42.5)).toBe(-42.5);
});
it('should convert numeric string to number', () => {
expect(ParameterValueResolver.toNumber('42')).toBe(42);
expect(ParameterValueResolver.toNumber('0')).toBe(0);
expect(ParameterValueResolver.toNumber('-42.5')).toBe(-42.5);
});
it('should return undefined for non-numeric string', () => {
expect(ParameterValueResolver.toNumber('hello')).toBe(undefined);
expect(ParameterValueResolver.toNumber('not a number')).toBe(undefined);
});
it('should return undefined for undefined', () => {
expect(ParameterValueResolver.toNumber(undefined)).toBe(undefined);
});
it('should return undefined for null', () => {
expect(ParameterValueResolver.toNumber(null)).toBe(undefined);
});
it('should convert boolean to number', () => {
expect(ParameterValueResolver.toNumber(true)).toBe(1);
expect(ParameterValueResolver.toNumber(false)).toBe(0);
});
});
describe('with expression parameters', () => {
it('should extract numeric fallback from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
});
it('should convert string fallback to number', () => {
const exprParam = createExpressionParameter('Variables.count', '42', 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
});
it('should return undefined for non-numeric fallback', () => {
const exprParam = createExpressionParameter('Variables.text', 'hello', 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
expect(ParameterValueResolver.toNumber(regularObj)).toBe(undefined);
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toNumber([1, 2, 3])).toBe(undefined);
});
it('should handle empty string', () => {
expect(ParameterValueResolver.toNumber('')).toBe(0); // Empty string converts to 0
});
it('should handle whitespace string', () => {
expect(ParameterValueResolver.toNumber(' ')).toBe(0); // Whitespace converts to 0
});
});
});
describe('toBoolean()', () => {
describe('with primitive values', () => {
it('should return boolean as-is', () => {
expect(ParameterValueResolver.toBoolean(true)).toBe(true);
expect(ParameterValueResolver.toBoolean(false)).toBe(false);
});
it('should convert truthy strings to true', () => {
expect(ParameterValueResolver.toBoolean('hello')).toBe(true);
expect(ParameterValueResolver.toBoolean('0')).toBe(true); // Non-empty string is truthy
expect(ParameterValueResolver.toBoolean('false')).toBe(true); // Non-empty string is truthy
});
it('should convert empty string to false', () => {
expect(ParameterValueResolver.toBoolean('')).toBe(false);
});
it('should convert numbers using truthiness', () => {
expect(ParameterValueResolver.toBoolean(1)).toBe(true);
expect(ParameterValueResolver.toBoolean(42)).toBe(true);
expect(ParameterValueResolver.toBoolean(0)).toBe(false);
expect(ParameterValueResolver.toBoolean(-1)).toBe(true);
});
it('should convert undefined to false', () => {
expect(ParameterValueResolver.toBoolean(undefined)).toBe(false);
});
it('should convert null to false', () => {
expect(ParameterValueResolver.toBoolean(null)).toBe(false);
});
});
describe('with expression parameters', () => {
it('should extract boolean fallback from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(true);
});
it('should convert string fallback to boolean', () => {
const exprParamTruthy = createExpressionParameter('Variables.text', 'hello', 1);
expect(ParameterValueResolver.toBoolean(exprParamTruthy)).toBe(true);
const exprParamFalsy = createExpressionParameter('Variables.text', '', 1);
expect(ParameterValueResolver.toBoolean(exprParamFalsy)).toBe(false);
});
it('should convert numeric fallback to boolean', () => {
const exprParamTruthy = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toBoolean(exprParamTruthy)).toBe(true);
const exprParamFalsy = createExpressionParameter('Variables.count', 0, 1);
expect(ParameterValueResolver.toBoolean(exprParamFalsy)).toBe(false);
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(false);
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(false);
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Non-expression objects should return false (defensive behavior)
expect(ParameterValueResolver.toBoolean(regularObj)).toBe(false);
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toBoolean([1, 2, 3])).toBe(false);
});
});
});
describe('isExpression()', () => {
it('should return true for expression parameters', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.isExpression(exprParam)).toBe(true);
});
it('should return false for primitive values', () => {
expect(ParameterValueResolver.isExpression('hello')).toBe(false);
expect(ParameterValueResolver.isExpression(42)).toBe(false);
expect(ParameterValueResolver.isExpression(true)).toBe(false);
expect(ParameterValueResolver.isExpression(undefined)).toBe(false);
expect(ParameterValueResolver.isExpression(null)).toBe(false);
});
it('should return false for regular objects', () => {
const regularObj = { foo: 'bar' };
expect(ParameterValueResolver.isExpression(regularObj)).toBe(false);
});
it('should return false for arrays', () => {
expect(ParameterValueResolver.isExpression([1, 2, 3])).toBe(false);
});
});
describe('integration scenarios', () => {
it('should handle converting expression parameter through all type conversions', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('42');
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(true);
expect(ParameterValueResolver.isExpression(exprParam)).toBe(true);
});
it('should handle canvas rendering scenario (text.split prevention)', () => {
// This is the actual bug we're fixing - canvas tries to call .split() on a parameter
const exprParam = createExpressionParameter('Variables.text', 'Hello\nWorld', 1);
// Before fix: this would return the object, causing text.split() to crash
// After fix: this returns a string that can be safely split
const text = ParameterValueResolver.toString(exprParam);
expect(typeof text).toBe('string');
expect(() => text.split('\n')).not.toThrow();
expect(text.split('\n')).toEqual(['Hello', 'World']);
});
it('should handle property panel display scenario', () => {
// Property panel needs to show fallback value while user edits expression
const exprParam = createExpressionParameter('2 + 2', '4', 1);
const displayValue = ParameterValueResolver.resolve(exprParam, ValueContext.Display);
expect(displayValue).toBe('4');
});
it('should handle serialization scenario', () => {
// When saving, we need the full object preserved
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
const serialized = ParameterValueResolver.resolve(exprParam, ValueContext.Serialization);
expect(serialized).toBe(exprParam);
expect((serialized as ExpressionParameter).expression).toBe('Variables.x');
});
});
});

View File

@@ -1 +1,2 @@
export * from './ParameterValueResolver.test';
export * from './verify-json.spec';