Files
OpenNoodl/dev-docs/tasks/phase-2-react-migration/TASK-004-runtime-migration-system/01-PROJECT-DETECTION.md

15 KiB

01 - Project Detection and Visual Indicators

Overview

Detect legacy React 17 projects and display clear visual indicators throughout the UI so users understand which projects need migration.

Detection Logic

When to Check

  1. On app startup - Scan recent projects list
  2. On "Open Project" - Check selected folder
  3. On project list refresh - Re-scan visible projects

How to Detect Runtime Version

// packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts

interface RuntimeVersionInfo {
  version: 'react17' | 'react19' | 'unknown';
  confidence: 'high' | 'medium' | 'low';
  indicators: string[];
}

async function detectRuntimeVersion(projectPath: string): Promise<RuntimeVersionInfo> {
  const indicators: string[] = [];
  
  // Check 1: Explicit version in project.json (most reliable)
  const projectJson = await readProjectJson(projectPath);
  if (projectJson.runtimeVersion) {
    return {
      version: projectJson.runtimeVersion,
      confidence: 'high',
      indicators: ['Explicit runtimeVersion field in project.json']
    };
  }
  
  // Check 2: Look for migratedFrom field (indicates already migrated)
  if (projectJson.migratedFrom) {
    return {
      version: 'react19',
      confidence: 'high',
      indicators: ['Project has migratedFrom metadata']
    };
  }
  
  // Check 3: Check project version number
  // OpenNoodl 1.2+ = React 19, earlier = React 17
  const editorVersion = projectJson.editorVersion || projectJson.version;
  if (editorVersion) {
    const [major, minor] = editorVersion.split('.').map(Number);
    if (major >= 1 && minor >= 2) {
      indicators.push(`Editor version ${editorVersion} >= 1.2`);
      return { version: 'react19', confidence: 'high', indicators };
    } else {
      indicators.push(`Editor version ${editorVersion} < 1.2`);
      return { version: 'react17', confidence: 'high', indicators };
    }
  }
  
  // Check 4: Heuristic - scan for React 17 specific patterns in custom code
  const customCodePatterns = await scanForLegacyPatterns(projectPath);
  if (customCodePatterns.found) {
    indicators.push(...customCodePatterns.patterns);
    return { version: 'react17', confidence: 'medium', indicators };
  }
  
  // Check 5: If project was created before OpenNoodl fork, assume React 17
  const projectCreated = projectJson.createdAt || await getProjectCreationDate(projectPath);
  if (projectCreated && new Date(projectCreated) < new Date('2024-01-01')) {
    indicators.push('Project created before OpenNoodl fork');
    return { version: 'react17', confidence: 'medium', indicators };
  }
  
  // Default: Assume React 19 for truly unknown projects
  return {
    version: 'unknown',
    confidence: 'low',
    indicators: ['No version indicators found']
  };
}

Legacy Pattern Scanner

// Quick scan for legacy React patterns in JavaScript files

interface LegacyPatternScan {
  found: boolean;
  patterns: string[];
  files: Array<{ path: string; line: number; pattern: string; }>;
}

