Finished inital project migration workflow

This commit is contained in:
Richard Osborne
2025-12-15 11:58:55 +01:00
parent 1477a29ff7
commit 0b47d19776
44 changed files with 8995 additions and 174 deletions

View File

@@ -1,123 +1,124 @@
.Root {
border: none;
padding: 0;
background: transparent;
box-sizing: border-box;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
&.has-backdrop {
background-color: var(--theme-color-bg-1-transparent);
}
&.is-locking-scroll {
background-color: transparent;
}
&:not(.has-backdrop):not(.is-locking-scroll) {
pointer-events: none;
}
}
.VisibleDialog {
filter: drop-shadow(0 4px 15px var(--theme-color-bg-1-transparent-2));
box-shadow: 0 0 10px -5px var(--theme-color-bg-1-transparent-2);
position: absolute;
width: var(--width);
pointer-events: all;
.Root.is-centered & {
top: 50%;
left: 50%;
animation: enter-centered var(--speed-quick) var(--easing-base) both;
}
.Root:not(.is-centered) &.is-visible {
&.is-variant-default {
animation: enter var(--speed-quick) var(--easing-base) both;
}
&.is-variant-select {
transform: translate(var(--offsetX), var(--offsetY));
}
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--background);
border-radius: 2px;
overflow: hidden;
}
}
.Arrow {
position: absolute;
width: 0;
height: 0;
top: var(--arrow-top);
left: var(--arrow-left);
pointer-events: none;
&::after {
content: '';
display: block;
width: 11px;
height: 11px;
transform: translate(-50%, -50%) rotate(45deg);
background: var(--background);
}
&.is-contrast::after {
background: var(--backgroundContrast);
}
}
.Title {
background-color: var(--backgroundContrast);
padding: 12px;
}
.MeasuringContainer {
pointer-events: none;
height: 0;
overflow: visible;
opacity: 0;
}
.ChildContainer {
position: relative;
z-index: 1;
}
@keyframes enter {
from {
opacity: 0;
transform: translate(
calc(var(--animationStartOffsetX) + var(--offsetX)),
calc(var(--animationStartOffsetY) + var(--offsetY))
);
}
to {
opacity: 1;
transform: translate(var(--offsetX), var(--offsetY));
}
}
@keyframes enter-centered {
from {
opacity: 0;
transform: translate(-50%, calc(-50% + 16px));
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.Root {
border: none;
padding: 0;
background: transparent;
box-sizing: border-box;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
&.has-backdrop {
background-color: var(--theme-color-bg-1-transparent);
}
&.is-locking-scroll {
background-color: transparent;
}
&:not(.has-backdrop):not(.is-locking-scroll) {
pointer-events: none;
}
}
.VisibleDialog {
filter: drop-shadow(0 4px 15px var(--theme-color-bg-1-transparent-2));
box-shadow: 0 0 10px -5px var(--theme-color-bg-1-transparent-2);
position: absolute;
width: var(--width);
pointer-events: all;
.Root.is-centered & {
top: 50%;
left: 50%;
animation: enter-centered var(--speed-quick) var(--easing-base) both;
}
.Root:not(.is-centered) &.is-visible {
&.is-variant-default {
animation: enter var(--speed-quick) var(--easing-base) both;
}
&.is-variant-select {
transform: translate(var(--offsetX), var(--offsetY));
}
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--background);
border-radius: 2px;
overflow: hidden;
pointer-events: none; // Allow clicks to pass through to content
}
}
.Arrow {
position: absolute;
width: 0;
height: 0;
top: var(--arrow-top);
left: var(--arrow-left);
pointer-events: none;
&::after {
content: '';
display: block;
width: 11px;
height: 11px;
transform: translate(-50%, -50%) rotate(45deg);
background: var(--background);
}
&.is-contrast::after {
background: var(--backgroundContrast);
}
}
.Title {
background-color: var(--backgroundContrast);
padding: 12px;
}
.MeasuringContainer {
pointer-events: none;
height: 0;
overflow: visible;
opacity: 0;
}
.ChildContainer {
position: relative;
z-index: 1;
}
@keyframes enter {
from {
opacity: 0;
transform: translate(
calc(var(--animationStartOffsetX) + var(--offsetX)),
calc(var(--animationStartOffsetY) + var(--offsetY))
);
}
to {
opacity: 1;
transform: translate(var(--offsetX), var(--offsetY));
}
}
@keyframes enter-centered {
from {
opacity: 0;
transform: translate(-50%, calc(-50% + 16px));
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}

View File

@@ -23,6 +23,11 @@ export type DialogLayerOptions = {
id?: string;
};
export type ShowDialogOptions = DialogLayerOptions & {
/** Called when the dialog is closed */
onClose?: () => void;
};
export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerModelEvents> {
public static instance = new DialogLayerModel();
@@ -84,4 +89,40 @@ export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerMo
};
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
}
/**
* Show a custom dialog component.
* Returns a close function that can be called to programmatically close the dialog.
*
* @param render - Function that receives a close callback and returns JSX
* @param options - Dialog options including optional id
* @returns A function to close the dialog
*/
public showDialog(
render: (close: () => void) => JSX.Element,
options: ShowDialogOptions = {}
): () => void {
const id = options.id ?? guid();
const { onClose } = options;
const close = () => {
this.closeById(id);
onClose && onClose();
};
// Remove existing dialog with same id if present
if (this._dialogs[id]) {
this._order = this._order.filter((x) => x !== id);
delete this._dialogs[id];
}
this._order.push(id);
this._dialogs[id] = {
id,
slot: () => render(close)
};
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
return close;
}
}

View File

