mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
- Updated navigation functions to cast route paths correctly, improving type safety. - Added error handling for the templates API in project creation hooks to ensure robustness. - Refactored task progress panel to improve type handling for feature data. - Introduced type checks and default values in various components to enhance overall stability. These changes improve the reliability and maintainability of the application, ensuring better user experience and code quality.
962 lines
33 KiB
TypeScript
962 lines
33 KiB
TypeScript
import { useCallback, useState } from 'react';
|
|
import { createLogger } from '@automaker/utils/logger';
|
|
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
|
import { getElectronAPI } from '@/lib/electron';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Folder,
|
|
FolderOpen,
|
|
File,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Search,
|
|
RefreshCw,
|
|
BarChart3,
|
|
FileCode,
|
|
Loader2,
|
|
FileText,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
ListChecks,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const logger = createLogger('AnalysisView');
|
|
|
|
const IGNORE_PATTERNS = [
|
|
'node_modules',
|
|
'.git',
|
|
'.next',
|
|
'dist',
|
|
'build',
|
|
'.DS_Store',
|
|
'*.log',
|
|
'.cache',
|
|
'coverage',
|
|
'__pycache__',
|
|
'.pytest_cache',
|
|
'.venv',
|
|
'venv',
|
|
'.env',
|
|
];
|
|
|
|
const shouldIgnore = (name: string) => {
|
|
return IGNORE_PATTERNS.some((pattern) => {
|
|
if (pattern.startsWith('*')) {
|
|
return name.endsWith(pattern.slice(1));
|
|
}
|
|
return name === pattern;
|
|
});
|
|
};
|
|
|
|
const getExtension = (filename: string): string => {
|
|
const parts = filename.split('.');
|
|
return parts.length > 1 ? parts.pop() || '' : '';
|
|
};
|
|
|
|
export function AnalysisView() {
|
|
const {
|
|
currentProject,
|
|
projectAnalysis,
|
|
isAnalyzing,
|
|
setProjectAnalysis,
|
|
setIsAnalyzing,
|
|
clearAnalysis,
|
|
} = useAppStore();
|
|
|
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
|
const [isGeneratingSpec, setIsGeneratingSpec] = useState(false);
|
|
const [specGenerated, setSpecGenerated] = useState(false);
|
|
const [specError, setSpecError] = useState<string | null>(null);
|
|
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
|
|
const [featureListGenerated, setFeatureListGenerated] = useState(false);
|
|
const [featureListError, setFeatureListError] = useState<string | null>(null);
|
|
|
|
// Recursively scan directory
|
|
const scanDirectory = useCallback(
|
|
async (path: string, depth: number = 0): Promise<FileTreeNode[]> => {
|
|
if (depth > 10) return []; // Prevent infinite recursion
|
|
|
|
const api = getElectronAPI();
|
|
try {
|
|
const result = await api.readdir(path);
|
|
if (!result.success || !result.entries) return [];
|
|
|
|
const nodes: FileTreeNode[] = [];
|
|
const entries = result.entries.filter((e) => !shouldIgnore(e.name));
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = `${path}/${entry.name}`;
|
|
const node: FileTreeNode = {
|
|
name: entry.name,
|
|
path: fullPath,
|
|
isDirectory: entry.isDirectory,
|
|
extension: entry.isFile ? getExtension(entry.name) : undefined,
|
|
};
|
|
|
|
if (entry.isDirectory) {
|
|
// Recursively scan subdirectories
|
|
node.children = await scanDirectory(fullPath, depth + 1);
|
|
}
|
|
|
|
nodes.push(node);
|
|
}
|
|
|
|
// Sort: directories first, then files alphabetically
|
|
nodes.sort((a, b) => {
|
|
if (a.isDirectory && !b.isDirectory) return -1;
|
|
if (!a.isDirectory && b.isDirectory) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return nodes;
|
|
} catch (error) {
|
|
logger.error('Failed to scan directory:', path, error);
|
|
return [];
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Count files and directories
|
|
const countNodes = (
|
|
nodes: FileTreeNode[]
|
|
): { files: number; dirs: number; byExt: Record<string, number> } => {
|
|
let files = 0;
|
|
let dirs = 0;
|
|
const byExt: Record<string, number> = {};
|
|
|
|
const traverse = (items: FileTreeNode[]) => {
|
|
for (const item of items) {
|
|
if (item.isDirectory) {
|
|
dirs++;
|
|
if (item.children) traverse(item.children);
|
|
} else {
|
|
files++;
|
|
if (item.extension) {
|
|
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
|
|
} else {
|
|
byExt['(no extension)'] = (byExt['(no extension)'] || 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
traverse(nodes);
|
|
return { files, dirs, byExt };
|
|
};
|
|
|
|
// Run the analysis
|
|
const runAnalysis = useCallback(async () => {
|
|
if (!currentProject) return;
|
|
|
|
setIsAnalyzing(true);
|
|
clearAnalysis();
|
|
|
|
try {
|
|
const fileTree = await scanDirectory(currentProject.path);
|
|
const counts = countNodes(fileTree);
|
|
|
|
const analysis: ProjectAnalysis = {
|
|
fileTree,
|
|
totalFiles: counts.files,
|
|
totalDirectories: counts.dirs,
|
|
filesByExtension: counts.byExt,
|
|
analyzedAt: new Date().toISOString(),
|
|
};
|
|
|
|
setProjectAnalysis(analysis);
|
|
} catch (error) {
|
|
logger.error('Analysis failed:', error);
|
|
} finally {
|
|
setIsAnalyzing(false);
|
|
}
|
|
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
|
|
|
|
// Generate app_spec.txt from analysis
|
|
const generateSpec = useCallback(async () => {
|
|
if (!currentProject || !projectAnalysis) return;
|
|
|
|
setIsGeneratingSpec(true);
|
|
setSpecError(null);
|
|
setSpecGenerated(false);
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
|
|
// Read key files to understand the project better
|
|
const fileContents: Record<string, string> = {};
|
|
const keyFiles = ['package.json', 'README.md', 'tsconfig.json'];
|
|
|
|
// Collect file paths from analysis
|
|
const collectFilePaths = (
|
|
nodes: FileTreeNode[],
|
|
maxDepth: number = 3,
|
|
currentDepth: number = 0
|
|
): string[] => {
|
|
const paths: string[] = [];
|
|
for (const node of nodes) {
|
|
if (!node.isDirectory) {
|
|
paths.push(node.path);
|
|
} else if (node.children && currentDepth < maxDepth) {
|
|
paths.push(...collectFilePaths(node.children, maxDepth, currentDepth + 1));
|
|
}
|
|
}
|
|
return paths;
|
|
};
|
|
|
|
collectFilePaths(projectAnalysis.fileTree);
|
|
|
|
// Try to read key configuration files
|
|
for (const keyFile of keyFiles) {
|
|
const filePath = `${currentProject.path}/${keyFile}`;
|
|
const exists = await api.exists(filePath);
|
|
if (exists) {
|
|
const result = await api.readFile(filePath);
|
|
if (result.success && result.content) {
|
|
fileContents[keyFile] = result.content;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect project type and tech stack
|
|
const detectTechStack = () => {
|
|
const stack: string[] = [];
|
|
const extensions = projectAnalysis.filesByExtension;
|
|
|
|
// Check package.json for dependencies
|
|
if (fileContents['package.json']) {
|
|
try {
|
|
const pkg = JSON.parse(fileContents['package.json']);
|
|
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) stack.push('React');
|
|
if (pkg.dependencies?.next) stack.push('Next.js');
|
|
if (pkg.dependencies?.vue) stack.push('Vue');
|
|
if (pkg.dependencies?.angular) stack.push('Angular');
|
|
if (pkg.dependencies?.express) stack.push('Express');
|
|
if (pkg.dependencies?.electron) stack.push('Electron');
|
|
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript)
|
|
stack.push('TypeScript');
|
|
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss)
|
|
stack.push('Tailwind CSS');
|
|
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
|
|
stack.push('Playwright');
|
|
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) stack.push('Jest');
|
|
} catch {
|
|
// Ignore JSON parse errors
|
|
}
|
|
}
|
|
|
|
// Detect by file extensions
|
|
if (extensions['ts'] || extensions['tsx']) stack.push('TypeScript');
|
|
if (extensions['py']) stack.push('Python');
|
|
if (extensions['go']) stack.push('Go');
|
|
if (extensions['rs']) stack.push('Rust');
|
|
if (extensions['java']) stack.push('Java');
|
|
if (extensions['css'] || extensions['scss'] || extensions['sass']) stack.push('CSS/SCSS');
|
|
|
|
// Remove duplicates
|
|
return [...new Set(stack)];
|
|
};
|
|
|
|
// Get project name from package.json or folder name
|
|
const getProjectName = () => {
|
|
if (fileContents['package.json']) {
|
|
try {
|
|
const pkg = JSON.parse(fileContents['package.json']);
|
|
if (pkg.name) return pkg.name;
|
|
} catch {
|
|
// Ignore JSON parse errors
|
|
}
|
|
}
|
|
// Fall back to folder name
|
|
return currentProject.name;
|
|
};
|
|
|
|
// Get project description from package.json or README
|
|
const getProjectDescription = () => {
|
|
if (fileContents['package.json']) {
|
|
try {
|
|
const pkg = JSON.parse(fileContents['package.json']);
|
|
if (pkg.description) return pkg.description;
|
|
} catch {
|
|
// Ignore JSON parse errors
|
|
}
|
|
}
|
|
if (fileContents['README.md']) {
|
|
// Extract first paragraph from README
|
|
const lines = fileContents['README.md'].split('\n');
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (
|
|
trimmed &&
|
|
!trimmed.startsWith('#') &&
|
|
!trimmed.startsWith('!') &&
|
|
trimmed.length > 20
|
|
) {
|
|
return trimmed.substring(0, 200);
|
|
}
|
|
}
|
|
}
|
|
return 'A software project';
|
|
};
|
|
|
|
// Group files by directory for structure analysis
|
|
const analyzeStructure = () => {
|
|
const structure: string[] = [];
|
|
const topLevelDirs = projectAnalysis.fileTree
|
|
.filter((n: FileTreeNode) => n.isDirectory)
|
|
.map((n: FileTreeNode) => n.name);
|
|
|
|
for (const dir of topLevelDirs) {
|
|
structure.push(` <directory name="${dir}" />`);
|
|
}
|
|
return structure.join('\n');
|
|
};
|
|
|
|
const projectName = getProjectName();
|
|
const description = getProjectDescription();
|
|
const techStack = detectTechStack();
|
|
|
|
// Generate the spec content
|
|
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
|
|
const specContent = `<project_specification>
|
|
<project_name>${projectName}</project_name>
|
|
|
|
<overview>
|
|
${description}
|
|
</overview>
|
|
|
|
<technology_stack>
|
|
<languages>
|
|
${Object.entries(projectAnalysis.filesByExtension)
|
|
.filter(([ext]: [string, number]) =>
|
|
['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext)
|
|
)
|
|
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
|
|
.join('\n')}
|
|
</languages>
|
|
<frameworks>
|
|
${techStack.map((tech) => ` <framework>${tech}</framework>`).join('\n')}
|
|
</frameworks>
|
|
</technology_stack>
|
|
|
|
<project_structure>
|
|
<total_files>${projectAnalysis.totalFiles}</total_files>
|
|
<total_directories>${projectAnalysis.totalDirectories}</total_directories>
|
|
<top_level_structure>
|
|
${analyzeStructure()}
|
|
</top_level_structure>
|
|
</project_structure>
|
|
|
|
<file_breakdown>
|
|
${Object.entries(projectAnalysis.filesByExtension)
|
|
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(
|
|
([ext, count]: [string, number]) =>
|
|
` <extension type="${ext.startsWith('(') ? ext : '.' + ext}" count="${count}" />`
|
|
)
|
|
.join('\n')}
|
|
</file_breakdown>
|
|
|
|
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
|
|
</project_specification>
|
|
`;
|
|
|
|
// Write the spec file
|
|
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
|
|
const writeResult = await api.writeFile(specPath, specContent);
|
|
|
|
if (writeResult.success) {
|
|
setSpecGenerated(true);
|
|
} else {
|
|
setSpecError(writeResult.error || 'Failed to write spec file');
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to generate spec:', error);
|
|
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
|
|
} finally {
|
|
setIsGeneratingSpec(false);
|
|
}
|
|
}, [currentProject, projectAnalysis]);
|
|
|
|
// Generate features from analysis and save to .automaker/features folder
|
|
const generateFeatureList = useCallback(async () => {
|
|
if (!currentProject || !projectAnalysis) return;
|
|
|
|
setIsGeneratingFeatureList(true);
|
|
setFeatureListError(null);
|
|
setFeatureListGenerated(false);
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
|
|
// Read key files to understand the project
|
|
const fileContents: Record<string, string> = {};
|
|
const keyFiles = ['package.json', 'README.md'];
|
|
|
|
// Try to read key configuration files
|
|
for (const keyFile of keyFiles) {
|
|
const filePath = `${currentProject.path}/${keyFile}`;
|
|
const exists = await api.exists(filePath);
|
|
if (exists) {
|
|
const result = await api.readFile(filePath);
|
|
if (result.success && result.content) {
|
|
fileContents[keyFile] = result.content;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect file paths from analysis
|
|
const collectFilePaths = (nodes: FileTreeNode[]): string[] => {
|
|
const paths: string[] = [];
|
|
for (const node of nodes) {
|
|
if (!node.isDirectory) {
|
|
paths.push(node.path);
|
|
} else if (node.children) {
|
|
paths.push(...collectFilePaths(node.children));
|
|
}
|
|
}
|
|
return paths;
|
|
};
|
|
|
|
const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
|
|
|
|
// Analyze directories and files to detect features
|
|
interface DetectedFeature {
|
|
category: string;
|
|
description: string;
|
|
passes: boolean;
|
|
}
|
|
|
|
const detectedFeatures: DetectedFeature[] = [];
|
|
|
|
// Detect features based on project structure and files
|
|
const detectFeatures = () => {
|
|
const extensions = projectAnalysis.filesByExtension;
|
|
const topLevelDirs = projectAnalysis.fileTree
|
|
.filter((n: FileTreeNode) => n.isDirectory)
|
|
.map((n: FileTreeNode) => n.name.toLowerCase());
|
|
const topLevelFiles = projectAnalysis.fileTree
|
|
.filter((n: FileTreeNode) => !n.isDirectory)
|
|
.map((n: FileTreeNode) => n.name.toLowerCase());
|
|
|
|
// Check for test directories and files
|
|
const hasTests =
|
|
topLevelDirs.includes('tests') ||
|
|
topLevelDirs.includes('test') ||
|
|
topLevelDirs.includes('__tests__') ||
|
|
allFilePaths.some((p) => p.includes('.spec.') || p.includes('.test.'));
|
|
|
|
if (hasTests) {
|
|
detectedFeatures.push({
|
|
category: 'Testing',
|
|
description: 'Automated test suite',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Check for components directory (UI components)
|
|
const hasComponents =
|
|
topLevelDirs.includes('components') ||
|
|
allFilePaths.some((p) => p.toLowerCase().includes('/components/'));
|
|
|
|
if (hasComponents) {
|
|
detectedFeatures.push({
|
|
category: 'UI/Design',
|
|
description: 'Component-based UI architecture',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Check for src directory (organized source code)
|
|
if (topLevelDirs.includes('src')) {
|
|
detectedFeatures.push({
|
|
category: 'Project Structure',
|
|
description: 'Organized source code structure',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Check package.json for dependencies and detect features
|
|
if (fileContents['package.json']) {
|
|
try {
|
|
const pkg = JSON.parse(fileContents['package.json']);
|
|
|
|
// React/Next.js app detection
|
|
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) {
|
|
detectedFeatures.push({
|
|
category: 'Frontend',
|
|
description: 'React-based user interface',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
if (pkg.dependencies?.next) {
|
|
detectedFeatures.push({
|
|
category: 'Framework',
|
|
description: 'Next.js framework integration',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// TypeScript support
|
|
if (
|
|
pkg.devDependencies?.typescript ||
|
|
pkg.dependencies?.typescript ||
|
|
extensions['ts'] ||
|
|
extensions['tsx']
|
|
) {
|
|
detectedFeatures.push({
|
|
category: 'Developer Experience',
|
|
description: 'TypeScript type safety',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Tailwind CSS
|
|
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss) {
|
|
detectedFeatures.push({
|
|
category: 'UI/Design',
|
|
description: 'Tailwind CSS styling',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// ESLint/Prettier (code quality)
|
|
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
|
|
detectedFeatures.push({
|
|
category: 'Developer Experience',
|
|
description: 'Code quality tools',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Electron (desktop app)
|
|
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
|
|
detectedFeatures.push({
|
|
category: 'Platform',
|
|
description: 'Electron desktop application',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Playwright testing
|
|
if (pkg.devDependencies?.playwright || pkg.devDependencies?.['@playwright/test']) {
|
|
detectedFeatures.push({
|
|
category: 'Testing',
|
|
description: 'Playwright end-to-end testing',
|
|
passes: true,
|
|
});
|
|
}
|
|
} catch {
|
|
// Ignore JSON parse errors
|
|
}
|
|
}
|
|
|
|
// Check for documentation
|
|
if (topLevelFiles.includes('readme.md') || topLevelDirs.includes('docs')) {
|
|
detectedFeatures.push({
|
|
category: 'Documentation',
|
|
description: 'Project documentation',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Check for CI/CD configuration
|
|
const hasCICD =
|
|
topLevelDirs.includes('.github') ||
|
|
topLevelFiles.includes('.gitlab-ci.yml') ||
|
|
topLevelFiles.includes('.travis.yml');
|
|
|
|
if (hasCICD) {
|
|
detectedFeatures.push({
|
|
category: 'DevOps',
|
|
description: 'CI/CD pipeline configuration',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Check for API routes (Next.js API or Express)
|
|
const hasAPIRoutes = allFilePaths.some(
|
|
(p) => p.includes('/api/') || p.includes('/routes/') || p.includes('/endpoints/')
|
|
);
|
|
|
|
if (hasAPIRoutes) {
|
|
detectedFeatures.push({
|
|
category: 'Backend',
|
|
description: 'API endpoints',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Check for state management
|
|
const hasStateManagement = allFilePaths.some(
|
|
(p) =>
|
|
p.includes('/store/') ||
|
|
p.includes('/stores/') ||
|
|
p.includes('/redux/') ||
|
|
p.includes('/context/')
|
|
);
|
|
|
|
if (hasStateManagement) {
|
|
detectedFeatures.push({
|
|
category: 'Architecture',
|
|
description: 'State management system',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Check for configuration files
|
|
if (topLevelFiles.includes('tsconfig.json') || topLevelFiles.includes('package.json')) {
|
|
detectedFeatures.push({
|
|
category: 'Configuration',
|
|
description: 'Project configuration files',
|
|
passes: true,
|
|
});
|
|
}
|
|
};
|
|
|
|
detectFeatures();
|
|
|
|
// If no features were detected, add a default feature
|
|
if (detectedFeatures.length === 0) {
|
|
detectedFeatures.push({
|
|
category: 'Core',
|
|
description: 'Basic project structure',
|
|
passes: true,
|
|
});
|
|
}
|
|
|
|
// Create each feature using the features API
|
|
if (!api.features) {
|
|
throw new Error('Features API not available');
|
|
}
|
|
|
|
for (const detectedFeature of detectedFeatures) {
|
|
await api.features.create(currentProject.path, {
|
|
id: crypto.randomUUID(),
|
|
category: detectedFeature.category,
|
|
description: detectedFeature.description,
|
|
status: 'backlog',
|
|
// Initialize with empty steps so the object satisfies the Feature type
|
|
steps: [],
|
|
} as any);
|
|
}
|
|
|
|
setFeatureListGenerated(true);
|
|
} catch (error) {
|
|
logger.error('Failed to generate feature list:', error);
|
|
setFeatureListError(
|
|
error instanceof Error ? error.message : 'Failed to generate feature list'
|
|
);
|
|
} finally {
|
|
setIsGeneratingFeatureList(false);
|
|
}
|
|
}, [currentProject, projectAnalysis]);
|
|
|
|
// Toggle folder expansion
|
|
const toggleFolder = (path: string) => {
|
|
const newExpanded = new Set(expandedFolders);
|
|
if (expandedFolders.has(path)) {
|
|
newExpanded.delete(path);
|
|
} else {
|
|
newExpanded.add(path);
|
|
}
|
|
setExpandedFolders(newExpanded);
|
|
};
|
|
|
|
// Render file tree node
|
|
const renderNode = (node: FileTreeNode, depth: number = 0) => {
|
|
const isExpanded = expandedFolders.has(node.path);
|
|
|
|
return (
|
|
<div key={node.path} data-testid={`analysis-node-${node.name}`}>
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm'
|
|
)}
|
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
onClick={() => {
|
|
if (node.isDirectory) {
|
|
toggleFolder(node.path);
|
|
}
|
|
}}
|
|
>
|
|
{node.isDirectory ? (
|
|
<>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
)}
|
|
{isExpanded ? (
|
|
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
|
|
) : (
|
|
<Folder className="w-4 h-4 text-primary shrink-0" />
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="w-4" />
|
|
<File className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
</>
|
|
)}
|
|
<span className="truncate">{node.name}</span>
|
|
{node.extension && (
|
|
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span>
|
|
)}
|
|
</div>
|
|
{node.isDirectory && isExpanded && node.children && (
|
|
<div>{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (!currentProject) {
|
|
return (
|
|
<div
|
|
className="flex-1 flex items-center justify-center"
|
|
data-testid="analysis-view-no-project"
|
|
>
|
|
<p className="text-muted-foreground">No project selected</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="analysis-view">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
|
<div className="flex items-center gap-3">
|
|
<Search className="w-5 h-5 text-muted-foreground" />
|
|
<div>
|
|
<h1 className="text-xl font-bold">Project Analysis</h1>
|
|
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
|
</div>
|
|
</div>
|
|
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
|
|
{isAnalyzing ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Analyzing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
Analyze Project
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-hidden p-4">
|
|
{!projectAnalysis && !isAnalyzing ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
|
|
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
|
|
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
|
Click "Analyze Project" to scan your codebase and get insights about its
|
|
structure.
|
|
</p>
|
|
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
|
|
<Search className="w-4 h-4 mr-2" />
|
|
Start Analysis
|
|
</Button>
|
|
</div>
|
|
) : isAnalyzing ? (
|
|
<div className="flex flex-col items-center justify-center h-full">
|
|
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
|
|
<p className="text-muted-foreground">Scanning project files...</p>
|
|
</div>
|
|
) : projectAnalysis ? (
|
|
<div className="flex gap-4 h-full overflow-hidden">
|
|
{/* Stats Panel */}
|
|
<div className="w-80 shrink-0 overflow-y-auto space-y-4">
|
|
<Card data-testid="analysis-stats">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<BarChart3 className="w-4 h-4" />
|
|
Statistics
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Analyzed {new Date(projectAnalysis.analyzedAt).toLocaleString()}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-muted-foreground">Total Files</span>
|
|
<span className="font-medium" data-testid="total-files">
|
|
{projectAnalysis.totalFiles}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-muted-foreground">Total Directories</span>
|
|
<span className="font-medium" data-testid="total-directories">
|
|
{projectAnalysis.totalDirectories}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card data-testid="files-by-extension">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<FileCode className="w-4 h-4" />
|
|
Files by Extension
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{Object.entries(projectAnalysis.filesByExtension)
|
|
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
|
.slice(0, 15)
|
|
.map(([ext, count]: [string, number]) => (
|
|
<div key={ext} className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground font-mono">
|
|
{ext.startsWith('(') ? ext : `.${ext}`}
|
|
</span>
|
|
<span>{count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Generate Spec Card */}
|
|
<Card data-testid="generate-spec-card">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<FileText className="w-4 h-4" />
|
|
Generate Specification
|
|
</CardTitle>
|
|
<CardDescription>Create app_spec.txt from analysis</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Generate a project specification file based on the analyzed codebase structure
|
|
and detected technologies.
|
|
</p>
|
|
<Button
|
|
onClick={generateSpec}
|
|
disabled={isGeneratingSpec}
|
|
className="w-full"
|
|
data-testid="generate-spec-button"
|
|
>
|
|
{isGeneratingSpec ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Generating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<FileText className="w-4 h-4 mr-2" />
|
|
Generate Spec
|
|
</>
|
|
)}
|
|
</Button>
|
|
{specGenerated && (
|
|
<div
|
|
className="flex items-center gap-2 text-sm text-green-500"
|
|
data-testid="spec-generated-success"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
<span>app_spec.txt created successfully!</span>
|
|
</div>
|
|
)}
|
|
{specError && (
|
|
<div
|
|
className="flex items-center gap-2 text-sm text-red-500"
|
|
data-testid="spec-generated-error"
|
|
>
|
|
<AlertCircle className="w-4 h-4" />
|
|
<span>{specError}</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Generate Feature List Card */}
|
|
<Card data-testid="generate-feature-list-card">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<ListChecks className="w-4 h-4" />
|
|
Generate Feature List
|
|
</CardTitle>
|
|
<CardDescription>Create features from analysis</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Automatically detect and generate a feature list based on the analyzed codebase
|
|
structure, dependencies, and project configuration.
|
|
</p>
|
|
<Button
|
|
onClick={generateFeatureList}
|
|
disabled={isGeneratingFeatureList}
|
|
className="w-full"
|
|
data-testid="generate-feature-list-button"
|
|
>
|
|
{isGeneratingFeatureList ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Generating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<ListChecks className="w-4 h-4 mr-2" />
|
|
Generate Feature List
|
|
</>
|
|
)}
|
|
</Button>
|
|
{featureListGenerated && (
|
|
<div
|
|
className="flex items-center gap-2 text-sm text-green-500"
|
|
data-testid="feature-list-generated-success"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
<span>Features created successfully!</span>
|
|
</div>
|
|
)}
|
|
{featureListError && (
|
|
<div
|
|
className="flex items-center gap-2 text-sm text-red-500"
|
|
data-testid="feature-list-generated-error"
|
|
>
|
|
<AlertCircle className="w-4 h-4" />
|
|
<span>{featureListError}</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* File Tree */}
|
|
<Card className="flex-1 overflow-hidden">
|
|
<CardHeader className="pb-2 border-b">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<Folder className="w-4 h-4" />
|
|
File Tree
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{' '}
|
|
directories
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-0 overflow-y-auto h-full" data-testid="analysis-file-tree">
|
|
<div className="p-2">
|
|
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|