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.
Files changed (44) hide show
  1. package/README.md +34 -1
  2. package/dist/cli.d.ts +4 -0
  3. package/dist/cli.js +30 -2
  4. package/dist/cli.test.d.ts +1 -0
  5. package/dist/cli.test.js +67 -0
  6. package/dist/components/App.d.ts +1 -0
  7. package/dist/components/App.js +107 -37
  8. package/dist/components/Menu.d.ts +6 -1
  9. package/dist/components/Menu.js +228 -50
  10. package/dist/components/Menu.recent-projects.test.d.ts +1 -0
  11. package/dist/components/Menu.recent-projects.test.js +159 -0
  12. package/dist/components/Menu.test.d.ts +1 -0
  13. package/dist/components/Menu.test.js +196 -0
  14. package/dist/components/NewWorktree.js +30 -2
  15. package/dist/components/ProjectList.d.ts +10 -0
  16. package/dist/components/ProjectList.js +231 -0
  17. package/dist/components/ProjectList.recent-projects.test.d.ts +1 -0
  18. package/dist/components/ProjectList.recent-projects.test.js +186 -0
  19. package/dist/components/ProjectList.test.d.ts +1 -0
  20. package/dist/components/ProjectList.test.js +501 -0
  21. package/dist/constants/env.d.ts +3 -0
  22. package/dist/constants/env.js +4 -0
  23. package/dist/constants/error.d.ts +6 -0
  24. package/dist/constants/error.js +7 -0
  25. package/dist/hooks/useSearchMode.d.ts +15 -0
  26. package/dist/hooks/useSearchMode.js +67 -0
  27. package/dist/services/configurationManager.d.ts +1 -0
  28. package/dist/services/configurationManager.js +14 -7
  29. package/dist/services/globalSessionOrchestrator.d.ts +16 -0
  30. package/dist/services/globalSessionOrchestrator.js +73 -0
  31. package/dist/services/globalSessionOrchestrator.test.d.ts +1 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +180 -0
  33. package/dist/services/projectManager.d.ts +60 -0
  34. package/dist/services/projectManager.js +418 -0
  35. package/dist/services/projectManager.test.d.ts +1 -0
  36. package/dist/services/projectManager.test.js +342 -0
  37. package/dist/services/sessionManager.d.ts +8 -0
  38. package/dist/services/sessionManager.js +38 -0
  39. package/dist/services/sessionManager.test.js +79 -0
  40. package/dist/services/worktreeService.d.ts +1 -0
  41. package/dist/services/worktreeService.js +20 -5
  42. package/dist/services/worktreeService.test.js +72 -0
  43. package/dist/types/index.d.ts +55 -0
  44. 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 {};