@@ -8,6 +8,8 @@
* @since 1.2.0
*/
import { filesystem } from '@noodl/platform';
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import { detectRuntimeVersion, scanProjectForMigration } from './ProjectScanner';
import {
@@ -409,27 +411,72 @@ export class MigrationSessionManager extends EventDispatcher {
}
private async executeCopyPhase(): Promise<void> {
if (!this.session) return;
const sourcePath = this.session.source.path;
const targetPath = this.session.target.path;
this.updateProgress({ phase: 'copying', current: 0 });
this.addLogEntry({
level: 'info',
message: 'Creating project copy...'
message: `Copying project from ${sourcePath} to ${targetPath}...`
});
// TODO: Implement actual file copying using filesystem
// For now, this is a placeholder
try {
// Check if target already exists
const targetExists = await filesystem.exists(targetPath);
if (targetExists) {
throw new Error(`Target directory already exists: ${targetPath}`);
}
await this.simulateDelay(500);
// Create target directory
await filesystem.makeDirectory(targetPath);
// Copy all files recursively
await this.copyDirectoryRecursive(sourcePath, targetPath);
if (this.session) {
this.session.target.copied = true;
this.addLogEntry({
level: 'success',
message: 'Project copied successfully'
});
this.updateProgress({ current: 1 });
} catch (error) {
this.addLogEntry({
level: 'error',
message: `Failed to copy project: ${error instanceof Error ? error.message : 'Unknown error'}`
});
throw error;
}
}
this.addLogEntry({
level: 'success',
message: 'Project copied successfully'
});
/**
* Recursively copies a directory and its contents
*/
private async copyDirectoryRecursive(sourcePath: string, targetPath: string): Promise<void> {
const entries = await filesystem.listDirectory(sourcePath);
this.updateProgress({ current: 1 });
for (const entry of entries) {
const sourceItemPath = entry.fullPath;
const targetItemPath = `${targetPath}/${entry.name}`;
if (entry.isDirectory) {
// Skip node_modules and .git folders
if (entry.name === 'node_modules' || entry.name === '.git') {
continue;
}
// Create directory and recurse
await filesystem.makeDirectory(targetItemPath);
await this.copyDirectoryRecursive(sourceItemPath, targetItemPath);
} else {
// Copy file
const content = await filesystem.readFile(sourceItemPath);
await filesystem.writeFile(targetItemPath, content);
}
}
}
private async executeAutomaticPhase(): Promise<void> {
@@ -493,14 +540,47 @@ export class MigrationSessionManager extends EventDispatcher {
}
private async executeFinalizePhase(): Promise<void> {
if (!this.session) return;
this.updateProgress({ phase: 'finalizing' });
this.addLogEntry({
level: 'info',
message: 'Finalizing migration...'
});
// TODO: Update project.json with migration metadata
await this.simulateDelay(200);
try {
// Update project.json with migration metadata
const targetProjectJsonPath = `${this.session.target.path}/project.json`;
// Read existing project.json
const projectJson = await filesystem.readJson(targetProjectJsonPath) as Record<string, unknown>;
// Add React 19 markers
projectJson.runtimeVersion = 'react19';
projectJson.migratedFrom = {
version: 'react17',
date: new Date().toISOString(),
originalPath: this.session.source.path,
aiAssisted: this.session.ai?.enabled ?? false
};
// Write updated project.json back
await filesystem.writeFile(
targetProjectJsonPath,
JSON.stringify(projectJson, null, 2)
);
this.addLogEntry({
level: 'success',
message: 'Project marked as React 19'
});
} catch (error) {
this.addLogEntry({
level: 'warning',
message: `Could not update project.json metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
});
// Don't throw - this is not a critical failure
}
this.addLogEntry({
level: 'success',

View File

@@ -315,12 +315,14 @@ export async function detectRuntimeVersion(projectPath: string): Promise<Runtime
}
// ==========================================================================
// Default: Unknown - could be either version
// Default: Assume React 17 for older projects without explicit markers
// Any project without runtimeVersion, migratedFrom, or a recent editorVersion
// is most likely a legacy project from before OpenNoodl
// ==========================================================================
return {
version: 'unknown',
version: 'react17',
confidence: 'low',
indicators: ['No version indicators found - manual verification recommended']
indicators: ['No React 19 markers found - assuming legacy React 17 project']
};
}

View File

@@ -950,3 +950,128 @@
overflow: hidden overlay;
max-height: calc(100vh - 180px);
}
/* -------------------------------------------------------------------
Legacy Project Styles (React 17 Migration)
------------------------------------------------------------------- */
/* Legacy project card modifier */
.projects-item--legacy {
border: 2px solid #d49517;
box-sizing: border-box;
}
.projects-item--legacy:hover {
border-color: #fdb314;
}
/* Legacy badge in top-right corner */
.projects-item-legacy-badge {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 4px;
background-color: rgba(212, 149, 23, 0.9);
color: #000;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
z-index: 10;
}
.projects-item-legacy-badge svg {
flex-shrink: 0;
}
/* Hidden class for conditional display */
.hidden {
display: none !important;
}
/* Legacy project hover actions overlay */
.projects-item-legacy-actions {
position: absolute;
bottom: 70px;
left: 0;
right: 0;
top: 0;
background: rgba(19, 19, 19, 0.95);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 20px;
box-sizing: border-box;
}
.projects-item--legacy:hover .projects-item-legacy-actions {
display: flex;
}
/* Migrate Project button */
.projects-item-migrate-btn {
background-color: #d49517;
border: none;
color: #000;
padding: 10px 20px;
font-weight: 600;
font-size: 12px;
cursor: pointer;
width: 100%;
max-width: 180px;
border-radius: 4px;
text-align: center;
}
.projects-item-migrate-btn:hover {
background-color: #fdb314;
}
/* Open Read-Only button */
.projects-item-readonly-btn {
background-color: transparent;
border: 1px solid #666;
color: #aaa;
padding: 8px 16px;
font-weight: 500;
font-size: 11px;
cursor: pointer;
width: 100%;
max-width: 180px;
border-radius: 4px;
text-align: center;
}
.projects-item-readonly-btn:hover {
background-color: #333;
border-color: #888;
color: #fff;
}
/* Runtime detection pending indicator */
.projects-item-detecting {
opacity: 0.7;
}
.projects-item-detecting::after {
content: "";
position: absolute;
top: 8px;
right: 8px;
width: 12px;
height: 12px;
border: 2px solid #666;
border-top-color: #d49517;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -1,6 +1,6 @@
<div class="projects-main">
<!-- project item template -->
<div data-template="projects-item" class="projects-item projects-item-plate" data-click="onProjectItemClicked">
<div data-template="projects-item" class="projects-item projects-item-plate" data-class="isLegacy:projects-item--legacy" data-click="onProjectItemClicked">
<div class="projects-item-thumb" style="position:absolute; left:0px; top:0px; width:100%; bottom:70px;">
<div class="projects-item-cloud-download" style="width:100%; height:100%;">
@@ -10,6 +10,14 @@
</div>
</div>
<!-- Legacy project badge -->
<div class="projects-item-legacy-badge" data-class="!isLegacy:hidden">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
</svg>
<span>Legacy</span>
</div>
<div class="projects-item-label" style="position:absolute; bottom:36px; left:10px; right:10px;"
data-click="onRenameProjectClicked">
<span data-text="label" data-test="project-card-label"></span>
@@ -28,6 +36,16 @@
<img class="projects-remove-icon" src="../assets/images/sharp-clear-24px.svg">
</div>
<!-- Legacy project hover actions -->
<div class="projects-item-legacy-actions" data-class="!isLegacy:hidden">
<button class="projects-item-migrate-btn" data-click="onMigrateProjectClicked">
Migrate Project
</button>
<button class="projects-item-readonly-btn" data-click="onOpenReadOnlyClicked">
Open Read-Only
</button>
</div>
</div>
<!-- tutorial item template, guides etc (not lessons) -->

View File

@@ -13,6 +13,8 @@ import { templateRegistry } from '@noodl-utils/forge';
import Model from '../../../shared/model';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import { RuntimeVersionInfo } from '../models/migration/types';
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
import FileSystem from './filesystem';
import { tracker } from './tracker';
import { guid } from './utils';
@@ -25,6 +27,14 @@ export interface ProjectItem {
thumbURI: string;
retainedProjectDirectory: string;
}
/**
* Extended project item with runtime version info (not persisted)
*/
export interface ProjectItemWithRuntime extends ProjectItem {
runtimeInfo?: RuntimeVersionInfo;
runtimeDetectionPending?: boolean;
}
export class LocalProjectsModel extends Model {
public static instance = new LocalProjectsModel();
@@ -34,6 +44,17 @@ export class LocalProjectsModel extends Model {
name: 'recently_opened_project'
});
/**
* Cache for runtime version info - keyed by project directory path
* Not persisted, re-detected on each app session
*/
private runtimeInfoCache: Map<string, RuntimeVersionInfo> = new Map();
/**
* Set of project directories currently being detected
*/
private detectingProjects: Set<string> = new Set();
async fetch() {
// Fetch projects from local storage and verify project folders
const folders = (this.recentProjectsStore.get('recentProjects') || []) as ProjectItem[];
@@ -299,4 +320,128 @@ export class LocalProjectsModel extends Model {
setRequestGitAccount(func);
}
// =========================================================================
// Runtime Version Detection Methods
// =========================================================================
/**
* Get cached runtime info for a project, or null if not yet detected
* @param projectPath - The project directory path
*/
getRuntimeInfo(projectPath: string): RuntimeVersionInfo | null {
return this.runtimeInfoCache.get(projectPath) || null;
}
/**
* Check if runtime detection is currently in progress for a project
* @param projectPath - The project directory path
*/
isDetectingRuntime(projectPath: string): boolean {
return this.detectingProjects.has(projectPath);
}
/**
* Get projects with their runtime info (extended interface)
* Returns projects enriched with cached runtime detection status
*/
getProjectsWithRuntime(): ProjectItemWithRuntime[] {
return this.projectEntries.map((project) => ({
...project,
runtimeInfo: this.getRuntimeInfo(project.retainedProjectDirectory),
runtimeDetectionPending: this.isDetectingRuntime(project.retainedProjectDirectory)
}));
}
/**
* Detect runtime version for a single project.
* Results are cached and listeners are notified.
* @param projectPath - Path to the project directory
* @returns The detected runtime version info
*/
async detectProjectRuntime(projectPath: string): Promise<RuntimeVersionInfo> {
// Return cached result if available
const cached = this.runtimeInfoCache.get(projectPath);
if (cached) {
return cached;
}
// Skip if already detecting
if (this.detectingProjects.has(projectPath)) {
// Wait for existing detection to complete by polling
return new Promise((resolve) => {
const checkCached = () => {
const result = this.runtimeInfoCache.get(projectPath);
if (result) {
resolve(result);
} else if (this.detectingProjects.has(projectPath)) {
setTimeout(checkCached, 100);
} else {
// Detection finished but no result - return unknown
resolve({ version: 'unknown', confidence: 'low', indicators: ['Detection failed'] });
}
};
checkCached();
});
}
// Mark as detecting
this.detectingProjects.add(projectPath);
this.notifyListeners('runtimeDetectionStarted', projectPath);
try {
const runtimeInfo = await detectRuntimeVersion(projectPath);
this.runtimeInfoCache.set(projectPath, runtimeInfo);
this.notifyListeners('runtimeDetectionComplete', projectPath, runtimeInfo);
return runtimeInfo;
} catch (error) {
console.error(`Failed to detect runtime for ${projectPath}:`, error);
const fallback: RuntimeVersionInfo = {
version: 'unknown',
confidence: 'low',
indicators: ['Detection error: ' + (error instanceof Error ? error.message : 'Unknown error')]
};
this.runtimeInfoCache.set(projectPath, fallback);
this.notifyListeners('runtimeDetectionComplete', projectPath, fallback);
return fallback;
} finally {
this.detectingProjects.delete(projectPath);
}
}
/**
* Detect runtime version for all projects in the list (background)
* Useful for pre-populating the cache when the projects view loads
*/
async detectAllProjectRuntimes(): Promise<void> {
const projects = this.getProjects();
// Detect in parallel but don't wait for all to complete
// Instead, trigger detection and let events update the UI
for (const project of projects) {
// Don't await - let them run in background
this.detectProjectRuntime(project.retainedProjectDirectory).catch(() => {
// Errors are handled in detectProjectRuntime
});
}
}
/**
* Check if a project is a legacy project (React 17)
* @param projectPath - Path to the project directory
* @returns True if project is detected as React 17
*/
isLegacyProject(projectPath: string): boolean {
const info = this.getRuntimeInfo(projectPath);
return info?.version === 'react17';
}
/**
* Clear runtime cache for a specific project (e.g., after migration)
* @param projectPath - Path to the project directory
*/
clearRuntimeCache(projectPath: string): void {
this.runtimeInfoCache.delete(projectPath);
this.notifyListeners('runtimeCacheCleared', projectPath);
}
}

View File

@@ -0,0 +1,44 @@
/**
* MigrationWizard Styles
*
* Main container for the migration wizard using CoreBaseDialog.
*/
.WizardContainer {
position: relative;
width: 700px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-4);
border-radius: 4px;
overflow: hidden;
}
.CloseButton {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
}
.WizardHeader {
padding: 24px 24px 16px;
padding-right: 48px; // Space for close button
}
.WizardContent {
display: flex;
flex-direction: column;
padding: 0 24px 24px;
gap: 16px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.StepContainer {
flex: 1;
min-height: 200px;
}

View File

@@ -0,0 +1,395 @@
/**
* MigrationWizard
*
* Main container component for the React 19 migration wizard.
* Manages step navigation and integrates with MigrationSessionManager.
*
* @module noodl-editor/views/migration
* @since 1.2.0
*/
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { MigrationSession, MigrationScan, MigrationResult } from '../../models/migration/types';
import { migrationSessionManager, getStepLabel, getStepNumber, getTotalSteps } from '../../models/migration/MigrationSession';
import { WizardProgress } from './components/WizardProgress';
import { ConfirmStep } from './steps/ConfirmStep';
import { ScanningStep } from './steps/ScanningStep';
import { ReportStep } from './steps/ReportStep';
import { CompleteStep } from './steps/CompleteStep';
import { FailedStep } from './steps/FailedStep';
import css from './MigrationWizard.module.scss';
// =============================================================================
// Types
// =============================================================================
export interface MigrationWizardProps {
/** Path to the source project */
sourcePath: string;
/** Name of the project */
projectName: string;
/** Called when migration completes successfully */
onComplete: (targetPath: string) => void;
/** Called when wizard is cancelled */
onCancel: () => void;
}
type WizardAction =
| { type: 'SET_SESSION'; session: MigrationSession }
| { type: 'SET_TARGET_PATH'; path: string }
| { type: 'START_SCAN' }
| { type: 'SCAN_COMPLETE'; scan: MigrationScan }
| { type: 'ERROR'; error: Error }
| { type: 'START_MIGRATE'; useAi: boolean }
| { type: 'MIGRATION_PROGRESS'; progress: number; currentComponent?: string }
| { type: 'COMPLETE'; result: MigrationResult }
| { type: 'RETRY' };
interface WizardState {
session: MigrationSession | null;
loading: boolean;
error: Error | null;
}
// =============================================================================
// Reducer
// =============================================================================
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case 'SET_SESSION':
return {
...state,
session: action.session
};
case 'SET_TARGET_PATH':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
target: { ...state.session.target, path: action.path }
}
};
case 'START_SCAN':
if (!state.session) return state;
return {
...state,
session: { ...state.session, step: 'scanning' },
loading: true
};
case 'SCAN_COMPLETE':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
step: 'report',
scan: action.scan
},
loading: false
};
case 'ERROR':
if (!state.session) return state;
return {
...state,
session: { ...state.session, step: 'failed' },
loading: false,
error: action.error
};
case 'START_MIGRATE':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
step: 'migrating',
ai: action.useAi ? state.session.ai : undefined
},
loading: true
};
case 'MIGRATION_PROGRESS':
if (!state.session?.progress) return state;
return {
...state,
session: {
...state.session,
progress: {
...state.session.progress,
current: action.progress,
currentComponent: action.currentComponent
}
}
};
case 'COMPLETE':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
step: 'complete',
result: action.result
},
loading: false
};
case 'RETRY':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
step: 'confirm',
scan: undefined,
progress: undefined,
result: undefined
},
loading: false,
error: null
};
default:
return state;
}
}
// =============================================================================
// Component
// =============================================================================
export function MigrationWizard({
sourcePath,
projectName,
onComplete,
onCancel
}: MigrationWizardProps) {
// Initialize session on mount
const [state, dispatch] = useReducer(wizardReducer, {
session: null,
loading: false,
error: null
});
const [isInitialized, setIsInitialized] = useState(false);
// Create session on mount
useEffect(() => {
async function initSession() {
try {
// Create session in manager (stores it internally)
await migrationSessionManager.createSession(sourcePath, projectName);
// Set default target path
const defaultTargetPath = `${sourcePath}-react19`;
migrationSessionManager.setTargetPath(defaultTargetPath);
// Update session with new target path
const updatedSession = migrationSessionManager.getSession();
if (updatedSession) {
// Initialize reducer state with the session
dispatch({ type: 'SET_SESSION', session: updatedSession });
}
setIsInitialized(true);
} catch (error) {
console.error('Failed to create migration session:', error);
dispatch({ type: 'ERROR', error: error as Error });
}
}
initSession();
// Cleanup on unmount
return () => {
migrationSessionManager.cancelSession();
};
}, [sourcePath, projectName]);
// Sync local state with session manager
useEffect(() => {
if (!isInitialized) return;
const currentSession = migrationSessionManager.getSession();
if (currentSession) {
// Initialize local state from session manager
dispatch({ type: 'SET_TARGET_PATH', path: currentSession.target.path });
}
}, [isInitialized]);
// ==========================================================================
// Handlers
// ==========================================================================
const handleUpdateTargetPath = useCallback((path: string) => {
migrationSessionManager.setTargetPath(path);
dispatch({ type: 'SET_TARGET_PATH', path });
}, []);
const handleStartScan = useCallback(async () => {
dispatch({ type: 'START_SCAN' });
try {
const scan = await migrationSessionManager.startScanning();
dispatch({ type: 'SCAN_COMPLETE', scan });
} catch (error) {
dispatch({ type: 'ERROR', error: error as Error });
}
}, []);
const handleStartMigration = useCallback(async (useAi: boolean) => {
dispatch({ type: 'START_MIGRATE', useAi });
try {
const result = await migrationSessionManager.startMigration();
dispatch({ type: 'COMPLETE', result });
} catch (error) {
dispatch({ type: 'ERROR', error: error as Error });
}
}, []);
const handleRetry = useCallback(async () => {
try {
await migrationSessionManager.resetForRetry();
dispatch({ type: 'RETRY' });
} catch (error) {
console.error('Failed to reset session:', error);
}
}, []);
const handleOpenProject = useCallback(() => {
const currentSession = migrationSessionManager.getSession();
if (currentSession?.target.path) {
onComplete(currentSession.target.path);
}
}, [onComplete]);
// ==========================================================================
// Render
// ==========================================================================
// Get current session from manager (source of truth)
const session = migrationSessionManager.getSession();
if (!session) {
return null; // Session not initialized yet
}
const currentStep = session.step;
const stepIndex = getStepNumber(currentStep);
const totalSteps = getTotalSteps(false); // No AI for now
const renderStep = () => {
switch (currentStep) {
case 'confirm':
return (
<ConfirmStep
sourcePath={sourcePath}
projectName={projectName}
targetPath={session.target.path}
onUpdateTargetPath={handleUpdateTargetPath}
onNext={handleStartScan}
onCancel={onCancel}
loading={state.loading}
/>
);
case 'scanning':
return (
<ScanningStep
sourcePath={sourcePath}
targetPath={session.target.path}
/>
);
case 'report':
return (
<ReportStep
scan={session.scan!}
onMigrateWithoutAi={() => handleStartMigration(false)}
onMigrateWithAi={() => handleStartMigration(true)}
onCancel={onCancel}
/>
);
case 'migrating':
return (
<ScanningStep
sourcePath={sourcePath}
targetPath={session.target.path}
isMigrating
progress={session.progress}
/>
);
case 'complete':
return (
<CompleteStep
result={session.result!}
sourcePath={sourcePath}
targetPath={session.target.path}
onOpenProject={handleOpenProject}
/>
);
case 'failed':
return (
<FailedStep
error={state.error}
onRetry={handleRetry}
onCancel={onCancel}
/>
);
default:
return null;
}
};
return (
<CoreBaseDialog isVisible hasBackdrop onClose={onCancel}>
<div className={css['WizardContainer']}>
{/* Close Button */}
<div className={css['CloseButton']}>
<IconButton
icon={IconName.Close}
onClick={onCancel}
variant={IconButtonVariant.Transparent}
/>
</div>
{/* Header */}
<div className={css['WizardHeader']}>
<Title size={TitleSize.Large} variant={TitleVariant.Highlighted}>
Migrate Project to React 19
</Title>
<Text textType={TextType.Secondary}>{getStepLabel(currentStep)}</Text>
</div>
{/* Content */}
<div className={css['WizardContent']}>
<WizardProgress
currentStep={stepIndex}
totalSteps={totalSteps}
stepLabels={['Confirm', 'Scan', 'Report', 'Migrate', 'Complete']}
/>
<div className={css['StepContainer']}>
{renderStep()}
</div>
</div>
</div>
</CoreBaseDialog>
);
}
export default MigrationWizard;

View File

@@ -0,0 +1,78 @@
/**
* WizardProgress Styles
*
* Step progress indicator for migration wizard.
*/
.Root {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.Step {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
position: relative;
}
.StepCircle {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-secondary-as-fg);
transition: background-color 0.2s, color 0.2s;
}
.Step.is-completed .StepCircle {
background-color: var(--theme-color-success);
color: white;
}
.Step.is-active .StepCircle {
background-color: var(--theme-color-primary);
color: white;
}
.StepLabel {
font-size: 10px;
color: var(--theme-color-secondary-as-fg);
text-align: center;
max-width: 60px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.Step.is-completed .StepLabel,
.Step.is-active .StepLabel {
color: var(--theme-color-fg-highlight);
}
.Connector {
width: 24px;
height: 2px;
background-color: var(--theme-color-bg-2);
margin-bottom: 20px;
transition: background-color 0.2s;
}
.Connector.is-completed {
background-color: var(--theme-color-success);
}
.CheckIcon {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,77 @@
/**
* WizardProgress
*
* A visual progress indicator showing the current step in the migration wizard.
*
* @module noodl-editor/views/migration/components
* @since 1.2.0
*/
import classNames from 'classnames';
import React from 'react';
import css from './WizardProgress.module.scss';
export interface WizardProgressProps {
/** Current step index (1-indexed) */
currentStep: number;
/** Total number of steps */
totalSteps: number;
/** Labels for each step */
stepLabels: string[];
}
export function WizardProgress({ currentStep, totalSteps, stepLabels }: WizardProgressProps) {
return (
<div className={css['Root']}>
<div className={css['Steps']}>
{stepLabels.map((label, index) => {
const stepNumber = index + 1;
const isActive = stepNumber === currentStep;
const isCompleted = stepNumber < currentStep;
return (
<div
key={label}
className={classNames(
css['Step'],
isActive && css['is-active'],
isCompleted && css['is-completed']
)}
>
<div className={css['StepIndicator']}>
{isCompleted ? (
<svg viewBox="0 0 16 16" className={css['CheckIcon']}>
<path
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
) : (
<span>{stepNumber}</span>
)}
</div>
<span className={css['StepLabel']}>{label}</span>
{index < stepLabels.length - 1 && (
<div
className={classNames(
css['StepConnector'],
isCompleted && css['is-completed']
)}
/>
)}
</div>
);
})}
</div>
<div className={css['ProgressBar']}>
<div
className={css['ProgressFill']}
style={{ width: `${((currentStep - 1) / (totalSteps - 1)) * 100}%` }}
/>
</div>
</div>
);
}
export default WizardProgress;

View File

@@ -0,0 +1,168 @@
/**
* CompleteStep Styles
*
* Final step showing migration summary.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
svg {
color: var(--theme-color-success);
}
}
.Stats {
display: flex;
gap: 12px;
margin-top: 16px;
}
.StatCard {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
text-align: center;
}
.StatCardIcon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.StatCard.is-success .StatCardIcon {
color: var(--theme-color-success);
}
.StatCard.is-warning .StatCardIcon {
color: var(--theme-color-warning);
}
.StatCard.is-error .StatCardIcon {
color: var(--theme-color-danger);
}
.StatCardValue {
font-size: 24px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
.StatCardLabel {
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.MetaInfo {
display: flex;
gap: 16px;
margin-top: 12px;
}
.MetaItem {
display: flex;
align-items: center;
gap: 6px;
color: var(--theme-color-secondary-as-fg);
svg {
width: 14px;
height: 14px;
}
}
.Paths {
margin-top: 16px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.PathItem {
display: flex;
gap: 12px;
margin-top: 12px;
svg {
width: 16px;
height: 16px;
color: var(--theme-color-secondary-as-fg);
flex-shrink: 0;
margin-top: 2px;
}
&:first-of-type {
margin-top: 8px;
}
}
.PathContent {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
span:last-child {
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
}
.NextSteps {
margin-top: 16px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.StepsList {
list-style: none;
padding: 0;
margin: 8px 0 0 0;
li {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--theme-color-bg-2);
&:last-child {
border-bottom: none;
}
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 2px;
color: var(--theme-color-secondary-as-fg);
}
}
}
.Actions {
margin-top: auto;
padding-top: 24px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,304 @@
/**
* CompleteStep
*
* Step 5 of the migration wizard: Shows final summary.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React from 'react';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationResult } from '../../../models/migration/types';
import css from './CompleteStep.module.scss';
export interface CompleteStepProps {
/** Migration result */
result: MigrationResult;
/** Path to the source project */
sourcePath: string;
/** Path to the migrated project */
targetPath: string;
/** Called when user wants to open the migrated project */
onOpenProject: () => void;
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${seconds}s`;
}
export function CompleteStep({
result,
sourcePath,
targetPath,
onOpenProject
}: CompleteStepProps) {
const hasIssues = result.needsReview > 0;
return (
<div className={css['Root']}>
<VStack hasSpacing>
{/* Header */}
<div className={css['Header']}>
{hasIssues ? <CheckWarningIcon /> : <CheckCircleIcon />}
<Title size={TitleSize.Medium}>
{hasIssues ? 'Migration Complete (With Notes)' : 'Migration Complete!'}
</Title>
</div>
<Text textType={TextType.Secondary}>
Your project has been migrated to React 19. The original project remains untouched.
</Text>
{/* Stats */}
<div className={css['Stats']}>
<StatCard
icon={<CheckIcon />}
value={result.migrated}
label="Migrated"
variant="success"
/>
{result.needsReview > 0 && (
<StatCard
icon={<WarningIcon />}
value={result.needsReview}
label="Needs Review"
variant="warning"
/>
)}
{result.failed > 0 && (
<StatCard
icon={<ErrorIcon />}
value={result.failed}
label="Failed"
variant="error"
/>
)}
</div>
{/* Duration and Cost */}
<div className={css['MetaInfo']}>
<div className={css['MetaItem']}>
<ClockIcon />
<Text size={TextSize.Small}>Time: {formatDuration(result.duration)}</Text>
</div>
{result.totalCost > 0 && (
<div className={css['MetaItem']}>
<RobotIcon />
<Text size={TextSize.Small}>AI cost: ${result.totalCost.toFixed(2)}</Text>
</div>
)}
</div>
{/* Project Paths */}
<div className={css['Paths']}>
<Title size={TitleSize.Small}>Project Locations</Title>
<div className={css['PathItem']}>
<LockIcon />
<div className={css['PathContent']}>
<Text size={TextSize.Small} textType={TextType.Shy}>Original (untouched)</Text>
<Text size={TextSize.Small}>{sourcePath}</Text>
</div>
</div>
<div className={css['PathItem']}>
<FolderIcon />
<div className={css['PathContent']}>
<Text size={TextSize.Small} textType={TextType.Shy}>Migrated copy</Text>
<Text size={TextSize.Small}>{targetPath}</Text>
</div>
</div>
</div>
{/* What's Next */}
<div className={css['NextSteps']}>
<Title size={TitleSize.Small}>What&apos;s Next?</Title>
<ol className={css['StepsList']}>
{result.needsReview > 0 && (
<li>
<WarningIcon />
<Text size={TextSize.Small}>
Components marked with have notes in the component panel -
click to see migration details
</Text>
</li>
)}
<li>
<CheckIcon />
<Text size={TextSize.Small}>
Test your app thoroughly before deploying
</Text>
</li>
<li>
<TrashIcon />
<Text size={TextSize.Small}>
Once confirmed working, you can archive or delete the original folder
</Text>
</li>
</ol>
</div>
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Open Migrated Project"
onClick={onOpenProject}
/>
</HStack>
</div>
</div>
);
}
// =============================================================================
// Sub-Components
// =============================================================================
interface StatCardProps {
icon: React.ReactNode;
value: number;
label: string;
variant: 'success' | 'warning' | 'error';
}
function StatCard({ icon, value, label, variant }: StatCardProps) {
return (
<div className={`${css['StatCard']} ${css[`is-${variant}`]}`}>
<div className={css['StatCardIcon']}>{icon}</div>
<div className={css['StatCardValue']}>{value}</div>
<div className={css['StatCardLabel']}>{label}</div>
</div>
);
}
// =============================================================================
// Icons
// =============================================================================
function CheckCircleIcon() {
return (
<svg viewBox="0 0 16 16" width={32} height={32}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
fill="currentColor"
/>
</svg>
);
}
function CheckWarningIcon() {
return (
<svg viewBox="0 0 16 16" width={32} height={32}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
fill="currentColor"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
);
}
function WarningIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
);
}
function ErrorIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
fill="currentColor"
/>
</svg>
);
}
function ClockIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 0a8 8 0 108 8A8 8 0 008 0zm0 14.5A6.5 6.5 0 1114.5 8 6.5 6.5 0 018 14.5zM8 3.5a.75.75 0 01.75.75V8h2.5a.75.75 0 110 1.5H8a.75.75 0 01-.75-.75V4.25A.75.75 0 018 3.5z"
fill="currentColor"
/>
</svg>
);
}
function RobotIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 0a1 1 0 011 1v1.5h2A2.5 2.5 0 0113.5 5v6a2.5 2.5 0 01-2.5 2.5h-6A2.5 2.5 0 012.5 11V5A2.5 2.5 0 015 2.5h2V1a1 1 0 011-1zM5 4a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V5a1 1 0 00-1-1H5zm1.5 2a1 1 0 110 2 1 1 0 010-2zm3 0a1 1 0 110 2 1 1 0 010-2zM6 8.5a.5.5 0 000 1h4a.5.5 0 000-1H6z"
fill="currentColor"
/>
</svg>
);
}
function LockIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
fill="currentColor"
/>
</svg>
);
}
function FolderIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
fill="currentColor"
/>
</svg>
);
}
function TrashIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"
fill="currentColor"
/>
</svg>
);
}
export default CompleteStep;

