ccmanager 0.1.15 → 0.2.1

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 (38) hide show
  1. package/dist/cli.js +3 -0
  2. package/dist/components/App.js +35 -1
  3. package/dist/components/ConfigureCommand.js +367 -121
  4. package/dist/components/Menu.js +18 -18
  5. package/dist/components/PresetSelector.d.ts +7 -0
  6. package/dist/components/PresetSelector.js +52 -0
  7. package/dist/hooks/useGitStatus.d.ts +2 -0
  8. package/dist/hooks/useGitStatus.js +52 -0
  9. package/dist/hooks/useGitStatus.test.d.ts +1 -0
  10. package/dist/hooks/useGitStatus.test.js +186 -0
  11. package/dist/services/configurationManager.d.ts +11 -1
  12. package/dist/services/configurationManager.js +111 -3
  13. package/dist/services/configurationManager.selectPresetOnStart.test.d.ts +1 -0
  14. package/dist/services/configurationManager.selectPresetOnStart.test.js +103 -0
  15. package/dist/services/configurationManager.test.d.ts +1 -0
  16. package/dist/services/configurationManager.test.js +313 -0
  17. package/dist/services/sessionManager.d.ts +1 -0
  18. package/dist/services/sessionManager.js +69 -0
  19. package/dist/services/sessionManager.test.js +103 -0
  20. package/dist/services/worktreeConfigManager.d.ts +10 -0
  21. package/dist/services/worktreeConfigManager.js +27 -0
  22. package/dist/services/worktreeService.js +8 -0
  23. package/dist/services/worktreeService.test.js +8 -0
  24. package/dist/types/index.d.ts +16 -0
  25. package/dist/utils/concurrencyLimit.d.ts +4 -0
  26. package/dist/utils/concurrencyLimit.js +30 -0
  27. package/dist/utils/concurrencyLimit.test.d.ts +1 -0
  28. package/dist/utils/concurrencyLimit.test.js +63 -0
  29. package/dist/utils/gitStatus.d.ts +19 -0
  30. package/dist/utils/gitStatus.js +146 -0
  31. package/dist/utils/gitStatus.test.d.ts +1 -0
  32. package/dist/utils/gitStatus.test.js +141 -0
  33. package/dist/utils/worktreeConfig.d.ts +3 -0
  34. package/dist/utils/worktreeConfig.js +43 -0
  35. package/dist/utils/worktreeUtils.d.ts +37 -0
  36. package/dist/utils/worktreeUtils.js +114 -0
  37. package/dist/utils/worktreeUtils.test.js +105 -1
  38. package/package.json +1 -1
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createConcurrencyLimited } from './concurrencyLimit.js';
3
+ describe('createConcurrencyLimited', () => {
4
+ it('should limit concurrent executions', async () => {
5
+ let running = 0;
6
+ let maxRunning = 0;
7
+ const task = async (id) => {
8
+ running++;
9
+ maxRunning = Math.max(maxRunning, running);
10
+ // Simulate work
11
+ await new Promise(resolve => setTimeout(resolve, 10));
12
+ running--;
13
+ return id;
14
+ };
15
+ const limitedTask = createConcurrencyLimited(task, 2);
16
+ // Start 5 tasks
17
+ const promises = [
18
+ limitedTask(1),
19
+ limitedTask(2),
20
+ limitedTask(3),
21
+ limitedTask(4),
22
+ limitedTask(5),
23
+ ];
24
+ const results = await Promise.all(promises);
25
+ // All tasks should complete
26
+ expect(results).toEqual([1, 2, 3, 4, 5]);
27
+ // Max concurrent should not exceed limit
28
+ expect(maxRunning).toBeLessThanOrEqual(2);
29
+ // All tasks should have finished
30
+ expect(running).toBe(0);
31
+ });
32
+ it('should handle errors without blocking queue', async () => {
33
+ let callCount = 0;
34
+ const original = async () => {
35
+ callCount++;
36
+ if (callCount === 1) {
37
+ throw new Error('Task failed');
38
+ }
39
+ return 'success';
40
+ };
41
+ const limited = createConcurrencyLimited(original, 1);
42
+ // Start failing task first
43
+ const promise1 = limited().catch(e => e.message);
44
+ // Queue successful task
45
+ const promise2 = limited();
46
+ const results = await Promise.all([promise1, promise2]);
47
+ expect(results[0]).toBe('Task failed');
48
+ expect(results[1]).toBe('success');
49
+ });
50
+ it('should preserve function arguments', async () => {
51
+ const original = async (a, b, c) => {
52
+ return `${a}-${b}-${c}`;
53
+ };
54
+ const limited = createConcurrencyLimited(original, 1);
55
+ const result = await limited(42, 'test', true);
56
+ expect(result).toBe('42-test-true');
57
+ });
58
+ it('should throw for invalid maxConcurrent', () => {
59
+ const fn = async () => 'test';
60
+ expect(() => createConcurrencyLimited(fn, 0)).toThrow('maxConcurrent must be at least 1');
61
+ expect(() => createConcurrencyLimited(fn, -1)).toThrow('maxConcurrent must be at least 1');
62
+ });
63
+ });
@@ -0,0 +1,19 @@
1
+ export interface GitStatus {
2
+ filesAdded: number;
3
+ filesDeleted: number;
4
+ aheadCount: number;
5
+ behindCount: number;
6
+ parentBranch: string | null;
7
+ }
8
+ export interface GitOperationResult<T> {
9
+ success: boolean;
10
+ data?: T;
11
+ error?: string;
12
+ skipped?: boolean;
13
+ }
14
+ export declare function getGitStatus(worktreePath: string, signal: AbortSignal): Promise<GitOperationResult<GitStatus>>;
15
+ export declare function formatGitFileChanges(status: GitStatus): string;
16
+ export declare function formatGitAheadBehind(status: GitStatus): string;
17
+ export declare function formatGitStatus(status: GitStatus): string;
18
+ export declare function formatParentBranch(parentBranch: string | null, currentBranch: string): string;
19
+ export declare const getGitStatusLimited: (worktreePath: string, signal: AbortSignal) => Promise<GitOperationResult<GitStatus>>;
@@ -0,0 +1,146 @@
1
+ import { promisify } from 'util';
2
+ import { exec, execFile } from 'child_process';
3
+ import { getWorktreeParentBranch } from './worktreeConfig.js';
4
+ import { createConcurrencyLimited } from './concurrencyLimit.js';
5
+ const execp = promisify(exec);
6
+ const execFilePromisified = promisify(execFile);
7
+ export async function getGitStatus(worktreePath, signal) {
8
+ try {
9
+ // Get unstaged changes
10
+ const [diffResult, stagedResult, branchResult, parentBranch] = await Promise.all([
11
+ execp('git diff --shortstat', { cwd: worktreePath, signal }).catch(() => EMPTY_EXEC_RESULT),
12
+ execp('git diff --staged --shortstat', {
13
+ cwd: worktreePath,
14
+ signal,
15
+ }).catch(() => EMPTY_EXEC_RESULT),
16
+ execp('git branch --show-current', { cwd: worktreePath, signal }).catch(() => EMPTY_EXEC_RESULT),
17
+ getWorktreeParentBranch(worktreePath, signal),
18
+ ]);
19
+ // Parse file changes
20
+ let filesAdded = 0;
21
+ let filesDeleted = 0;
22
+ if (diffResult.stdout) {
23
+ const stats = parseGitStats(diffResult.stdout);
24
+ filesAdded += stats.insertions;
25
+ filesDeleted += stats.deletions;
26
+ }
27
+ if (stagedResult.stdout) {
28
+ const stats = parseGitStats(stagedResult.stdout);
29
+ filesAdded += stats.insertions;
30
+ filesDeleted += stats.deletions;
31
+ }
32
+ // Get ahead/behind counts
33
+ let aheadCount = 0;
34
+ let behindCount = 0;
35
+ const currentBranch = branchResult.stdout.trim();
36
+ if (currentBranch && parentBranch && currentBranch !== parentBranch) {
37
+ try {
38
+ const aheadBehindResult = await execFilePromisified('git', ['rev-list', '--left-right', '--count', `${parentBranch}...HEAD`], { cwd: worktreePath, signal });
39
+ const [behind, ahead] = aheadBehindResult.stdout
40
+ .trim()
41
+ .split('\t')
42
+ .map(n => parseInt(n, 10));
43
+ aheadCount = ahead || 0;
44
+ behindCount = behind || 0;
45
+ }
46
+ catch {
47
+ // Branch comparison might fail
48
+ }
49
+ }
50
+ return {
51
+ success: true,
52
+ data: {
53
+ filesAdded,
54
+ filesDeleted,
55
+ aheadCount,
56
+ behindCount,
57
+ parentBranch,
58
+ },
59
+ };
60
+ }
61
+ catch (error) {
62
+ let errorMessage = '';
63
+ if (error instanceof Error) {
64
+ errorMessage = error.message;
65
+ }
66
+ else {
67
+ errorMessage = String(error);
68
+ }
69
+ return {
70
+ success: false,
71
+ error: errorMessage,
72
+ };
73
+ }
74
+ }
75
+ // Split git status formatting into file changes and ahead/behind
76
+ export function formatGitFileChanges(status) {
77
+ const parts = [];
78
+ const colors = {
79
+ green: '\x1b[32m',
80
+ red: '\x1b[31m',
81
+ reset: '\x1b[0m',
82
+ };
83
+ // File changes
84
+ if (status.filesAdded > 0) {
85
+ parts.push(`${colors.green}+${status.filesAdded}${colors.reset}`);
86
+ }
87
+ if (status.filesDeleted > 0) {
88
+ parts.push(`${colors.red}-${status.filesDeleted}${colors.reset}`);
89
+ }
90
+ return parts.join(' ');
91
+ }
92
+ export function formatGitAheadBehind(status) {
93
+ const parts = [];
94
+ const colors = {
95
+ cyan: '\x1b[36m',
96
+ magenta: '\x1b[35m',
97
+ reset: '\x1b[0m',
98
+ };
99
+ // Ahead/behind - compact format with arrows
100
+ if (status.aheadCount > 0) {
101
+ parts.push(`${colors.cyan}↑${status.aheadCount}${colors.reset}`);
102
+ }
103
+ if (status.behindCount > 0) {
104
+ parts.push(`${colors.magenta}↓${status.behindCount}${colors.reset}`);
105
+ }
106
+ return parts.join(' ');
107
+ }
108
+ // Keep the original function for backward compatibility
109
+ export function formatGitStatus(status) {
110
+ const fileChanges = formatGitFileChanges(status);
111
+ const aheadBehind = formatGitAheadBehind(status);
112
+ const parts = [];
113
+ if (fileChanges)
114
+ parts.push(fileChanges);
115
+ if (aheadBehind)
116
+ parts.push(aheadBehind);
117
+ return parts.join(' ');
118
+ }
119
+ export function formatParentBranch(parentBranch, currentBranch) {
120
+ // Only show parent branch if it exists and is different from current branch
121
+ if (!parentBranch || parentBranch === currentBranch) {
122
+ return '';
123
+ }
124
+ const colors = {
125
+ dim: '\x1b[90m',
126
+ reset: '\x1b[0m',
127
+ };
128
+ return `${colors.dim}(${parentBranch})${colors.reset}`;
129
+ }
130
+ const EMPTY_EXEC_RESULT = { stdout: '', stderr: '' };
131
+ function parseGitStats(statLine) {
132
+ let insertions = 0;
133
+ let deletions = 0;
134
+ // Parse git diff --shortstat output
135
+ // Example: " 3 files changed, 42 insertions(+), 10 deletions(-)"
136
+ const insertMatch = statLine.match(/(\d+) insertion/);
137
+ const deleteMatch = statLine.match(/(\d+) deletion/);
138
+ if (insertMatch && insertMatch[1]) {
139
+ insertions = parseInt(insertMatch[1], 10);
140
+ }
141
+ if (deleteMatch && deleteMatch[1]) {
142
+ deletions = parseInt(deleteMatch[1], 10);
143
+ }
144
+ return { insertions, deletions };
145
+ }
146
+ export const getGitStatusLimited = createConcurrencyLimited(getGitStatus, 10);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { formatGitStatus, formatGitFileChanges, formatGitAheadBehind, formatParentBranch, getGitStatus, } from './gitStatus.js';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as os from 'os';
8
+ // Mock worktreeConfigManager
9
+ vi.mock('../services/worktreeConfigManager.js', () => ({
10
+ worktreeConfigManager: {
11
+ initialize: vi.fn(),
12
+ isAvailable: vi.fn(() => true),
13
+ reset: vi.fn(),
14
+ },
15
+ }));
16
+ const execAsync = promisify(exec);
17
+ describe('formatGitStatus', () => {
18
+ it('should format status with ANSI colors', () => {
19
+ const status = {
20
+ filesAdded: 42,
21
+ filesDeleted: 10,
22
+ aheadCount: 5,
23
+ behindCount: 3,
24
+ parentBranch: 'main',
25
+ };
26
+ const formatted = formatGitStatus(status);
27
+ expect(formatted).toBe('\x1b[32m+42\x1b[0m \x1b[31m-10\x1b[0m \x1b[36m↑5\x1b[0m \x1b[35m↓3\x1b[0m');
28
+ });
29
+ it('should use formatGitStatusWithColors as alias', () => {
30
+ const status = {
31
+ filesAdded: 1,
32
+ filesDeleted: 2,
33
+ aheadCount: 3,
34
+ behindCount: 4,
35
+ parentBranch: 'main',
36
+ };
37
+ const withColors = formatGitStatus(status);
38
+ const withColorsParam = formatGitStatus(status);
39
+ expect(withColors).toBe(withColorsParam);
40
+ });
41
+ });
42
+ describe('GitService Integration Tests', { timeout: 10000 }, () => {
43
+ it('should handle concurrent calls correctly', async () => {
44
+ // Create a temporary git repo for testing
45
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-test-'));
46
+ try {
47
+ // Initialize git repo
48
+ await execAsync('git init', { cwd: tmpDir });
49
+ await execAsync('git config user.email "test@example.com"', {
50
+ cwd: tmpDir,
51
+ });
52
+ await execAsync('git config user.name "Test User"', { cwd: tmpDir });
53
+ await execAsync('git config commit.gpgsign false', { cwd: tmpDir });
54
+ // Create a file and commit
55
+ fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'Hello World');
56
+ await execAsync('git add test.txt', { cwd: tmpDir });
57
+ await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
58
+ // Test concurrent calls - all should succeed now without locking
59
+ // Create abort controllers for each call
60
+ const controller1 = new AbortController();
61
+ const controller2 = new AbortController();
62
+ const controller3 = new AbortController();
63
+ const results = await Promise.all([
64
+ getGitStatus(tmpDir, controller1.signal),
65
+ getGitStatus(tmpDir, controller2.signal),
66
+ getGitStatus(tmpDir, controller3.signal),
67
+ ]);
68
+ // All should succeed
69
+ const successCount = results.filter(r => r.success).length;
70
+ expect(successCount).toBe(3);
71
+ // All results should have the same data
72
+ const firstData = results[0].data;
73
+ results.forEach(result => {
74
+ expect(result.success).toBe(true);
75
+ expect(result.data).toEqual(firstData);
76
+ });
77
+ }
78
+ finally {
79
+ // Cleanup
80
+ fs.rmSync(tmpDir, { recursive: true, force: true });
81
+ }
82
+ });
83
+ });
84
+ describe('formatGitFileChanges', () => {
85
+ it('should format only file changes', () => {
86
+ const status = {
87
+ filesAdded: 10,
88
+ filesDeleted: 5,
89
+ aheadCount: 3,
90
+ behindCount: 2,
91
+ parentBranch: 'main',
92
+ };
93
+ expect(formatGitFileChanges(status)).toBe('\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m');
94
+ });
95
+ it('should handle zero file changes', () => {
96
+ const status = {
97
+ filesAdded: 0,
98
+ filesDeleted: 0,
99
+ aheadCount: 3,
100
+ behindCount: 2,
101
+ parentBranch: 'main',
102
+ };
103
+ expect(formatGitFileChanges(status)).toBe('');
104
+ });
105
+ });
106
+ describe('formatGitAheadBehind', () => {
107
+ it('should format only ahead/behind markers', () => {
108
+ const status = {
109
+ filesAdded: 10,
110
+ filesDeleted: 5,
111
+ aheadCount: 3,
112
+ behindCount: 2,
113
+ parentBranch: 'main',
114
+ };
115
+ expect(formatGitAheadBehind(status)).toBe('\x1b[36m↑3\x1b[0m \x1b[35m↓2\x1b[0m');
116
+ });
117
+ it('should handle zero ahead/behind', () => {
118
+ const status = {
119
+ filesAdded: 10,
120
+ filesDeleted: 5,
121
+ aheadCount: 0,
122
+ behindCount: 0,
123
+ parentBranch: 'main',
124
+ };
125
+ expect(formatGitAheadBehind(status)).toBe('');
126
+ });
127
+ });
128
+ describe('formatParentBranch', () => {
129
+ it('should return empty string when parent and current branch are the same', () => {
130
+ expect(formatParentBranch('main', 'main')).toBe('');
131
+ expect(formatParentBranch('feature', 'feature')).toBe('');
132
+ });
133
+ it('should format parent branch when different from current', () => {
134
+ expect(formatParentBranch('main', 'feature')).toBe('\x1b[90m(main)\x1b[0m');
135
+ expect(formatParentBranch('develop', 'feature-123')).toBe('\x1b[90m(develop)\x1b[0m');
136
+ });
137
+ it('should include color codes', () => {
138
+ const formatted = formatParentBranch('main', 'feature');
139
+ expect(formatted).toBe('\x1b[90m(main)\x1b[0m');
140
+ });
141
+ });
@@ -0,0 +1,3 @@
1
+ export declare function isWorktreeConfigEnabled(gitPath?: string): boolean;
2
+ export declare function getWorktreeParentBranch(worktreePath: string, signal?: AbortSignal): Promise<string | null>;
3
+ export declare function setWorktreeParentBranch(worktreePath: string, parentBranch: string): void;
@@ -0,0 +1,43 @@
1
+ import { promisify } from 'util';
2
+ import { exec, execSync, execFileSync } from 'child_process';
3
+ import { worktreeConfigManager } from '../services/worktreeConfigManager.js';
4
+ const execp = promisify(exec);
5
+ export function isWorktreeConfigEnabled(gitPath) {
6
+ try {
7
+ const result = execSync('git config extensions.worktreeConfig', {
8
+ cwd: gitPath || process.cwd(),
9
+ encoding: 'utf8',
10
+ }).trim();
11
+ return result === 'true';
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export async function getWorktreeParentBranch(worktreePath, signal) {
18
+ // Return null if worktree config extension is not available
19
+ if (!worktreeConfigManager.isAvailable()) {
20
+ return null;
21
+ }
22
+ try {
23
+ const result = await execp('git config --worktree ccmanager.parentBranch', {
24
+ cwd: worktreePath,
25
+ encoding: 'utf8',
26
+ signal,
27
+ });
28
+ return result.stdout.trim() || null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ export function setWorktreeParentBranch(worktreePath, parentBranch) {
35
+ // Skip if worktree config extension is not available
36
+ if (!worktreeConfigManager.isAvailable()) {
37
+ return;
38
+ }
39
+ execFileSync('git', ['config', '--worktree', 'ccmanager.parentBranch', parentBranch], {
40
+ cwd: worktreePath,
41
+ encoding: 'utf8',
42
+ });
43
+ }
@@ -1,5 +1,42 @@
1
+ import { Worktree, Session } from '../types/index.js';
2
+ /**
3
+ * Worktree item with formatted content for display.
4
+ */
5
+ interface WorktreeItem {
6
+ worktree: Worktree;
7
+ session?: Session;
8
+ baseLabel: string;
9
+ fileChanges: string;
10
+ aheadBehind: string;
11
+ parentBranch: string;
12
+ error?: string;
13
+ lengths: {
14
+ base: number;
15
+ fileChanges: number;
16
+ aheadBehind: number;
17
+ parentBranch: number;
18
+ };
19
+ }
20
+ export declare function truncateString(str: string, maxLength: number): string;
1
21
  export declare function generateWorktreeDirectory(branchName: string, pattern?: string): string;
2
22
  export declare function extractBranchParts(branchName: string): {
3
23
  prefix?: string;
4
24
  name: string;
5
25
  };
26
+ /**
27
+ * Prepares worktree content for display with plain and colored versions.
28
+ */
29
+ export declare function prepareWorktreeItems(worktrees: Worktree[], sessions: Session[]): WorktreeItem[];
30
+ /**
31
+ * Calculates column positions based on content widths.
32
+ */
33
+ export declare function calculateColumnPositions(items: WorktreeItem[]): {
34
+ fileChanges: number;
35
+ aheadBehind: number;
36
+ parentBranch: number;
37
+ };
38
+ /**
39
+ * Assembles the final worktree label with proper column alignment
40
+ */
41
+ export declare function assembleWorktreeLabel(item: WorktreeItem, columns: ReturnType<typeof calculateColumnPositions>): string;
42
+ export {};
@@ -1,4 +1,17 @@
1
1
  import path from 'path';
2
+ import { getStatusDisplay } from '../constants/statusIcons.js';
3
+ import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from './gitStatus.js';
4
+ // Constants
5
+ const MAX_BRANCH_NAME_LENGTH = 40; // Maximum characters for branch name display
6
+ const MIN_COLUMN_PADDING = 2; // Minimum spaces between columns
7
+ // Strip ANSI escape codes for length calculation
8
+ const stripAnsi = (str) => str.replace(/\x1b\[[0-9;]*m/g, '');
9
+ // Utility function to truncate strings with ellipsis
10
+ export function truncateString(str, maxLength) {
11
+ if (str.length <= maxLength)
12
+ return str;
13
+ return str.substring(0, maxLength - 3) + '...';
14
+ }
2
15
  export function generateWorktreeDirectory(branchName, pattern) {
3
16
  // Default pattern if not specified
4
17
  const defaultPattern = '../{branch}';
@@ -27,3 +40,104 @@ export function extractBranchParts(branchName) {
27
40
  }
28
41
  return { name: branchName };
29
42
  }
43
+ /**
44
+ * Prepares worktree content for display with plain and colored versions.
45
+ */
46
+ export function prepareWorktreeItems(worktrees, sessions) {
47
+ return worktrees.map(wt => {
48
+ const session = sessions.find(s => s.worktreePath === wt.path);
49
+ const status = session ? ` [${getStatusDisplay(session.state)}]` : '';
50
+ const fullBranchName = wt.branch
51
+ ? wt.branch.replace('refs/heads/', '')
52
+ : 'detached';
53
+ const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
54
+ const isMain = wt.isMainWorktree ? ' (main)' : '';
55
+ const baseLabel = `${branchName}${isMain}${status}`;
56
+ let fileChanges = '';
57
+ let aheadBehind = '';
58
+ let parentBranch = '';
59
+ let error = '';
60
+ if (wt.gitStatus) {
61
+ fileChanges = formatGitFileChanges(wt.gitStatus);
62
+ aheadBehind = formatGitAheadBehind(wt.gitStatus);
63
+ parentBranch = formatParentBranch(wt.gitStatus.parentBranch, fullBranchName);
64
+ }
65
+ else if (wt.gitStatusError) {
66
+ // Format error in red
67
+ error = `\x1b[31m[git error]\x1b[0m`;
68
+ }
69
+ else {
70
+ // Show fetching status in dim gray
71
+ fileChanges = '\x1b[90m[fetching...]\x1b[0m';
72
+ }
73
+ return {
74
+ worktree: wt,
75
+ session,
76
+ baseLabel,
77
+ fileChanges,
78
+ aheadBehind,
79
+ parentBranch,
80
+ error,
81
+ lengths: {
82
+ base: stripAnsi(baseLabel).length,
83
+ fileChanges: stripAnsi(fileChanges).length,
84
+ aheadBehind: stripAnsi(aheadBehind).length,
85
+ parentBranch: stripAnsi(parentBranch).length,
86
+ },
87
+ };
88
+ });
89
+ }
90
+ /**
91
+ * Calculates column positions based on content widths.
92
+ */
93
+ export function calculateColumnPositions(items) {
94
+ // Calculate maximum widths from pre-calculated lengths
95
+ let maxBranchLength = 0;
96
+ let maxFileChangesLength = 0;
97
+ let maxAheadBehindLength = 0;
98
+ items.forEach(item => {
99
+ // Skip items with errors for alignment calculation
100
+ if (item.error)
101
+ return;
102
+ maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
103
+ maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
104
+ maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
105
+ });
106
+ // Simple column positioning
107
+ const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING;
108
+ const aheadBehindColumn = fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
109
+ const parentBranchColumn = aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
110
+ return {
111
+ fileChanges: fileChangesColumn,
112
+ aheadBehind: aheadBehindColumn,
113
+ parentBranch: parentBranchColumn,
114
+ };
115
+ }
116
+ // Pad string to column position
117
+ function padTo(str, visibleLength, column) {
118
+ return str + ' '.repeat(Math.max(0, column - visibleLength));
119
+ }
120
+ /**
121
+ * Assembles the final worktree label with proper column alignment
122
+ */
123
+ export function assembleWorktreeLabel(item, columns) {
124
+ // If there's an error, just show the base label with error appended
125
+ if (item.error) {
126
+ return `${item.baseLabel} ${item.error}`;
127
+ }
128
+ let label = item.baseLabel;
129
+ let currentLength = item.lengths.base;
130
+ if (item.fileChanges) {
131
+ label = padTo(label, currentLength, columns.fileChanges) + item.fileChanges;
132
+ currentLength = columns.fileChanges + item.lengths.fileChanges;
133
+ }
134
+ if (item.aheadBehind) {
135
+ label = padTo(label, currentLength, columns.aheadBehind) + item.aheadBehind;
136
+ currentLength = columns.aheadBehind + item.lengths.aheadBehind;
137
+ }
138
+ if (item.parentBranch) {
139
+ label =
140
+ padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
141
+ }
142
+ return label;
143
+ }