ccmanager 1.4.5 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +30 -2
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +67 -0
- package/dist/components/App.d.ts +1 -0
- package/dist/components/App.js +107 -37
- package/dist/components/Menu.d.ts +6 -1
- package/dist/components/Menu.js +228 -50
- package/dist/components/Menu.recent-projects.test.d.ts +1 -0
- package/dist/components/Menu.recent-projects.test.js +159 -0
- package/dist/components/Menu.test.d.ts +1 -0
- package/dist/components/Menu.test.js +196 -0
- package/dist/components/NewWorktree.js +30 -2
- package/dist/components/ProjectList.d.ts +10 -0
- package/dist/components/ProjectList.js +231 -0
- package/dist/components/ProjectList.recent-projects.test.d.ts +1 -0
- package/dist/components/ProjectList.recent-projects.test.js +186 -0
- package/dist/components/ProjectList.test.d.ts +1 -0
- package/dist/components/ProjectList.test.js +501 -0
- package/dist/constants/env.d.ts +3 -0
- package/dist/constants/env.js +4 -0
- package/dist/constants/error.d.ts +6 -0
- package/dist/constants/error.js +7 -0
- package/dist/hooks/useSearchMode.d.ts +15 -0
- package/dist/hooks/useSearchMode.js +67 -0
- package/dist/services/configurationManager.d.ts +1 -0
- package/dist/services/configurationManager.js +14 -7
- package/dist/services/globalSessionOrchestrator.d.ts +16 -0
- package/dist/services/globalSessionOrchestrator.js +73 -0
- package/dist/services/globalSessionOrchestrator.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.test.js +180 -0
- package/dist/services/projectManager.d.ts +60 -0
- package/dist/services/projectManager.js +418 -0
- package/dist/services/projectManager.test.d.ts +1 -0
- package/dist/services/projectManager.test.js +342 -0
- package/dist/services/sessionManager.d.ts +8 -0
- package/dist/services/sessionManager.js +38 -0
- package/dist/services/sessionManager.test.js +79 -0
- package/dist/services/worktreeService.d.ts +1 -0
- package/dist/services/worktreeService.js +20 -5
- package/dist/services/worktreeService.test.js +72 -0
- package/dist/types/index.d.ts +55 -0
- package/package.json +1 -1
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { WorktreeService } from './worktreeService.js';
|
|
2
|
+
import { ENV_VARS } from '../constants/env.js';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
export class ProjectManager {
|
|
8
|
+
constructor() {
|
|
9
|
+
Object.defineProperty(this, "currentMode", {
|
|
10
|
+
enumerable: true,
|
|
11
|
+
configurable: true,
|
|
12
|
+
writable: true,
|
|
13
|
+
value: void 0
|
|
14
|
+
});
|
|
15
|
+
Object.defineProperty(this, "currentProject", {
|
|
16
|
+
enumerable: true,
|
|
17
|
+
configurable: true,
|
|
18
|
+
writable: true,
|
|
19
|
+
value: void 0
|
|
20
|
+
});
|
|
21
|
+
Object.defineProperty(this, "projects", {
|
|
22
|
+
enumerable: true,
|
|
23
|
+
configurable: true,
|
|
24
|
+
writable: true,
|
|
25
|
+
value: []
|
|
26
|
+
});
|
|
27
|
+
Object.defineProperty(this, "worktreeServiceCache", {
|
|
28
|
+
enumerable: true,
|
|
29
|
+
configurable: true,
|
|
30
|
+
writable: true,
|
|
31
|
+
value: new Map()
|
|
32
|
+
});
|
|
33
|
+
Object.defineProperty(this, "projectsDir", {
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
value: void 0
|
|
38
|
+
});
|
|
39
|
+
// Multi-project discovery
|
|
40
|
+
Object.defineProperty(this, "projectCache", {
|
|
41
|
+
enumerable: true,
|
|
42
|
+
configurable: true,
|
|
43
|
+
writable: true,
|
|
44
|
+
value: new Map()
|
|
45
|
+
});
|
|
46
|
+
Object.defineProperty(this, "discoveryWorkers", {
|
|
47
|
+
enumerable: true,
|
|
48
|
+
configurable: true,
|
|
49
|
+
writable: true,
|
|
50
|
+
value: 4
|
|
51
|
+
});
|
|
52
|
+
Object.defineProperty(this, "recentProjects", {
|
|
53
|
+
enumerable: true,
|
|
54
|
+
configurable: true,
|
|
55
|
+
writable: true,
|
|
56
|
+
value: []
|
|
57
|
+
});
|
|
58
|
+
Object.defineProperty(this, "dataPath", {
|
|
59
|
+
enumerable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
writable: true,
|
|
62
|
+
value: void 0
|
|
63
|
+
});
|
|
64
|
+
Object.defineProperty(this, "configDir", {
|
|
65
|
+
enumerable: true,
|
|
66
|
+
configurable: true,
|
|
67
|
+
writable: true,
|
|
68
|
+
value: void 0
|
|
69
|
+
});
|
|
70
|
+
// Initialize mode based on environment variables
|
|
71
|
+
const multiProjectRoot = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
72
|
+
this.projectsDir = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
73
|
+
// Set initial mode
|
|
74
|
+
this.currentMode = multiProjectRoot ? 'multi-project' : 'normal';
|
|
75
|
+
// If in multi-project mode but no projects dir, default to normal mode
|
|
76
|
+
if (this.currentMode === 'multi-project' && !this.projectsDir) {
|
|
77
|
+
this.currentMode = 'normal';
|
|
78
|
+
}
|
|
79
|
+
// Initialize recent projects
|
|
80
|
+
const homeDir = homedir();
|
|
81
|
+
this.configDir =
|
|
82
|
+
process.platform === 'win32'
|
|
83
|
+
? path.join(process.env['APPDATA'] || path.join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
|
|
84
|
+
: path.join(homeDir, '.config', 'ccmanager');
|
|
85
|
+
// Ensure config directory exists
|
|
86
|
+
if (!existsSync(this.configDir)) {
|
|
87
|
+
mkdirSync(this.configDir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
this.dataPath = path.join(this.configDir, 'recent-projects.json');
|
|
90
|
+
this.loadRecentProjects();
|
|
91
|
+
}
|
|
92
|
+
setMode(mode) {
|
|
93
|
+
this.currentMode = mode;
|
|
94
|
+
// Clear current project when switching to normal mode
|
|
95
|
+
if (mode === 'normal') {
|
|
96
|
+
this.currentProject = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
selectProject(project) {
|
|
100
|
+
this.currentProject = project;
|
|
101
|
+
}
|
|
102
|
+
getWorktreeService(projectPath) {
|
|
103
|
+
// Use provided path or fall back to current project path or current directory
|
|
104
|
+
const path = projectPath || this.currentProject?.path || process.cwd();
|
|
105
|
+
// Check cache first
|
|
106
|
+
if (this.worktreeServiceCache.has(path)) {
|
|
107
|
+
return this.worktreeServiceCache.get(path);
|
|
108
|
+
}
|
|
109
|
+
// Create new service and cache it
|
|
110
|
+
const service = new WorktreeService(path);
|
|
111
|
+
this.worktreeServiceCache.set(path, service);
|
|
112
|
+
return service;
|
|
113
|
+
}
|
|
114
|
+
async refreshProjects() {
|
|
115
|
+
if (!this.projectsDir) {
|
|
116
|
+
throw new Error('Projects directory not configured');
|
|
117
|
+
}
|
|
118
|
+
// Discover projects
|
|
119
|
+
this.projects = await this.discoverProjects(this.projectsDir);
|
|
120
|
+
// Update current project if it still exists
|
|
121
|
+
if (this.currentProject) {
|
|
122
|
+
const updatedProject = this.projects.find(p => p.path === this.currentProject.path);
|
|
123
|
+
if (updatedProject) {
|
|
124
|
+
this.currentProject = updatedProject;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Current project no longer exists
|
|
128
|
+
this.currentProject = undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Helper methods
|
|
133
|
+
isMultiProjectEnabled() {
|
|
134
|
+
return !!process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
135
|
+
}
|
|
136
|
+
getProjectsDir() {
|
|
137
|
+
return this.projectsDir;
|
|
138
|
+
}
|
|
139
|
+
getCurrentProjectPath() {
|
|
140
|
+
return this.currentProject?.path || process.cwd();
|
|
141
|
+
}
|
|
142
|
+
// Clear cache for a specific project
|
|
143
|
+
clearWorktreeServiceCache(projectPath) {
|
|
144
|
+
if (projectPath) {
|
|
145
|
+
this.worktreeServiceCache.delete(projectPath);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Clear all cache
|
|
149
|
+
this.worktreeServiceCache.clear();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Get all cached WorktreeService instances (useful for cleanup)
|
|
153
|
+
getCachedServices() {
|
|
154
|
+
return new Map(this.worktreeServiceCache);
|
|
155
|
+
}
|
|
156
|
+
// Recent projects methods
|
|
157
|
+
loadRecentProjects() {
|
|
158
|
+
try {
|
|
159
|
+
if (existsSync(this.dataPath)) {
|
|
160
|
+
const data = readFileSync(this.dataPath, 'utf-8');
|
|
161
|
+
this.recentProjects = JSON.parse(data) || [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
console.error('Failed to load recent projects:', error);
|
|
166
|
+
this.recentProjects = [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
saveRecentProjects() {
|
|
170
|
+
try {
|
|
171
|
+
writeFileSync(this.dataPath, JSON.stringify(this.recentProjects, null, 2));
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
console.error('Failed to save recent projects:', error);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
getRecentProjects(limit) {
|
|
178
|
+
// Return recent projects sorted by last accessed
|
|
179
|
+
const sorted = this.recentProjects.sort((a, b) => b.lastAccessed - a.lastAccessed);
|
|
180
|
+
// Apply limit if specified, otherwise use default MAX_RECENT_PROJECTS
|
|
181
|
+
const maxItems = limit !== undefined ? limit : ProjectManager.MAX_RECENT_PROJECTS;
|
|
182
|
+
return maxItems > 0 ? sorted.slice(0, maxItems) : sorted;
|
|
183
|
+
}
|
|
184
|
+
addRecentProject(project) {
|
|
185
|
+
if (project.path === 'EXIT_APPLICATION') {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const existingIndex = this.recentProjects.findIndex(p => p.path === project.path);
|
|
189
|
+
const recentProject = {
|
|
190
|
+
path: project.path,
|
|
191
|
+
name: project.name,
|
|
192
|
+
lastAccessed: Date.now(),
|
|
193
|
+
};
|
|
194
|
+
if (existingIndex !== -1) {
|
|
195
|
+
// Update existing project
|
|
196
|
+
this.recentProjects[existingIndex] = recentProject;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
// Add new project
|
|
200
|
+
this.recentProjects.unshift(recentProject);
|
|
201
|
+
}
|
|
202
|
+
// Sort by last accessed (newest first)
|
|
203
|
+
this.recentProjects = this.recentProjects.sort((a, b) => b.lastAccessed - a.lastAccessed);
|
|
204
|
+
// Save to disk
|
|
205
|
+
this.saveRecentProjects();
|
|
206
|
+
}
|
|
207
|
+
clearRecentProjects() {
|
|
208
|
+
this.recentProjects = [];
|
|
209
|
+
this.saveRecentProjects();
|
|
210
|
+
}
|
|
211
|
+
// Multi-project discovery methods
|
|
212
|
+
async discoverProjects(projectsDir) {
|
|
213
|
+
const projects = [];
|
|
214
|
+
const projectMap = new Map();
|
|
215
|
+
try {
|
|
216
|
+
// Verify the directory exists
|
|
217
|
+
await fs.access(projectsDir);
|
|
218
|
+
// Step 1: Fast concurrent directory discovery
|
|
219
|
+
const directories = await this.discoverDirectories(projectsDir);
|
|
220
|
+
// Step 2: Process directories in parallel to check if they're git repos
|
|
221
|
+
const results = await this.processDirectoriesInParallel(directories, projectsDir);
|
|
222
|
+
// Step 3: Create project objects (all results are valid git repos)
|
|
223
|
+
for (const result of results) {
|
|
224
|
+
// Handle name conflicts
|
|
225
|
+
let displayName = result.name;
|
|
226
|
+
if (projectMap.has(result.name)) {
|
|
227
|
+
displayName = result.relativePath.replace(/[\\/\\\\]/g, '/');
|
|
228
|
+
}
|
|
229
|
+
const project = {
|
|
230
|
+
name: displayName,
|
|
231
|
+
path: result.path,
|
|
232
|
+
relativePath: result.relativePath,
|
|
233
|
+
isValid: true,
|
|
234
|
+
error: result.error,
|
|
235
|
+
};
|
|
236
|
+
projectMap.set(displayName, project);
|
|
237
|
+
}
|
|
238
|
+
// Convert to array and sort
|
|
239
|
+
projects.push(...projectMap.values());
|
|
240
|
+
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
241
|
+
// Cache results
|
|
242
|
+
this.projectCache.clear();
|
|
243
|
+
projects.forEach(p => this.projectCache.set(p.path, p));
|
|
244
|
+
return projects;
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (error.code === 'ENOENT') {
|
|
248
|
+
throw new Error(`Projects directory does not exist: ${projectsDir}`);
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Fast directory discovery - similar to ghq's approach
|
|
255
|
+
*/
|
|
256
|
+
async discoverDirectories(rootDir, maxDepth = 3) {
|
|
257
|
+
const tasks = [];
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
const walk = async (dir, depth) => {
|
|
260
|
+
if (depth > maxDepth)
|
|
261
|
+
return;
|
|
262
|
+
try {
|
|
263
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
264
|
+
// Process entries in parallel
|
|
265
|
+
await Promise.all(entries.map(async (entry) => {
|
|
266
|
+
if (!entry.isDirectory())
|
|
267
|
+
return;
|
|
268
|
+
if (entry.name.startsWith('.') && entry.name !== '.git')
|
|
269
|
+
return;
|
|
270
|
+
const fullPath = path.join(dir, entry.name);
|
|
271
|
+
const relativePath = path.relative(rootDir, fullPath);
|
|
272
|
+
// Quick check if this is a git repository
|
|
273
|
+
const hasGitDir = await this.hasGitDirectory(fullPath);
|
|
274
|
+
if (hasGitDir) {
|
|
275
|
+
// Found a git repository - add to tasks and skip subdirectories
|
|
276
|
+
if (!seen.has(fullPath)) {
|
|
277
|
+
seen.add(fullPath);
|
|
278
|
+
tasks.push({ path: fullPath, relativePath });
|
|
279
|
+
}
|
|
280
|
+
return; // Early termination - don't walk subdirectories
|
|
281
|
+
}
|
|
282
|
+
// Not a git repo, continue walking subdirectories
|
|
283
|
+
await walk(fullPath, depth + 1);
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
// Silently skip directories we can't read
|
|
288
|
+
if (error.code !== 'EACCES') {
|
|
289
|
+
console.error(`Error scanning directory ${dir}:`, error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
await walk(rootDir, 0);
|
|
294
|
+
return tasks;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Quick check for .git directory without running git commands
|
|
298
|
+
*/
|
|
299
|
+
async hasGitDirectory(dirPath) {
|
|
300
|
+
try {
|
|
301
|
+
const gitPath = path.join(dirPath, '.git');
|
|
302
|
+
const stats = await fs.stat(gitPath);
|
|
303
|
+
return stats.isDirectory() || stats.isFile(); // File for worktrees
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Process directories in parallel using worker pool pattern
|
|
311
|
+
*/
|
|
312
|
+
async processDirectoriesInParallel(tasks, rootDir) {
|
|
313
|
+
const results = [];
|
|
314
|
+
const queue = [...tasks];
|
|
315
|
+
const workers = [];
|
|
316
|
+
// Create worker function
|
|
317
|
+
const worker = async () => {
|
|
318
|
+
while (queue.length > 0) {
|
|
319
|
+
const task = queue.shift();
|
|
320
|
+
if (!task)
|
|
321
|
+
break;
|
|
322
|
+
const result = await this.processDirectory(task, rootDir);
|
|
323
|
+
if (result) {
|
|
324
|
+
results.push(result);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
// Start workers
|
|
329
|
+
for (let i = 0; i < this.discoveryWorkers; i++) {
|
|
330
|
+
workers.push(worker());
|
|
331
|
+
}
|
|
332
|
+
// Wait for all workers to complete
|
|
333
|
+
await Promise.all(workers);
|
|
334
|
+
return results;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Process a single directory to check if it's a valid git repo
|
|
338
|
+
* @param task - The discovery task containing path information
|
|
339
|
+
* @param _rootDir - The root directory (unused)
|
|
340
|
+
* @returns A DiscoveryResult object if the directory is a valid git repository,
|
|
341
|
+
* or null if it's not a valid git repository (will be filtered out)
|
|
342
|
+
*/
|
|
343
|
+
async processDirectory(task, _rootDir) {
|
|
344
|
+
const result = {
|
|
345
|
+
path: task.path,
|
|
346
|
+
relativePath: task.relativePath,
|
|
347
|
+
name: path.basename(task.path),
|
|
348
|
+
};
|
|
349
|
+
try {
|
|
350
|
+
// Check if directory has .git (already validated in discoverDirectories)
|
|
351
|
+
// Double-check here to ensure it's still valid
|
|
352
|
+
const hasGit = await this.hasGitDirectory(task.path);
|
|
353
|
+
if (!hasGit) {
|
|
354
|
+
// Not a git repo, return null to filter it out
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
result.error = `Failed to process: ${error.message}`;
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
async validateGitRepository(projectPath) {
|
|
364
|
+
// Simply check for .git directory existence
|
|
365
|
+
return this.hasGitDirectory(projectPath);
|
|
366
|
+
}
|
|
367
|
+
// Helper method to get a cached project
|
|
368
|
+
getCachedProject(projectPath) {
|
|
369
|
+
return this.projectCache.get(projectPath);
|
|
370
|
+
}
|
|
371
|
+
// Helper method to refresh a single project
|
|
372
|
+
async refreshProject(projectPath) {
|
|
373
|
+
if (!(await this.validateGitRepository(projectPath))) {
|
|
374
|
+
this.projectCache.delete(projectPath);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const name = path.basename(projectPath);
|
|
378
|
+
const project = {
|
|
379
|
+
name,
|
|
380
|
+
path: projectPath,
|
|
381
|
+
relativePath: name,
|
|
382
|
+
isValid: true,
|
|
383
|
+
};
|
|
384
|
+
this.projectCache.set(projectPath, project);
|
|
385
|
+
return project;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Recent projects
|
|
389
|
+
Object.defineProperty(ProjectManager, "MAX_RECENT_PROJECTS", {
|
|
390
|
+
enumerable: true,
|
|
391
|
+
configurable: true,
|
|
392
|
+
writable: true,
|
|
393
|
+
value: 5
|
|
394
|
+
});
|
|
395
|
+
// Create singleton instance
|
|
396
|
+
let _instance = null;
|
|
397
|
+
export const projectManager = {
|
|
398
|
+
get instance() {
|
|
399
|
+
if (!_instance) {
|
|
400
|
+
_instance = new ProjectManager();
|
|
401
|
+
}
|
|
402
|
+
return _instance;
|
|
403
|
+
},
|
|
404
|
+
// Proxy methods to maintain backward compatibility with recentProjectsService
|
|
405
|
+
getRecentProjects(limit) {
|
|
406
|
+
return this.instance.getRecentProjects(limit);
|
|
407
|
+
},
|
|
408
|
+
addRecentProject(project) {
|
|
409
|
+
return this.instance.addRecentProject(project);
|
|
410
|
+
},
|
|
411
|
+
clearRecentProjects() {
|
|
412
|
+
return this.instance.clearRecentProjects();
|
|
413
|
+
},
|
|
414
|
+
// Reset instance for testing
|
|
415
|
+
_resetForTesting() {
|
|
416
|
+
_instance = null;
|
|
417
|
+
},
|
|
418
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|