View File

@@ -0,0 +1,172 @@
/**
* ConfirmStep Styles
*
* First step of migration wizard - confirm source and target paths.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
svg {
color: var(--theme-color-primary);
}
}
.PathSection {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.PathHeader {
display: flex;
align-items: center;
gap: 8px;
}
.LockIcon,
.FolderIcon {
color: var(--theme-color-secondary-as-fg);
}
.PathFields {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.PathField {
display: flex;
flex-direction: column;
gap: 8px;
}
.PathLabel {
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-color-secondary-as-fg);
svg {
width: 14px;
height: 14px;
}
}
.PathDisplay {
display: flex;
flex-direction: column;
gap: 4px;
}
.PathText {
font-family: monospace;
font-size: 12px;
word-break: break-all;
color: var(--theme-color-fg-highlight);
}
.ProjectName {
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
}
.PathValue {
padding: 8px 12px;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
font-family: monospace;
font-size: 12px;
color: var(--theme-color-fg-highlight);
word-break: break-all;
}
.PathInput {
input {
font-family: monospace;
font-size: 12px;
}
}
.PathError {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.Arrow {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
color: var(--theme-color-secondary-as-fg);
}
.InfoBox {
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.StepsList {
margin: 8px 0 0 0;
padding-left: 20px;
color: var(--theme-color-fg-default);
li {
margin-bottom: 4px;
}
}
.WarningBox {
display: flex;
gap: 12px;
padding: 12px 16px;
background-color: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 8px;
margin-top: 16px;
svg {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--theme-color-warning);
}
}
.WarningContent {
display: flex;
flex-direction: column;
gap: 4px;
}
.WarningTitle {
font-weight: 500;
color: var(--theme-color-warning);
}
.Actions {
margin-top: auto;
padding-top: 24px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,203 @@
/**
* ConfirmStep
*
* Step 1 of the migration wizard: Confirm source and target paths.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React, { useState, useEffect, useCallback, ChangeEvent } from 'react';
import { FeedbackType } from '@noodl-constants/FeedbackType';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { filesystem } from '@noodl/platform';
import css from './ConfirmStep.module.scss';
export interface ConfirmStepProps {
/** Path to the source project */
sourcePath: string;
/** Name of the project */
projectName: string;
/** Current target path */
targetPath: string;
/** Called when target path changes */
onUpdateTargetPath: (path: string) => void;
/** Called when user proceeds to next step */
onNext: () => void;
/** Called when user cancels the wizard */
onCancel: () => void;
/** Whether the wizard is loading */
loading?: boolean;
}
export function ConfirmStep({
sourcePath,
projectName,
targetPath,
onUpdateTargetPath,
onNext,
onCancel,
loading = false
}: ConfirmStepProps) {
const [targetExists, setTargetExists] = useState(false);
const [checkingPath, setCheckingPath] = useState(false);
// Check if target path exists
const checkTargetPath = useCallback(async (path: string) => {
if (!path) {
setTargetExists(false);
return;
}
setCheckingPath(true);
try {
const exists = await filesystem.exists(path);
setTargetExists(exists);
} catch {
setTargetExists(false);
} finally {
setCheckingPath(false);
}
}, []);
useEffect(() => {
checkTargetPath(targetPath);
}, [targetPath, checkTargetPath]);
const handleTargetChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onUpdateTargetPath(e.target.value);
},
[onUpdateTargetPath]
);
const handleUseUniqueName = useCallback(() => {
const timestamp = Date.now();
const uniquePath = `${sourcePath}-react19-${timestamp}`;
onUpdateTargetPath(uniquePath);
}, [sourcePath, onUpdateTargetPath]);
const canProceed = targetPath && !targetExists && !loading && !checkingPath;
return (
<div className={css['Root']}>
<VStack hasSpacing>
<Box hasBottomSpacing>
<Text>
We&apos;ll create a safe copy of your project before making any changes.
Your original project will remain untouched.
</Text>
</Box>
{/* Source Project (Read-only) */}
<div className={css['PathSection']}>
<div className={css['PathHeader']}>
<svg
viewBox="0 0 16 16"
className={css['LockIcon']}
width={16}
height={16}
>
<path
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
fill="currentColor"
/>
</svg>
<Title size={TitleSize.Small}>Original Project (will not be modified)</Title>
</div>
<div className={css['PathDisplay']}>
<Text className={css['PathText']}>{sourcePath}</Text>
<Text className={css['ProjectName']}>{projectName}</Text>
</div>
</div>
{/* Arrow */}
<div className={css['Arrow']}>
<svg viewBox="0 0 16 16" width={20} height={20}>
<path
d="M8 2a.75.75 0 01.75.75v8.69l2.22-2.22a.75.75 0 111.06 1.06l-3.5 3.5a.75.75 0 01-1.06 0l-3.5-3.5a.75.75 0 111.06-1.06l2.22 2.22V2.75A.75.75 0 018 2z"
fill="currentColor"
/>
</svg>
<Text size={TextSize.Small}>Creates copy</Text>
</div>
{/* Target Path (Editable) */}
<div className={css['PathSection']}>
<div className={css['PathHeader']}>
<svg
viewBox="0 0 16 16"
className={css['FolderIcon']}
width={16}
height={16}
>
<path
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
fill="currentColor"
/>
</svg>
<Title size={TitleSize.Small}>Migrated Copy Location</Title>
</div>
<TextInput
value={targetPath}
onChange={handleTargetChange}
UNSAFE_className={css['PathInput']}
/>
{targetExists && (
<div className={css['PathError']}>
<Text textType={FeedbackType.Danger}>
A folder already exists at this location.
</Text>
<PrimaryButton
label="Use Different Name"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={handleUseUniqueName}
/>
</div>
)}
</div>
{/* What happens next */}
<Box hasTopSpacing>
<div className={css['InfoBox']}>
<Title size={TitleSize.Small}>What happens next:</Title>
<ol className={css['StepsList']}>
<li>Your project will be copied to the new location</li>
<li>We&apos;ll scan for compatibility issues</li>
<li>You&apos;ll see a report of what needs to change</li>
<li>Automatic fixes will be applied</li>
</ol>
</div>
</Box>
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Cancel"
variant={PrimaryButtonVariant.Muted}
onClick={onCancel}
isDisabled={loading}
/>
<PrimaryButton
label={loading ? 'Starting...' : 'Start Migration'}
onClick={onNext}
isDisabled={!canProceed}
/>
</HStack>
</div>
</div>
);
}
export default ConfirmStep;

