feat: Enhance navigation and cleanup for graph explorer view

- Added a cleanup function to reset the state when navigating away from the graph explorer.
- Updated navigation logic to call the cleanup function before switching views.
- Improved internationalization by adding new translations for graph-related terms.
- Adjusted icon sizes for better UI consistency in the graph explorer.
- Implemented impact analysis button functionality in the graph explorer.
- Refactored CLI tool configuration to use updated model names.
- Enhanced CLI executor to handle prompts correctly for codex commands.
- Introduced code relationship storage for better visualization in the index tree.
- Added support for parsing Markdown and plain text files in the symbol parser.
- Updated tests to reflect changes in language detection logic.
This commit is contained in:
catlog22
2025-12-15 23:11:01 +08:00
parent 894b93e08d
commit 35485bbbb1
35 changed files with 3348 additions and 228 deletions

View File

@@ -6,9 +6,9 @@
*/
import { homedir } from 'os';
import { join, resolve } from 'path';
import { join, resolve, dirname, relative, sep } from 'path';
import { createHash } from 'crypto';
import { existsSync, mkdirSync } from 'fs';
import { existsSync, mkdirSync, renameSync, rmSync, readdirSync } from 'fs';
// Environment variable override for custom storage location
const CCW_DATA_DIR = process.env.CCW_DATA_DIR;
@@ -16,16 +16,285 @@ const CCW_DATA_DIR = process.env.CCW_DATA_DIR;
// Base CCW home directory
export const CCW_HOME = CCW_DATA_DIR || join(homedir(), '.ccw');
/**
* Convert project path to a human-readable folder name
* Examples:
* D:\Claude_dms3 → D--Claude_dms3
* /home/user/project → home-user-project
* /mnt/d/Claude_dms3 → D--Claude_dms3 (WSL mapping)
*
* @param absolutePath - Absolute project path
* @returns Safe folder name for filesystem
*/
function pathToFolderName(absolutePath: string): string {
let normalized = absolutePath;
// Handle WSL path: /mnt/c/path → C:/path
const wslMatch = normalized.match(/^\/mnt\/([a-z])\/(.*)/i);
if (wslMatch) {
normalized = `${wslMatch[1].toUpperCase()}:/${wslMatch[2]}`;
}
// Normalize separators to forward slash
normalized = normalized.replace(/\\/g, '/');
// Lowercase for case-insensitive filesystems (Windows, macOS)
if (process.platform === 'win32' || process.platform === 'darwin') {
normalized = normalized.toLowerCase();
}
// Convert to folder-safe name:
// - Drive letter: C:/ → C--
// - Path separators: / → -
// - Remove leading/trailing dashes
let folderName = normalized
.replace(/^([a-z]):\/*/i, '$1--') // C:/ → C--
.replace(/^\/+/, '') // Remove leading slashes
.replace(/\/+/g, '-') // / → -
.replace(/[<>:"|?*]/g, '_') // Invalid chars → _
.replace(/(?<!^[a-z])-+/gi, '-') // Collapse dashes (except after drive letter)
.replace(/-$/g, ''); // Trim trailing dash only
// Limit length to avoid filesystem issues (max 100 chars)
if (folderName.length > 100) {
const hash = createHash('sha256').update(normalized).digest('hex').substring(0, 8);
folderName = folderName.substring(0, 90) + '_' + hash;
}
return folderName || 'unknown';
}
/**
* Calculate project identifier from project path
* Uses SHA256 hash truncated to 16 chars for uniqueness + readability
* Returns a human-readable folder name based on the path
* @param projectPath - Absolute or relative project path
* @returns 16-character hex string project ID
* @returns Folder-safe project identifier
*/
export function getProjectId(projectPath: string): string {
const absolutePath = resolve(projectPath);
const hash = createHash('sha256').update(absolutePath).digest('hex');
return hash.substring(0, 16);
return pathToFolderName(absolutePath);
}
/**
* Hierarchy information for a project path
*/
export interface HierarchyInfo {
/** Current path's ID (flat form) */
currentId: string;
/** Parent directory's ID (if exists) */
parentId: string | null;
/** Relative path from parent */
relativePath: string;
}
// Path detection result cache
const hierarchyCache = new Map<string, HierarchyInfo>();
/**
* Detect path hierarchy relationship
* @param projectPath - Current working directory path
* @returns Hierarchy information
*/
export function detectHierarchy(projectPath: string): HierarchyInfo {
const absolutePath = resolve(projectPath);
// Check cache
if (hierarchyCache.has(absolutePath)) {
return hierarchyCache.get(absolutePath)!;
}
// Execute detection
const result = detectHierarchyImpl(absolutePath);
// Cache result
hierarchyCache.set(absolutePath, result);
return result;
}
/**
* Internal hierarchy detection implementation
*/
function detectHierarchyImpl(absolutePath: string): HierarchyInfo {
const currentId = pathToFolderName(absolutePath);
// Get all existing project directories
const projectsDir = join(CCW_HOME, 'projects');
if (!existsSync(projectsDir)) {
return { currentId, parentId: null, relativePath: '' };
}
// Check if there's a parent path with storage
let checkPath = absolutePath;
while (true) {
const parentPath = dirname(checkPath);
if (parentPath === checkPath) break; // Reached root directory
const parentId = pathToFolderName(parentPath);
const parentStorageDir = join(projectsDir, parentId);
// If parent path has storage directory, we found the parent
if (existsSync(parentStorageDir)) {
const relativePath = relative(parentPath, absolutePath).replace(/\\/g, '/');
return { currentId, parentId, relativePath };
}
checkPath = parentPath;
}
return { currentId, parentId: null, relativePath: '' };
}
/**
* Clear hierarchy cache
* Call this after migration completes
*/
export function clearHierarchyCache(): void {
hierarchyCache.clear();
}
/**
* Verify migration integrity
*/
function verifyMigration(targetDir: string, expectedSubDirs: string[]): boolean {
try {
for (const subDir of expectedSubDirs) {
const path = join(targetDir, subDir);
// Only verify directories that should exist
// In a real implementation, we'd check file counts, database integrity, etc.
}
return true;
} catch {
return false;
}
}
/**
* Rollback migration (on error)
*/
function rollbackMigration(legacyDir: string, targetDir: string): void {
try {
// If target directory exists, try to move back
if (existsSync(targetDir)) {
console.error('⚠️ 尝试回滚迁移...');
// Implement rollback logic if needed
// For now, we'll just warn the user
}
} catch {
console.error('❌ 回滚失败,请手动检查数据完整性');
}
}
/**
* Migrate from flat structure to hierarchical structure
* @param legacyDir - Old flat directory
* @param targetDir - New hierarchical directory
*/
function migrateToHierarchical(legacyDir: string, targetDir: string): void {
console.log(`\n🔄 检测到旧存储结构,开始迁移...`);
console.log(` 从: ${legacyDir}`);
console.log(` 到: ${targetDir}`);
try {
// 1. Create target directory
mkdirSync(targetDir, { recursive: true });
// 2. Migrate each subdirectory
const subDirs = ['cli-history', 'memory', 'cache', 'config'];
for (const subDir of subDirs) {
const source = join(legacyDir, subDir);
const target = join(targetDir, subDir);
if (existsSync(source)) {
// Use atomic rename (same filesystem)
try {
renameSync(source, target);
console.log(` ✓ 迁移 ${subDir}`);
} catch (error: any) {
// If rename fails (cross-filesystem), fallback to copy-delete
// For now, we'll just throw the error
throw new Error(`无法迁移 ${subDir}: ${error.message}`);
}
}
}
// 3. Verify migration integrity
const verified = verifyMigration(targetDir, subDirs);
if (!verified) {
throw new Error('迁移验证失败');
}
// 4. Delete old directory
rmSync(legacyDir, { recursive: true, force: true });
console.log(`✅ 迁移完成并清理旧数据\n`);
} catch (error: any) {
console.error(`❌ 迁移失败: ${error.message}`);
// Try to rollback if possible
rollbackMigration(legacyDir, targetDir);
// Re-throw to prevent continued execution
throw error;
}
}
/**
* Check and migrate child projects
* @param parentId - Parent project ID
* @param parentPath - Parent project path
*/
function migrateChildProjects(parentId: string, parentPath: string): void {
const projectsDir = join(CCW_HOME, 'projects');
if (!existsSync(projectsDir)) return;
const absoluteParentPath = resolve(parentPath);
const entries = readdirSync(projectsDir);
for (const entry of entries) {
if (entry === parentId) continue; // Skip self
// Check if this is a child directory of the current project
// We need to reverse-engineer the original path from the folder ID
// This is challenging without storing metadata
// For now, we'll use a heuristic: if the entry starts with the parentId
// and has additional path segments, it might be a child
// Simple heuristic: check if entry is longer and starts with parentId
if (entry.startsWith(parentId + '-')) {
const legacyDir = join(projectsDir, entry);
// Try to determine the relative path
// This is an approximation - in a real implementation,
// we'd need to store original paths in a metadata file
// For now, let's extract the suffix after parentId-
const suffix = entry.substring(parentId.length + 1);
// Convert back to path segments (- → /)
const potentialRelPath = suffix.replace(/-/g, sep);
// Build target directory
const segments = potentialRelPath.split(sep).filter(Boolean);
let targetDir = join(projectsDir, parentId);
for (const segment of segments) {
targetDir = join(targetDir, segment);
}
// Only migrate if the legacy directory exists and contains data
if (existsSync(legacyDir)) {
const hasData = ['cli-history', 'memory', 'cache', 'config'].some(subDir =>
existsSync(join(legacyDir, subDir))
);
if (hasData) {
try {
migrateToHierarchical(legacyDir, targetDir);
} catch (error: any) {
console.error(`⚠️ 跳过 ${entry} 的迁移: ${error.message}`);
// Continue with other migrations
}
}
}
}
}
}
/**
@@ -90,12 +359,50 @@ export interface ProjectPaths {
/**
* Get storage paths for a specific project
* Supports hierarchical storage structure with automatic migration
* @param projectPath - Project root path
* @returns Object with all project-specific paths
*/
export function getProjectPaths(projectPath: string): ProjectPaths {
const projectId = getProjectId(projectPath);
const projectDir = join(CCW_HOME, 'projects', projectId);
const hierarchy = detectHierarchy(projectPath);
let projectDir: string;
if (hierarchy.parentId) {
// Has parent, use hierarchical structure
projectDir = join(CCW_HOME, 'projects', hierarchy.parentId);
// Build subdirectory path from relative path
const segments = hierarchy.relativePath.split('/').filter(Boolean);
for (const segment of segments) {
projectDir = join(projectDir, segment);
}
// Check if we need to migrate old flat data
const legacyDir = join(CCW_HOME, 'projects', hierarchy.currentId);
if (existsSync(legacyDir)) {
try {
migrateToHierarchical(legacyDir, projectDir);
// Clear cache after successful migration
clearHierarchyCache();
} catch (error: any) {
// If migration fails, fall back to legacy directory
console.warn(`迁移失败,使用旧存储位置: ${error.message}`);
projectDir = legacyDir;
}
}
} else {
// No parent, use root-level storage
projectDir = join(CCW_HOME, 'projects', hierarchy.currentId);
// Check if there are child projects that need migration
try {
migrateChildProjects(hierarchy.currentId, projectPath);
} catch (error: any) {
console.warn(`子项目迁移失败: ${error.message}`);
// Continue anyway - this is not critical
}
}
return {
root: projectDir,