async function scanForLegacyPatterns(projectPath: string): Promise<LegacyPatternScan> {
  const jsFiles = await glob(`${projectPath}/**/*.{js,jsx,ts,tsx}`, {
    ignore: ['**/node_modules/**']
  });
  
  const legacyPatterns = [
    { regex: /componentWillMount\s*\(/, name: 'componentWillMount' },
    { regex: /componentWillReceiveProps\s*\(/, name: 'componentWillReceiveProps' },
    { regex: /componentWillUpdate\s*\(/, name: 'componentWillUpdate' },
    { regex: /UNSAFE_componentWillMount/, name: 'UNSAFE_componentWillMount' },
    { regex: /UNSAFE_componentWillReceiveProps/, name: 'UNSAFE_componentWillReceiveProps' },
    { regex: /UNSAFE_componentWillUpdate/, name: 'UNSAFE_componentWillUpdate' },
    { regex: /ref\s*=\s*["'][^"']+["']/, name: 'String ref' },
    { regex: /contextTypes\s*=/, name: 'Legacy contextTypes' },
    { regex: /childContextTypes\s*=/, name: 'Legacy childContextTypes' },
    { regex: /getChildContext\s*\(/, name: 'getChildContext' },
    { regex: /React\.createFactory/, name: 'createFactory' },
  ];
  
  const results: LegacyPatternScan = {
    found: false,
    patterns: [],
    files: []
  };
  
  for (const file of jsFiles) {
    const content = await fs.readFile(file, 'utf-8');
    const lines = content.split('\n');
    
    for (const pattern of legacyPatterns) {
      lines.forEach((line, index) => {
        if (pattern.regex.test(line)) {
          results.found = true;
          if (!results.patterns.includes(pattern.name)) {
            results.patterns.push(pattern.name);
          }
          results.files.push({
            path: file,
            line: index + 1,
            pattern: pattern.name
          });
        }
      });
    }
  }
  
  return results;
}

Visual Indicators

Projects Panel - Recent Projects List

// packages/noodl-editor/src/editor/src/views/ProjectsPanel/ProjectCard.tsx

interface ProjectCardProps {
  project: RecentProject;
  runtimeInfo: RuntimeVersionInfo;
}

function ProjectCard({ project, runtimeInfo }: ProjectCardProps) {
  const isLegacy = runtimeInfo.version === 'react17';
  const [expanded, setExpanded] = useState(false);
  
  return (
    <div className={css['project-card', isLegacy && 'project-card--legacy']}>
      <div className={css['project-card__header']}>
        <FolderIcon />
        <div className={css['project-card__info']}>
          <h3 className={css['project-card__name']}>
            {project.name}
            {isLegacy && (
              <Tooltip content="This project uses React 17 and needs migration">
                <WarningIcon className={css['project-card__warning-icon']} />
              </Tooltip>
            )}
          </h3>
          <span className={css['project-card__date']}>
            Last opened: {formatDate(project.lastOpened)}
          </span>
        </div>
      </div>
      
      {isLegacy && (
        <div className={css['project-card__legacy-banner']}>
          <div className={css['legacy-banner__content']}>
            <WarningIcon size={16} />
            <span>Legacy Runtime (React 17)</span>
          </div>
          <button 
            className={css['legacy-banner__expand']}
            onClick={() => setExpanded(!expanded)}
          >
            {expanded ? 'Less' : 'More'}
          </button>
        </div>
      )}
      
      {isLegacy && expanded && (
        <div className={css['project-card__legacy-details']}>
          <p>
            This project needs migration to work with OpenNoodl 1.2+.
            Your original project will remain untouched.
          </p>
          <div className={css['legacy-details__actions']}>
            <Button 
              variant="primary" 
              onClick={() => openMigrationWizard(project)}
            >
              Migrate Project
            </Button>
            <Button 
              variant="secondary"
              onClick={() => openProjectReadOnly(project)}
            >
              Open Read-Only
            </Button>
            <Button 
              variant="ghost"
              onClick={() => openDocs('migration-guide')}
            >
              Learn More
            </Button>
          </div>
        </div>
      )}
      
      {!isLegacy && (
        <div className={css['project-card__actions']}>
          <Button onClick={() => openProject(project)}>Open</Button>
        </div>
      )}
    </div>
  );
}

CSS Styles

// packages/noodl-editor/src/editor/src/styles/projects-panel.scss

.project-card {
  background: var(--color-bg-secondary);
  border-radius: 8px;
  padding: 16px;
  border: 1px solid var(--color-border);
  transition: border-color 0.2s;
  
  &:hover {
    border-color: var(--color-border-hover);
  }
  
  &--legacy {
    border-color: var(--color-warning-border);
    
    &:hover {
      border-color: var(--color-warning-border-hover);
    }
  }
}

.project-card__warning-icon {
  color: var(--color-warning);
  margin-left: 8px;
  width: 16px;
  height: 16px;
}

.project-card__legacy-banner {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: var(--color-warning-bg);
  border-radius: 4px;
  padding: 8px 12px;
  margin-top: 12px;
}

.legacy-banner__content {
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--color-warning-text);
  font-size: 13px;
  font-weight: 500;
}

.project-card__legacy-details {
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid var(--color-border);
  
  p {
    color: var(--color-text-secondary);
    font-size: 13px;
    margin-bottom: 12px;
  }
}

.legacy-details__actions {
  display: flex;
  gap: 8px;
}

Open Project Dialog - Legacy Detection

// packages/noodl-editor/src/editor/src/views/dialogs/OpenProjectDialog.tsx

function OpenProjectDialog({ onClose }: { onClose: () => void }) {
  const [selectedPath, setSelectedPath] = useState<string | null>(null);
  const [runtimeInfo, setRuntimeInfo] = useState<RuntimeVersionInfo | null>(null);
  const [checking, setChecking] = useState(false);
  
  const handleFolderSelect = async (path: string) => {
    setSelectedPath(path);
    setChecking(true);
    
    try {
      const info = await detectRuntimeVersion(path);
      setRuntimeInfo(info);
    } finally {
      setChecking(false);
    }
  };
  
  const isLegacy = runtimeInfo?.version === 'react17';
  
  return (
    <Dialog title="Open Project" onClose={onClose}>
      <FolderPicker 
        value={selectedPath}
        onChange={handleFolderSelect}
      />
      
      {checking && (
        <div className={css['checking-indicator']}>
          <Spinner size={16} />
          <span>Checking project version...</span>
        </div>
      )}
      
      {runtimeInfo && isLegacy && (
        <LegacyProjectNotice 
          projectPath={selectedPath}
          runtimeInfo={runtimeInfo}
        />
      )}
      
      <DialogActions>
        <Button variant="secondary" onClick={onClose}>
          Cancel
        </Button>
        
        {isLegacy ? (
          <>
            <Button 
              variant="secondary"
              onClick={() => openProjectReadOnly(selectedPath)}
            >
              Open Read-Only
            </Button>
            <Button 
              variant="primary"
              onClick={() => openMigrationWizard(selectedPath)}
            >
              Migrate & Open
            </Button>
          </>
        ) : (
          <Button 
            variant="primary"
            disabled={!selectedPath || checking}
            onClick={() => openProject(selectedPath)}
          >
            Open
          </Button>
        )}
      </DialogActions>
    </Dialog>
  );
}

function LegacyProjectNotice({ 
  projectPath, 
  runtimeInfo 
}: { 
  projectPath: string; 
  runtimeInfo: RuntimeVersionInfo;
}) {
  const projectName = path.basename(projectPath);
  const defaultTargetPath = `${projectPath}-r19`;
  const [targetPath, setTargetPath] = useState(defaultTargetPath);
  
  return (
    <div className={css['legacy-notice']}>
      <div className={css['legacy-notice__header']}>
        <WarningIcon size={20} />
        <h3>Legacy Project Detected</h3>
      </div>
      
      <p>
        <strong>"{projectName}"</strong> was created with an older version of 
        Noodl using React 17. OpenNoodl 1.2+ uses React 19.
      </p>
      
      <p>
        To open this project, we'll create a migrated copy. 
        Your original project will remain untouched.
      </p>
      
      <div className={css['legacy-notice__paths']}>
        <div className={css['path-row']}>
          <label>Original:</label>
          <code>{projectPath}</code>
        </div>
        <div className={css['path-row']}>
          <label>Copy:</label>
          <input 
            type="text" 
            value={targetPath}
            onChange={(e) => setTargetPath(e.target.value)}
          />
          <Button 
            variant="ghost" 
            size="small"
            onClick={() => selectFolder().then(setTargetPath)}
          >
            Change...
          </Button>
        </div>
      </div>
      
      {runtimeInfo.confidence !== 'high' && (
        <div className={css['legacy-notice__confidence']}>
          <InfoIcon size={14} />
          <span>
            Detection confidence: {runtimeInfo.confidence}. 
            Indicators: {runtimeInfo.indicators.join(', ')}
          </span>
        </div>
      )}
    </div>
  );
}

Read-Only Mode

When opening a legacy project in read-only mode:

// packages/noodl-editor/src/editor/src/models/projectmodel.ts

interface ProjectOpenOptions {
  readOnly?: boolean;
  legacyMode?: boolean;
}

async function openProject(path: string, options: ProjectOpenOptions = {}) {
  const project = await ProjectModel.fromDirectory(path);
  
  if (options.readOnly || options.legacyMode) {
    project.setReadOnly(true);
    
    // Show banner in editor
    EditorBanner.show({
      type: 'warning',
      message: 'This project is open in read-only mode. Migrate to make changes.',
      actions: [
        { label: 'Migrate Now', onClick: () => openMigrationWizard(path) },
        { label: 'Dismiss', onClick: () => EditorBanner.hide() }
      ]
    });
  }
  
  return project;
}

Read-Only Banner Component

// packages/noodl-editor/src/editor/src/views/EditorBanner.tsx

interface EditorBannerProps {
  type: 'info' | 'warning' | 'error';
  message: string;
  actions?: Array<{
    label: string;
    onClick: () => void;
  }>;
}

function EditorBanner({ type, message, actions }: EditorBannerProps) {
  return (
    <div className={css['editor-banner', `editor-banner--${type}`]}>
      <div className={css['editor-banner__content']}>
        {type === 'warning' && <WarningIcon size={16} />}
        {type === 'info' && <InfoIcon size={16} />}
        {type === 'error' && <ErrorIcon size={16} />}
        <span>{message}</span>
      </div>
      
      {actions && (
        <div className={css['editor-banner__actions']}>
          {actions.map((action, i) => (
            <Button 
              key={i}
              variant={i === 0 ? 'primary' : 'ghost'}
              size="small"
              onClick={action.onClick}
            >
              {action.label}
            </Button>
          ))}
        </div>
      )}
    </div>
  );
}

Testing Checklist

  • Legacy project shows warning icon in recent projects
  • Clicking legacy project shows expanded details
  • "Migrate Project" button opens migration wizard
  • "Open Read-Only" opens project without changes
  • Opening folder with legacy project shows detection dialog
  • Target path can be customized
  • Read-only mode shows banner
  • Banner "Migrate Now" opens wizard
  • New/modern projects open normally without warnings