View File

@@ -0,0 +1,128 @@
/**
* FailedStep Styles
*
* Error state when migration fails.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.ErrorCircleIcon {
color: var(--theme-color-danger);
}
.DescriptionText {
color: var(--theme-color-secondary-as-fg);
}
.ErrorBox {
margin-top: 16px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
overflow: hidden;
}
.ErrorHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: rgba(239, 68, 68, 0.15);
svg {
color: var(--theme-color-danger);
}
}
.ErrorText {
color: var(--theme-color-danger);
font-weight: 500;
}
.ErrorMessage {
padding: 12px 16px;
font-family: monospace;
font-size: 12px;
color: var(--theme-color-fg-highlight);
word-break: break-all;
}
.Suggestions {
margin-top: 16px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.SuggestionList {
list-style: none;
padding: 0;
margin: 12px 0 0 0;
li {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--theme-color-bg-2);
&:last-child {
border-bottom: none;
}
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 2px;
color: var(--theme-color-secondary-as-fg);
}
}
}
.Link {
color: var(--theme-color-primary);
text-decoration: underline;
&:hover {
opacity: 0.8;
}
}
.SafetyNotice {
display: flex;
gap: 12px;
margin-top: 16px;
padding: 12px 16px;
background-color: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--theme-color-success);
}
}
.SafetyText {
color: var(--theme-color-secondary-as-fg);
}
.Actions {
margin-top: auto;
padding-top: 24px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,225 @@
/**
* FailedStep
*
* Step shown when migration fails. Allows user to retry or cancel.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React from 'react';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import css from './FailedStep.module.scss';
export interface FailedStepProps {
/** The error that caused the failure */
error: Error | null;
/** Called when user wants to retry */
onRetry: () => void;
/** Called when user wants to cancel */
onCancel: () => void;
}
export function FailedStep({
error,
onRetry,
onCancel
}: FailedStepProps) {
const errorMessage = error?.message || 'An unknown error occurred during migration.';
const isNetworkError = errorMessage.toLowerCase().includes('network') ||
errorMessage.toLowerCase().includes('timeout');
const isPermissionError = errorMessage.toLowerCase().includes('permission') ||
errorMessage.toLowerCase().includes('access');
return (
<div className={css['Root']}>
<VStack hasSpacing>
{/* Header */}
<div className={css['Header']}>
<ErrorCircleIcon />
<Title size={TitleSize.Medium}>
Migration Failed
</Title>
</div>
<Text className={css['DescriptionText']}>
Something went wrong during the migration process. Your original project is safe and unchanged.
</Text>
{/* Error Details */}
<div className={css['ErrorBox']}>
<div className={css['ErrorHeader']}>
<ErrorIcon />
<Text className={css['ErrorText']}>Error Details</Text>
</div>
<div className={css['ErrorMessage']}>
<Text size={TextSize.Small}>{errorMessage}</Text>
</div>
</div>
{/* Suggestions */}
<div className={css['Suggestions']}>
<Title size={TitleSize.Small}>What you can try:</Title>
<ul className={css['SuggestionList']}>
{isNetworkError && (
<li>
<WifiIcon />
<Text size={TextSize.Small}>Check your internet connection and try again</Text>
</li>
)}
{isPermissionError && (
<li>
<LockIcon />
<Text size={TextSize.Small}>Make sure you have write access to the target directory</Text>
</li>
)}
<li>
<RefreshIcon />
<Text size={TextSize.Small}>Click &quot;Try Again&quot; to restart the migration</Text>
</li>
<li>
<FolderIcon />
<Text size={TextSize.Small}>Try choosing a different target directory</Text>
</li>
<li>
<HelpIcon />
<Text size={TextSize.Small}>
If the problem persists, check the{' '}
<a
href="https://github.com/The-Low-Code-Foundation/OpenNoodl/issues"
target="_blank"
rel="noopener noreferrer"
className={css['Link']}
>
GitHub Issues
</a>
</Text>
</li>
</ul>
</div>
{/* Safety Notice */}
<div className={css['SafetyNotice']}>
<ShieldIcon />
<Text size={TextSize.Small} className={css['SafetyText']}>
Your original project remains untouched. Any partial migration files have been cleaned up.
</Text>
</div>
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Cancel"
variant={PrimaryButtonVariant.Muted}
onClick={onCancel}
/>
<PrimaryButton
label="Try Again"
onClick={onRetry}
/>
</HStack>
</div>
</div>
);
}
// =============================================================================
// Icons
// =============================================================================
function ErrorCircleIcon() {
return (
<svg viewBox="0 0 16 16" width={32} height={32} className={css['ErrorCircleIcon']}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm0-11.5a.75.75 0 01.75.75v3.5a.75.75 0 11-1.5 0v-3.5A.75.75 0 018 4.5zm0 8a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
);
}
function ErrorIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
fill="currentColor"
/>
</svg>
);
}
function WifiIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 12a1.5 1.5 0 100 3 1.5 1.5 0 000-3zM1.332 5.084a.75.75 0 10.97 1.142 9.5 9.5 0 0113.396 0 .75.75 0 00.97-1.142 11 11 0 00-15.336 0zm2.91 2.908a.75.75 0 10.97 1.142 5.5 5.5 0 017.576 0 .75.75 0 00.97-1.142 7 7 0 00-9.516 0z"
fill="currentColor"
/>
</svg>
);
}
function LockIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
fill="currentColor"
/>
</svg>
);
}
function RefreshIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 3a5 5 0 00-4.546 2.914.5.5 0 01-.908-.414A6 6 0 0113.944 5H12.5a.5.5 0 010-1h3a.5.5 0 01.5.5v3a.5.5 0 11-1 0V6.057A5.956 5.956 0 018 3zM0 8a.5.5 0 01.5-.5h1a.5.5 0 010 1H.5A.5.5 0 010 8zm1.5 2.5a.5.5 0 01.5.5v1.443A5.956 5.956 0 008 13a5 5 0 004.546-2.914.5.5 0 01.908.414A6 6 0 012.056 11H3.5a.5.5 0 010 1h-3a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5z"
fill="currentColor"
/>
</svg>
);
}
function FolderIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
fill="currentColor"
/>
</svg>
);
}
function HelpIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 0a8 8 0 108 8A8 8 0 008 0zm0 14.5A6.5 6.5 0 1114.5 8 6.5 6.5 0 018 14.5zm0-10.25a2.25 2.25 0 00-2.25 2.25.75.75 0 001.5 0 .75.75 0 111.5 0c0 .52-.3.866-.658 1.075-.368.216-.842.425-.842 1.175a.75.75 0 001.5 0c0-.15.099-.282.282-.394.187-.114.486-.291.727-.524A2.25 2.25 0 008 4.25zM8 13a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
);
}
function ShieldIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M7.467.133a1.75 1.75 0 011.066 0l5.25 1.68A1.75 1.75 0 0115 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.7 1.7 0 01-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 011.217-1.667l5.25-1.68zm.61 1.429a.25.25 0 00-.153 0l-5.25 1.68a.25.25 0 00-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.2.2 0 00.154 0c2.245-.956 3.582-2.103 4.366-3.298C13.225 9.666 13.5 8.358 13.5 7V3.48a.25.25 0 00-.174-.238l-5.25-1.68zM11.28 6.28a.75.75 0 00-1.06-1.06L7.25 8.19 5.78 6.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l3.5-3.5z"
fill="currentColor"
/>
</svg>
);
}
export default FailedStep;

View File

@@ -0,0 +1,215 @@
/**
* ReportStep Styles
*
* Scan results report with categories and AI options.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
svg {
color: var(--theme-color-primary);
}
}
.StatsRow {
display: flex;
gap: 12px;
margin-top: 16px;
}
.StatCard {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
text-align: center;
}
.StatCardIcon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-color-secondary-as-fg);
}
.StatCard.is-automatic .StatCardIcon {
color: var(--theme-color-success);
}
.StatCard.is-simpleFixes .StatCardIcon {
color: var(--theme-color-warning);
}
.StatCard.is-needsReview .StatCardIcon {
color: var(--theme-color-danger);
}
.StatCardValue {
font-size: 24px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
.StatCardLabel {
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.Categories {
flex: 1;
overflow-y: auto;
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.CategorySection {
background-color: var(--theme-color-bg-3);
border-radius: 8px;
overflow: hidden;
}
.CategoryHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: var(--theme-color-bg-2);
cursor: pointer;
&:hover {
background-color: var(--theme-color-bg-1);
}
}
.CategoryIcon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.CategorySection.is-automatic .CategoryIcon {
color: var(--theme-color-success);
}
.CategorySection.is-simpleFixes .CategoryIcon {
color: var(--theme-color-warning);
}
.CategorySection.is-needsReview .CategoryIcon {
color: var(--theme-color-danger);
}
.CategoryTitle {
flex: 1;
font-weight: 500;
}
.CategoryCount {
background-color: var(--theme-color-bg-1);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
}
.ExpandIcon {
width: 16px;
height: 16px;
color: var(--theme-color-secondary-as-fg);
transition: transform 0.2s ease;
}
.CategorySection.is-expanded .ExpandIcon {
transform: rotate(180deg);
}
.ComponentList {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
max-height: 200px;
overflow-y: auto;
}
.ComponentItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
&:hover {
background-color: var(--theme-color-bg-1);
}
}
.ComponentName {
font-size: 13px;
}
.ComponentIssueCount {
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
}
.AiPromptSection {
margin-top: 16px;
padding: 16px;
background-color: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 8px;
}
.AiPromptHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
svg {
width: 20px;
height: 20px;
color: #8b5cf6; // AI purple
}
}
.AiPromptTitle {
font-weight: 500;
color: #8b5cf6; // AI purple
}
.AiPromptSection.is-disabled {
opacity: 0.6;
pointer-events: none;
}
.Actions {
margin-top: auto;
padding-top: 24px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,338 @@
/**
* ReportStep
*
* Step 3 of the migration wizard: Shows scan results by category.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React, { useState } from 'react';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationScan, ComponentMigrationInfo } from '../../../models/migration/types';
import css from './ReportStep.module.scss';
export interface ReportStepProps {
/** Scan results */
scan: MigrationScan;
/** Called when user chooses to migrate without AI */
onMigrateWithoutAi: () => void;
/** Called when user chooses to migrate with AI */
onMigrateWithAi: () => void;
/** Called when user cancels */
onCancel: () => void;
}
export function ReportStep({
scan,
onMigrateWithoutAi,
onMigrateWithAi,
onCancel
}: ReportStepProps) {
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
const { automatic, simpleFixes, needsReview } = scan.categories;
const totalIssues = simpleFixes.length + needsReview.length;
const allAutomatic = totalIssues === 0;
// Calculate estimated cost (placeholder - AI not yet implemented)
const estimatedCost = needsReview.length * 0.05 + simpleFixes.length * 0.02;
return (
<div className={css['Root']}>
<VStack hasSpacing>
<Text>
Analyzed {scan.totalComponents} components and {scan.totalNodes} nodes.
{scan.customJsFiles > 0 && ` Found ${scan.customJsFiles} custom JavaScript files.`}
</Text>
{/* Summary Stats */}
<div className={css['SummaryStats']}>
<StatCard
icon={<CheckCircleIcon />}
value={automatic.length}
label="Automatic"
variant="success"
/>
<StatCard
icon={<ZapIcon />}
value={simpleFixes.length}
label="Simple Fixes"
variant="info"
/>
<StatCard
icon={<ToolIcon />}
value={needsReview.length}
label="Needs Review"
variant="warning"
/>
</div>
{/* Category Sections */}
<div className={css['Categories']}>
{/* Automatic */}
<CategorySection
title="Automatic"
description="These components will migrate without any changes"
icon={<CheckCircleIcon />}
items={automatic}
variant="success"
expanded={expandedCategory === 'automatic'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'automatic' ? null : 'automatic')
}
/>
{/* Simple Fixes */}
{simpleFixes.length > 0 && (
<CategorySection
title="Simple Fixes"
description="Minor syntax updates needed"
icon={<ZapIcon />}
items={simpleFixes}
variant="info"
expanded={expandedCategory === 'simpleFixes'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'simpleFixes' ? null : 'simpleFixes')
}
showIssueDetails
/>
)}
{/* Needs Review */}
{needsReview.length > 0 && (
<CategorySection
title="Needs Review"
description="May require manual adjustment after migration"
icon={<ToolIcon />}
items={needsReview}
variant="warning"
expanded={expandedCategory === 'needsReview'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'needsReview' ? null : 'needsReview')
}
showIssueDetails
/>
)}
</div>
{/* AI Prompt (if there are issues) */}
{!allAutomatic && (
<div className={css['AiPrompt']}>
<div className={css['AiPromptIcon']}>
<RobotIcon />
</div>
<div className={css['AiPromptContent']}>
<Title size={TitleSize.Small}>AI-Assisted Migration (Coming Soon)</Title>
<Text textType={TextType.Secondary} size={TextSize.Small}>
Claude can help automatically fix the {totalIssues} components that need
code changes. Estimated cost: ~${estimatedCost.toFixed(2)}
</Text>
</div>
</div>
)}
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Cancel"
variant={PrimaryButtonVariant.Muted}
onClick={onCancel}
/>
<PrimaryButton
label={allAutomatic ? 'Migrate Project' : 'Migrate (Auto Only)'}
onClick={onMigrateWithoutAi}
/>
{!allAutomatic && (
<PrimaryButton
label="Migrate with AI"
onClick={onMigrateWithAi}
isDisabled // AI not yet implemented
/>
)}
</HStack>
</div>
</div>
);
}
// =============================================================================
// Sub-Components
// =============================================================================
interface StatCardProps {
icon: React.ReactNode;
value: number;
label: string;
variant: 'success' | 'info' | 'warning';
}
function StatCard({ icon, value, label, variant }: StatCardProps) {
return (
<div className={`${css['StatCard']} ${css[`is-${variant}`]}`}>
<div className={css['StatCardIcon']}>{icon}</div>
<div className={css['StatCardValue']}>{value}</div>
<div className={css['StatCardLabel']}>{label}</div>
</div>
);
}
interface CategorySectionProps {
title: string;
description: string;
icon: React.ReactNode;
items: ComponentMigrationInfo[];
variant: 'success' | 'info' | 'warning';
expanded: boolean;
onToggle: () => void;
showIssueDetails?: boolean;
}
function CategorySection({
title,
description,
icon,
items,
variant,
expanded,
onToggle,
showIssueDetails = false
}: CategorySectionProps) {
if (items.length === 0) return null;
return (
<div className={`${css['CategorySection']} ${css[`is-${variant}`]}`}>
<button className={css['CategoryHeader']} onClick={onToggle}>
<div className={css['CategoryHeaderLeft']}>
{icon}
<div>
<Text textType={TextType.Proud}>
{title} ({items.length})
</Text>
<Text textType={TextType.Secondary} size={TextSize.Small}>
{description}
</Text>
</div>
</div>
<ChevronIcon direction={expanded ? 'up' : 'down'} />
</button>
<Collapsible isCollapsed={!expanded}>
<div className={css['CategoryItems']}>
{items.map((item) => (
<div key={item.id} className={css['CategoryItem']}>
<ComponentIcon />
<div className={css['CategoryItemInfo']}>
<Text size={TextSize.Small}>{item.name}</Text>
{showIssueDetails && item.issues.length > 0 && (
<ul className={css['IssuesList']}>
{item.issues.map((issue) => (
<li key={issue.id}>
<code>{issue.type}</code>
<Text size={TextSize.Small} isSpan textType={TextType.Secondary}>
{' '}{issue.description}
</Text>
</li>
))}
</ul>
)}
</div>
{item.estimatedCost !== undefined && (
<Text size={TextSize.Small} textType={TextType.Shy}>
~${item.estimatedCost.toFixed(2)}
</Text>
)}
</div>
))}
</div>
</Collapsible>
</div>
);
}
// =============================================================================
// Icons
// =============================================================================
function CheckCircleIcon() {
return (
<svg viewBox="0 0 16 16" width={16} height={16}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
fill="currentColor"
/>
</svg>
);
}
function ZapIcon() {
return (
<svg viewBox="0 0 16 16" width={16} height={16}>
<path
d="M9.504.43a.75.75 0 01.397.696L9.223 5h4.027a.75.75 0 01.577 1.22l-5.25 6.25a.75.75 0 01-1.327-.55l.678-4.42H3.902a.75.75 0 01-.577-1.22l5.25-6.25a.75.75 0 01.93-.18z"
fill="currentColor"
/>
</svg>
);
}
function ToolIcon() {
return (
<svg viewBox="0 0 16 16" width={16} height={16}>
<path
d="M5.433 2.304A4.492 4.492 0 003.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64l-.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 003.5 6a5.99 5.99 0 012.433-4.864.75.75 0 011.134.64v3.046l.5.865.5-.865V1.776a.75.75 0 011.134-.64A5.99 5.99 0 0111.5 6a4.495 4.495 0 01-.922 3.802c-.518.328-.929.923-.902 1.64l.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 007.5 6c0-.54-.185-1.061-.433-1.548"
fill="currentColor"
/>
</svg>
);
}
function RobotIcon() {
return (
<svg viewBox="0 0 16 16" width={24} height={24}>
<path
d="M8 0a1 1 0 011 1v1.5h2A2.5 2.5 0 0113.5 5v6a2.5 2.5 0 01-2.5 2.5h-6A2.5 2.5 0 012.5 11V5A2.5 2.5 0 015 2.5h2V1a1 1 0 011-1zM5 4a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V5a1 1 0 00-1-1H5zm1.5 2a1 1 0 110 2 1 1 0 010-2zm3 0a1 1 0 110 2 1 1 0 010-2zM6 8.5a.5.5 0 000 1h4a.5.5 0 000-1H6z"
fill="currentColor"
/>
</svg>
);
}
function ComponentIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8.186 1.113a.5.5 0 00-.372 0L1.814 3.5l-.372.149v8.702l.372.149 6 2.387a.5.5 0 00.372 0l6-2.387.372-.149V3.649l-.372-.149-6-2.387zM8 2.123l4.586 1.828L8 5.778 3.414 3.95 8 2.123zm-5.5 2.89l5 1.992v6.372l-5-1.992V5.013zm6.5 8.364V7.005l5-1.992v6.372l-5 1.992z"
fill="currentColor"
/>
</svg>
);
}
function ChevronIcon({ direction }: { direction: 'up' | 'down' }) {
return (
<svg
viewBox="0 0 16 16"
width={16}
height={16}
style={{ transform: direction === 'up' ? 'rotate(180deg)' : undefined }}
>
<path
d="M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
);
}
export default ReportStep;

View File

@@ -0,0 +1,154 @@
/**
* ScanningStep Styles
*
* Scanning/migrating progress display.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
gap: 16px;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
svg {
color: var(--theme-color-primary);
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ProgressSection {
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.ProgressHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ProgressBar {
height: 8px;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
overflow: hidden;
}
.ProgressFill {
height: 100%;
background-color: var(--theme-color-primary);
border-radius: 4px;
transition: width 0.3s ease;
}
.ActivityLog {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
overflow: hidden;
}
.ActivityHeader {
padding: 12px 16px;
border-bottom: 1px solid var(--theme-color-bg-2);
display: flex;
align-items: center;
gap: 8px;
}
.ActivityList {
flex: 1;
overflow-y: auto;
padding: 8px;
max-height: 200px;
}
.ActivityItem {
display: flex;
gap: 8px;
padding: 6px 8px;
font-size: 12px;
border-radius: 4px;
animation: fadeIn 0.2s ease;
&.is-info {
color: var(--theme-color-secondary-as-fg);
}
&.is-success {
color: var(--theme-color-success);
}
&.is-warning {
color: var(--theme-color-warning);
}
&.is-error {
color: var(--theme-color-danger);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ActivityTime {
color: var(--theme-color-secondary-as-fg);
font-family: monospace;
flex-shrink: 0;
}
.ActivityMessage {
flex: 1;
}
.EmptyActivity {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100px;
color: var(--theme-color-secondary-as-fg);
}
.InfoBox {
display: flex;
gap: 12px;
padding: 12px 16px;
background-color: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--theme-color-primary);
}
}

View File

@@ -0,0 +1,186 @@
/**
* ScanningStep
*
* Step 2 of the migration wizard: Shows progress while copying and scanning.
* Also used during the migration phase (step 4).
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React from 'react';
import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationProgress } from '../../../models/migration/types';
import css from './ScanningStep.module.scss';
export interface ScanningStepProps {
/** Path to the source project */
sourcePath: string;
/** Path to the target project */
targetPath: string;
/** Whether we're in migration phase (vs scanning phase) */
isMigrating?: boolean;
/** Progress information (for migration phase) */
progress?: MigrationProgress;
}
export function ScanningStep({
sourcePath: _sourcePath,
targetPath: _targetPath,
isMigrating = false,
progress
}: ScanningStepProps) {
// sourcePath and targetPath are available for future use (e.g., displaying paths)
void _sourcePath;
void _targetPath;
const title = isMigrating ? 'Migrating Project...' : 'Analyzing Project...';
const subtitle = isMigrating
? `Phase: ${getPhaseLabel(progress?.phase)}`
: 'Creating a safe copy before making any changes';
const progressPercent = progress
? Math.round((progress.current / progress.total) * 100)
: 0;
return (
<div className={css['Root']}>
<VStack hasSpacing>
<div className={css['Header']}>
<ActivityIndicator />
<Title size={TitleSize.Medium}>{title}</Title>
</div>
<Text textType={TextType.Secondary}>{subtitle}</Text>
{/* Progress Bar */}
<div className={css['ProgressSection']}>
<div className={css['ProgressBar']}>
<div
className={css['ProgressFill']}
style={{ width: `${progressPercent}%` }}
/>
</div>
{progress && (
<Text size={TextSize.Small} textType={TextType.Shy}>
{progress.current} / {progress.total} components
</Text>
)}
</div>
{/* Current Item */}
{progress?.currentComponent && (
<div className={css['CurrentItem']}>
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
fill="currentColor"
/>
</svg>
<Text size={TextSize.Small}>{progress.currentComponent}</Text>
</div>
)}
{/* Log Entries */}
{progress?.log && progress.log.length > 0 && (
<div className={css['LogSection']}>
<Title size={TitleSize.Small}>Activity Log</Title>
<div className={css['LogEntries']}>
{progress.log.slice(-5).map((entry, index) => (
<div
key={index}
className={`${css['LogEntry']} ${css[`is-${entry.level}`]}`}
>
<LogIcon level={entry.level} />
<div className={css['LogContent']}>
{entry.component && (
<Text
size={TextSize.Small}
textType={TextType.Proud}
isSpan
>
{entry.component}:{' '}
</Text>
)}
<Text size={TextSize.Small} isSpan>
{entry.message}
</Text>
</div>
</div>
))}
</div>
</div>
)}
{/* Info Box */}
<Box hasTopSpacing>
<div className={css['InfoBox']}>
<Text textType={TextType.Shy} size={TextSize.Small}>
{isMigrating
? 'Please wait while we migrate your project. This may take a few minutes for larger projects.'
: 'Scanning components for React 17 patterns that need updating...'}
</Text>
</div>
</Box>
</VStack>
</div>
);
}
// Helper Components
function LogIcon({ level }: { level: string }) {
const icons: Record<string, JSX.Element> = {
info: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm.93-9.412l-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287h.001zm-.043-3.33a.86.86 0 110 1.72.86.86 0 010-1.72z"
fill="currentColor"
/>
</svg>
),
success: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
),
warning: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
),
error: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
fill="currentColor"
/>
</svg>
)
};
return icons[level] || icons.info;
}
function getPhaseLabel(phase?: string): string {
const labels: Record<string, string> = {
copying: 'Copying files',
automatic: 'Applying automatic fixes',
'ai-assisted': 'AI-assisted migration',
finalizing: 'Finalizing'
};
return labels[phase || ''] || 'Starting';
}
export default ScanningStep;

View File

@@ -1,10 +1,14 @@
import React from 'react';
import { filesystem, platform } from '@noodl/platform';
import { DialogLayerModel } from '@noodl-models/DialogLayerModel';
import { LessonsProjectsModel } from '@noodl-models/LessonsProjectModel';
import { CloudServiceMetadata } from '@noodl-models/projectmodel';
import { setCloudServices } from '@noodl-models/projectmodel.editor';
import { LocalProjectsModel, ProjectItem } from '@noodl-utils/LocalProjectsModel';
import { LocalProjectsModel, ProjectItem, ProjectItemWithRuntime } from '@noodl-utils/LocalProjectsModel';
import { MigrationWizard } from './migration/MigrationWizard';
import View from '../../../shared/view';
import LessonTemplatesModel from '../models/lessontemplatesmodel';
@@ -29,6 +33,10 @@ type ProjectItemScope = {
project: ProjectItem;
label: string;
latestAccessedTimeAgo: string;
/** Whether the project uses legacy React 17 runtime */
isLegacy: boolean;
/** Whether runtime detection is in progress */
isDetecting: boolean;
};
export class ProjectsView extends View {
@@ -111,6 +119,9 @@ export class ProjectsView extends View {
this.renderProjectItemsPane();
this.projectsModel.on('myProjectsChanged', () => this.renderProjectItemsPane(), this);
// Re-render when runtime detection completes to update legacy indicators
this.projectsModel.on('runtimeDetectionComplete', () => this.renderProjectItemsPane(), this);
this.switchPane('projects');
@@ -274,27 +285,34 @@ export class ProjectsView extends View {
}) {
options = options || {};
const items = options.items;
const projectItems = options.items || [];
const projectItemsSelector = options.appendProjectItemsTo || '.projects-items';
const template = options.template || 'projects-item';
this.$(projectItemsSelector).html('');
for (const i in items) {
const label = items[i].name;
for (const item of projectItems) {
const label = item.name;
if (options.filter && label.toLowerCase().indexOf(options.filter) === -1) continue;
const latestAccessed = items[i].latestAccessed || Date.now();
const latestAccessed = item.latestAccessed || Date.now();
// Check if this is a legacy React 17 project
const projectPath = item.retainedProjectDirectory;
const isLegacy = projectPath ? this.projectsModel.isLegacyProject(projectPath) : false;
const isDetecting = projectPath ? this.projectsModel.isDetectingRuntime(projectPath) : false;
const scope: ProjectItemScope = {
project: items[i],
project: item,
label: label,
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago'
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago',
isLegacy,
isDetecting
};
const el = this.bindView(this.cloneTemplate(template), scope);
if (items[i].thumbURI) {
if (item.thumbURI) {
// Set the thumbnail image if there is one
View.$(el, '.projects-item-thumb').css('background-image', 'url(' + items[i].thumbURI + ')');
View.$(el, '.projects-item-thumb').css('background-image', 'url(' + item.thumbURI + ')');
} else {
// No thumbnail, show cloud download icon
View.$(el, '.projects-item-cloud-download').show();
@@ -302,6 +320,12 @@ export class ProjectsView extends View {
this.$(projectItemsSelector).append(el);
}
// Trigger background runtime detection for all projects
this.projectsModel.detectAllProjectRuntimes().then(() => {
// Re-render after detection completes (if any legacy projects found)
// The on('runtimeDetected') listener handles this
});
}
renderTutorialItems() {
@@ -633,6 +657,107 @@ export class ProjectsView extends View {
});
}
/**
* Called when user clicks "Migrate Project" on a legacy project card.
* Opens the migration wizard dialog.
*/
onMigrateProjectClicked(scope: ProjectItemScope, _el: unknown, evt: Event) {
evt.stopPropagation();
const projectPath = scope.project.retainedProjectDirectory;
if (!projectPath) {
ToastLayer.showError('Cannot migrate project: path not found');
return;
}
// Show the migration wizard as a dialog
DialogLayerModel.instance.showDialog(
(close) =>
React.createElement(MigrationWizard, {
sourcePath: projectPath,
projectName: scope.project.name,
onComplete: async (targetPath: string) => {
close();
// Clear runtime cache for the source project
this.projectsModel.clearRuntimeCache(projectPath);
// Show activity indicator
const activityId = 'opening-migrated-project';
ToastLayer.showActivity('Opening migrated project', activityId);
try {
// Open the migrated project from the target path
const project = await this.projectsModel.openProjectFromFolder(targetPath);
if (!project.name) {
project.name = scope.project.name + ' (React 19)';
}
ToastLayer.hideActivity(activityId);
ToastLayer.showSuccess('Project migrated successfully!');
// Open the migrated project
this.notifyListeners('projectLoaded', project);
} catch (error) {
ToastLayer.hideActivity(activityId);
ToastLayer.showError('Project migrated but could not open automatically. Check your projects list.');
console.error('Failed to open migrated project:', error);
// Refresh project list anyway
this.projectsModel.fetch();
}
},
onCancel: () => {
close();
}
}),
{
onClose: () => {
// Refresh project list when dialog closes
this.projectsModel.fetch();
}
}
);
tracker.track('Migration Wizard Opened', {
projectName: scope.project.name
});
}
/**
* Called when user clicks "Open Read-Only" on a legacy project card.
* Opens the project in read-only mode without migration.
* Note: The project will open normally; legacy banner display
* will be handled by the EditorBanner component based on runtime detection.
*/
async onOpenReadOnlyClicked(scope: ProjectItemScope, _el: unknown, evt: Event) {
evt.stopPropagation();
const activityId = 'opening-project-readonly';
ToastLayer.showActivity('Opening project in read-only mode', activityId);
try {
const project = await this.projectsModel.loadProject(scope.project);
ToastLayer.hideActivity(activityId);
if (!project) {
ToastLayer.showError("Couldn't load project.");
return;
}
tracker.track('Legacy Project Opened Read-Only', {
projectName: scope.project.name
});
// Open the project - the EditorBanner will detect legacy runtime
// and display a warning banner automatically
this.notifyListeners('projectLoaded', project);
} catch (error) {
ToastLayer.hideActivity(activityId);
ToastLayer.showError('Could not open project');
console.error('Failed to open legacy project:', error);
}
}
// Import a project from a URL
importFromUrl(uri) {
// Extract and remove query from url

View File

@@ -6,6 +6,15 @@ const URL = require('url');
var port = process.env.NOODL_CLOUD_FUNCTIONS_PORT || 8577;
// Safe console.log wrapper to prevent EPIPE errors when stdout is broken
function safeLog(...args) {
try {
console.log(...args);
} catch (e) {
// Ignore EPIPE errors - stdout pipe may be broken
}
}
function guid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
@@ -30,7 +39,7 @@ function openCloudRuntimeDevTools() {
mode: 'detach'
});
} else {
console.log('No cloud sandbox active');
safeLog('No cloud sandbox active');
}
}
@@ -62,7 +71,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
show: false
});
console.log('starting cloud runtime');
safeLog('starting cloud runtime');
hasLoadedProject = false;
@@ -103,9 +112,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
headers: args.headers
};
console.log('noodl-cf-fetch');
console.log(_options);
console.log(args.body);
safeLog('noodl-cf-fetch');
safeLog(_options);
safeLog(args.body);
const httpx = url.protocol === 'https:' ? https : http;
const req = httpx.request(_options, (res) => {
@@ -120,13 +129,13 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
body: _data,
status: res.statusCode
};
console.log('response', _response);
safeLog('response', _response);
sandbox.webContents.send('noodl-cf-fetch-response', _response);
});
});
req.on('error', (error) => {
console.log('error', error);
safeLog('error', error);
sandbox.webContents.send('noodl-cf-fetch-response', {
token,
error: error
@@ -161,9 +170,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
if (path.startsWith('/functions/')) {
const functionName = decodeURIComponent(path.split('/')[2]);
console.log('Calling cloud function: ' + functionName);
safeLog('Calling cloud function: ' + functionName);
if (!sandbox) {
console.log('Error: No cloud runtime active...');
safeLog('Error: No cloud runtime active...');
return;
}
@@ -184,7 +193,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
}
try {
console.log('with body ', body);
safeLog('with body ', body);
const token = guid();
_responseHandlers[token] = (args) => {
@@ -205,7 +214,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
queuedRequestsBeforeProjectLoaded.push(cfRequestArgs);
}
} catch (e) {
console.log(e);
safeLog(e);
response.writeHead(400, headers);
response.end(JSON.stringify({ error: 'Failed to run function.' }));
@@ -218,7 +227,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
var server;
if (process.env.ssl) {
console.log('Using SSL');
safeLog('Using SSL');
const options = {
key: fs.readFileSync(process.env.sslKey),
@@ -244,8 +253,8 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
});
});
server.on('listening', (e) => {
console.log('noodl cloud functions server running on port', port);
server.on('listening', () => {
safeLog('noodl cloud functions server running on port', port);
process.env.NOODL_CLOUD_FUNCTIONS_PORT = port;
});
}