ccmanager 3.8.1 → 3.10.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/dist/components/App.js +11 -2
- package/dist/components/App.test.js +4 -2
- package/dist/components/Configuration.js +14 -0
- package/dist/components/ConfigureMerge.d.ts +6 -0
- package/dist/components/ConfigureMerge.js +81 -0
- package/dist/components/Dashboard.d.ts +12 -0
- package/dist/components/Dashboard.js +443 -0
- package/dist/components/Dashboard.test.js +348 -0
- package/dist/components/MergeWorktree.js +20 -7
- package/dist/services/config/configEditor.d.ts +3 -1
- package/dist/services/config/configEditor.js +13 -0
- package/dist/services/config/configReader.d.ts +2 -1
- package/dist/services/config/configReader.js +12 -0
- package/dist/services/config/globalConfigManager.d.ts +3 -1
- package/dist/services/config/globalConfigManager.js +7 -0
- package/dist/services/config/projectConfigManager.d.ts +3 -1
- package/dist/services/config/projectConfigManager.js +8 -0
- package/dist/services/globalSessionOrchestrator.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.js +3 -0
- package/dist/services/projectManager.d.ts +7 -1
- package/dist/services/projectManager.js +26 -10
- package/dist/services/worktreeService.d.ts +4 -26
- package/dist/services/worktreeService.js +15 -32
- package/dist/services/worktreeService.merge.test.js +179 -0
- package/dist/services/worktreeService.test.js +149 -3
- package/dist/types/index.d.ts +9 -1
- package/dist/utils/worktreeUtils.d.ts +1 -2
- package/package.json +6 -6
- package/dist/components/ProjectList.d.ts +0 -10
- package/dist/components/ProjectList.js +0 -233
- package/dist/components/ProjectList.recent-projects.test.js +0 -193
- package/dist/components/ProjectList.test.js +0 -620
- /package/dist/components/{ProjectList.recent-projects.test.d.ts → Dashboard.test.d.ts} +0 -0
- /package/dist/{components/ProjectList.test.d.ts → services/worktreeService.merge.test.d.ts} +0 -0
package/dist/components/App.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
|
|
|
3
3
|
import { useApp, Box, Text } from 'ink';
|
|
4
4
|
import { Effect } from 'effect';
|
|
5
5
|
import Menu from './Menu.js';
|
|
6
|
-
import
|
|
6
|
+
import Dashboard from './Dashboard.js';
|
|
7
7
|
import Session from './Session.js';
|
|
8
8
|
import NewWorktree from './NewWorktree.js';
|
|
9
9
|
import DeleteWorktree from './DeleteWorktree.js';
|
|
@@ -363,6 +363,15 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
363
363
|
projectManager.addRecentProject(project);
|
|
364
364
|
navigateWithClear('menu');
|
|
365
365
|
};
|
|
366
|
+
const handleSelectSessionFromDashboard = (session, project) => {
|
|
367
|
+
// Set the correct session manager for this project
|
|
368
|
+
const projectSessionManager = globalSessionOrchestrator.getManagerForProject(project.path);
|
|
369
|
+
setSessionManager(projectSessionManager);
|
|
370
|
+
setWorktreeService(new WorktreeService(project.path));
|
|
371
|
+
// Don't set selectedProject so session exit returns to Dashboard
|
|
372
|
+
setActiveSession(session);
|
|
373
|
+
navigateWithClear('session');
|
|
374
|
+
};
|
|
366
375
|
const handleBackToProjectList = () => {
|
|
367
376
|
// Sessions persist in their project-specific managers
|
|
368
377
|
setSelectedProject(null);
|
|
@@ -378,7 +387,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
378
387
|
if (!projectsDir) {
|
|
379
388
|
return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: ["Error: ", MULTI_PROJECT_ERRORS.NO_PROJECTS_DIR] }) }));
|
|
380
389
|
}
|
|
381
|
-
return (_jsx(
|
|
390
|
+
return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, error: error, onDismissError: () => setError(null), version: version }));
|
|
382
391
|
}
|
|
383
392
|
if (view === 'menu') {
|
|
384
393
|
return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: handleSelectWorktree, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
|
|
@@ -54,6 +54,8 @@ vi.mock('../services/globalSessionOrchestrator.js', () => ({
|
|
|
54
54
|
globalSessionOrchestrator: {
|
|
55
55
|
getManagerForProject: getManagerForProjectMock,
|
|
56
56
|
destroyAllSessions: vi.fn(),
|
|
57
|
+
getProjectPaths: vi.fn(() => []),
|
|
58
|
+
getProjectSessions: vi.fn(() => []),
|
|
57
59
|
},
|
|
58
60
|
}));
|
|
59
61
|
vi.mock('../services/projectManager.js', () => ({
|
|
@@ -71,7 +73,7 @@ vi.mock('../services/worktreeService.js', () => ({
|
|
|
71
73
|
}),
|
|
72
74
|
}));
|
|
73
75
|
vi.mock('./Menu.js', createInkMock('Menu View', props => (menuProps = props)));
|
|
74
|
-
vi.mock('./
|
|
76
|
+
vi.mock('./Dashboard.js', createInkMock('Dashboard View', () => { }));
|
|
75
77
|
vi.mock('./NewWorktree.js', createInkMock('New Worktree View', props => {
|
|
76
78
|
newWorktreeProps = props;
|
|
77
79
|
}));
|
|
@@ -139,7 +141,7 @@ describe('App component view state', () => {
|
|
|
139
141
|
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = '/tmp/projects';
|
|
140
142
|
const { lastFrame, unmount } = render(_jsx(App, { multiProject: true, version: "test" }));
|
|
141
143
|
await flush();
|
|
142
|
-
expect(lastFrame()).toContain('
|
|
144
|
+
expect(lastFrame()).toContain('Dashboard View');
|
|
143
145
|
unmount();
|
|
144
146
|
if (original !== undefined) {
|
|
145
147
|
process.env[ENV_VARS.MULTI_PROJECT_ROOT] = original;
|
|
@@ -7,6 +7,7 @@ import ConfigureStatusHooks from './ConfigureStatusHooks.js';
|
|
|
7
7
|
import ConfigureWorktreeHooks from './ConfigureWorktreeHooks.js';
|
|
8
8
|
import ConfigureWorktree from './ConfigureWorktree.js';
|
|
9
9
|
import ConfigureCommand from './ConfigureCommand.js';
|
|
10
|
+
import ConfigureMerge from './ConfigureMerge.js';
|
|
10
11
|
import ConfigureOther from './ConfigureOther.js';
|
|
11
12
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
12
13
|
import { ConfigEditorProvider } from '../contexts/ConfigEditorContext.js';
|
|
@@ -34,6 +35,10 @@ const ConfigurationContent = ({ scope, onComplete }) => {
|
|
|
34
35
|
label: 'C 🚀 Configure Command Presets',
|
|
35
36
|
value: 'presets',
|
|
36
37
|
},
|
|
38
|
+
{
|
|
39
|
+
label: 'M 🔀 Configure Merge/Rebase',
|
|
40
|
+
value: 'mergeConfig',
|
|
41
|
+
},
|
|
37
42
|
{
|
|
38
43
|
label: 'O 🧪 Other & Experimental',
|
|
39
44
|
value: 'other',
|
|
@@ -62,6 +67,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
|
|
|
62
67
|
else if (item.value === 'presets') {
|
|
63
68
|
setView('presets');
|
|
64
69
|
}
|
|
70
|
+
else if (item.value === 'mergeConfig') {
|
|
71
|
+
setView('mergeConfig');
|
|
72
|
+
}
|
|
65
73
|
else if (item.value === 'other') {
|
|
66
74
|
setView('other');
|
|
67
75
|
}
|
|
@@ -90,6 +98,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
|
|
|
90
98
|
case 'c':
|
|
91
99
|
setView('presets');
|
|
92
100
|
break;
|
|
101
|
+
case 'm':
|
|
102
|
+
setView('mergeConfig');
|
|
103
|
+
break;
|
|
93
104
|
case 'o':
|
|
94
105
|
setView('other');
|
|
95
106
|
break;
|
|
@@ -117,6 +128,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
|
|
|
117
128
|
if (view === 'presets') {
|
|
118
129
|
return _jsx(ConfigureCommand, { onComplete: handleSubMenuComplete });
|
|
119
130
|
}
|
|
131
|
+
if (view === 'mergeConfig') {
|
|
132
|
+
return _jsx(ConfigureMerge, { onComplete: handleSubMenuComplete });
|
|
133
|
+
}
|
|
120
134
|
if (view === 'other') {
|
|
121
135
|
return _jsx(ConfigureOther, { onComplete: handleSubMenuComplete });
|
|
122
136
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import SelectInput from 'ink-select-input';
|
|
5
|
+
import TextInputWrapper from './TextInputWrapper.js';
|
|
6
|
+
import { useConfigEditor } from '../contexts/ConfigEditorContext.js';
|
|
7
|
+
import { shortcutManager } from '../services/shortcutManager.js';
|
|
8
|
+
const DEFAULT_MERGE_ARGS = ['--no-ff'];
|
|
9
|
+
const DEFAULT_REBASE_ARGS = [];
|
|
10
|
+
const ConfigureMerge = ({ onComplete }) => {
|
|
11
|
+
const configEditor = useConfigEditor();
|
|
12
|
+
const scope = configEditor.getScope();
|
|
13
|
+
const currentConfig = configEditor.getMergeConfig() || {};
|
|
14
|
+
const [mergeConfig, setMergeConfig] = useState(currentConfig);
|
|
15
|
+
const [editField, setEditField] = useState(null);
|
|
16
|
+
const [inputValue, setInputValue] = useState('');
|
|
17
|
+
const isInheriting = scope === 'project' && !configEditor.hasProjectOverride('mergeConfig');
|
|
18
|
+
const getMergeArgs = () => mergeConfig.mergeArgs ?? DEFAULT_MERGE_ARGS;
|
|
19
|
+
const getRebaseArgs = () => mergeConfig.rebaseArgs ?? DEFAULT_REBASE_ARGS;
|
|
20
|
+
const formatArgs = (args) => args.length > 0 ? args.join(' ') : '(none)';
|
|
21
|
+
const menuItems = [
|
|
22
|
+
{
|
|
23
|
+
label: `Merge Arguments: ${formatArgs(getMergeArgs())}`,
|
|
24
|
+
value: 'mergeArgs',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: `Rebase Arguments: ${formatArgs(getRebaseArgs())}`,
|
|
28
|
+
value: 'rebaseArgs',
|
|
29
|
+
},
|
|
30
|
+
{ label: '-----', value: 'separator' },
|
|
31
|
+
{ label: '<- Back', value: 'back' },
|
|
32
|
+
];
|
|
33
|
+
const handleSelect = (item) => {
|
|
34
|
+
if (item.value === 'separator')
|
|
35
|
+
return;
|
|
36
|
+
if (item.value === 'back') {
|
|
37
|
+
onComplete();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const field = item.value;
|
|
41
|
+
setEditField(field);
|
|
42
|
+
switch (field) {
|
|
43
|
+
case 'mergeArgs':
|
|
44
|
+
setInputValue(getMergeArgs().join(' '));
|
|
45
|
+
break;
|
|
46
|
+
case 'rebaseArgs':
|
|
47
|
+
setInputValue(getRebaseArgs().join(' '));
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const handleFieldUpdate = (value) => {
|
|
52
|
+
const args = value.trim() ? value.trim().split(/\s+/) : [];
|
|
53
|
+
const updated = { ...mergeConfig, [editField]: args };
|
|
54
|
+
setMergeConfig(updated);
|
|
55
|
+
configEditor.setMergeConfig(updated);
|
|
56
|
+
setEditField(null);
|
|
57
|
+
setInputValue('');
|
|
58
|
+
};
|
|
59
|
+
useInput((input, key) => {
|
|
60
|
+
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
61
|
+
if (editField) {
|
|
62
|
+
setEditField(null);
|
|
63
|
+
setInputValue('');
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
onComplete();
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
if (editField) {
|
|
72
|
+
const titles = {
|
|
73
|
+
mergeArgs: 'Enter merge arguments (space-separated, default: --no-ff):',
|
|
74
|
+
rebaseArgs: 'Enter rebase arguments (space-separated, default: none):',
|
|
75
|
+
};
|
|
76
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Edit Merge Config" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: titles[editField] }) }), _jsx(Box, { children: _jsx(TextInputWrapper, { value: inputValue, onChange: setInputValue, onSubmit: handleFieldUpdate, placeholder: "e.g., --no-ff or leave empty" }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press Enter to save, ", shortcutManager.getShortcutDisplay('cancel'), ' ', "to cancel"] }) })] }));
|
|
77
|
+
}
|
|
78
|
+
const scopeLabel = scope === 'project' ? 'Project' : 'Global';
|
|
79
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure Merge/Rebase (", scopeLabel, ")"] }) }), isInheriting && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { backgroundColor: "cyan", color: "black", children: [' ', "Inheriting from global configuration", ' '] }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Configure arguments for merge and rebase operations" }) }), _jsx(SelectInput, { items: menuItems, onSelect: handleSelect }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press Enter to edit, ", shortcutManager.getShortcutDisplay('cancel'), " to go back"] }) })] }));
|
|
80
|
+
};
|
|
81
|
+
export default ConfigureMerge;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { GitProject, Session as ISession } from '../types/index.js';
|
|
3
|
+
interface DashboardProps {
|
|
4
|
+
projectsDir: string;
|
|
5
|
+
onSelectSession: (session: ISession, project: GitProject) => void;
|
|
6
|
+
onSelectProject: (project: GitProject) => void;
|
|
7
|
+
error: string | null;
|
|
8
|
+
onDismissError: () => void;
|
|
9
|
+
version: string;
|
|
10
|
+
}
|
|
11
|
+
declare const Dashboard: React.FC<DashboardProps>;
|
|
12
|
+
export default Dashboard;
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
4
|
+
import { Effect } from 'effect';
|
|
5
|
+
import SelectInput from 'ink-select-input';
|
|
6
|
+
import stripAnsi from 'strip-ansi';
|
|
7
|
+
import { projectManager } from '../services/projectManager.js';
|
|
8
|
+
import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
|
|
9
|
+
import { SessionManager } from '../services/sessionManager.js';
|
|
10
|
+
import { WorktreeService } from '../services/worktreeService.js';
|
|
11
|
+
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../constants/statusIcons.js';
|
|
12
|
+
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
13
|
+
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
14
|
+
import { truncateString, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
|
|
15
|
+
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
|
|
16
|
+
import TextInputWrapper from './TextInputWrapper.js';
|
|
17
|
+
const MAX_BRANCH_NAME_LENGTH = 70;
|
|
18
|
+
const createSeparatorWithText = (text, totalWidth = 35) => {
|
|
19
|
+
const textWithSpaces = ` ${text} `;
|
|
20
|
+
const textLength = textWithSpaces.length;
|
|
21
|
+
const remainingWidth = totalWidth - textLength;
|
|
22
|
+
const leftDashes = Math.floor(remainingWidth / 2);
|
|
23
|
+
const rightDashes = Math.ceil(remainingWidth / 2);
|
|
24
|
+
return '─'.repeat(leftDashes) + textWithSpaces + '─'.repeat(rightDashes);
|
|
25
|
+
};
|
|
26
|
+
const formatErrorMessage = (error) => {
|
|
27
|
+
switch (error._tag) {
|
|
28
|
+
case 'ProcessError':
|
|
29
|
+
return `Process error: ${error.message}`;
|
|
30
|
+
case 'ConfigError':
|
|
31
|
+
return `Configuration error (${error.reason}): ${error.details}`;
|
|
32
|
+
case 'GitError':
|
|
33
|
+
return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
|
|
34
|
+
case 'FileSystemError':
|
|
35
|
+
return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
|
|
36
|
+
case 'ValidationError':
|
|
37
|
+
return `Validation failed for ${error.field}: ${error.constraint}`;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** Sort sessions: busy first, then waiting/pending, then idle. Within same state, by lastActivity desc. */
|
|
41
|
+
function sessionSortKey(session) {
|
|
42
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
43
|
+
switch (stateData.state) {
|
|
44
|
+
case 'busy':
|
|
45
|
+
return 0;
|
|
46
|
+
case 'waiting_input':
|
|
47
|
+
case 'pending_auto_approval':
|
|
48
|
+
return 1;
|
|
49
|
+
case 'idle':
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Resolve the display name for a project, using relativePath if names collide. */
|
|
54
|
+
function resolveProjectDisplayNames(projects) {
|
|
55
|
+
const nameCount = new Map();
|
|
56
|
+
for (const p of projects) {
|
|
57
|
+
nameCount.set(p.name, (nameCount.get(p.name) || 0) + 1);
|
|
58
|
+
}
|
|
59
|
+
const displayNames = new Map();
|
|
60
|
+
for (const p of projects) {
|
|
61
|
+
displayNames.set(p.path, nameCount.get(p.name) > 1 ? p.relativePath : p.name);
|
|
62
|
+
}
|
|
63
|
+
return displayNames;
|
|
64
|
+
}
|
|
65
|
+
const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDismissError, version, }) => {
|
|
66
|
+
const [projects, setProjects] = useState([]);
|
|
67
|
+
const [recentProjects, setRecentProjects] = useState([]);
|
|
68
|
+
const [loading, setLoading] = useState(true);
|
|
69
|
+
const [loadError, setLoadError] = useState(null);
|
|
70
|
+
const [items, setItems] = useState([]);
|
|
71
|
+
// Session-related state
|
|
72
|
+
const [sessionEntries, setSessionEntries] = useState([]);
|
|
73
|
+
const [baseSessionWorktrees, setBaseSessionWorktrees] = useState([]);
|
|
74
|
+
const [sessionRefreshKey, setSessionRefreshKey] = useState(0);
|
|
75
|
+
const { stdout } = useStdout();
|
|
76
|
+
const fixedRows = 6;
|
|
77
|
+
const displayError = error || loadError;
|
|
78
|
+
const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
|
|
79
|
+
isDisabled: !!displayError,
|
|
80
|
+
skipInTest: false,
|
|
81
|
+
});
|
|
82
|
+
const limit = Math.max(5, stdout.rows - fixedRows - (isSearchMode ? 1 : 0) - (displayError ? 3 : 0));
|
|
83
|
+
// Git status polling for session worktrees
|
|
84
|
+
const enrichedWorktrees = useGitStatus(baseSessionWorktrees, baseSessionWorktrees.length > 0 ? 'main' : null);
|
|
85
|
+
// Discover projects on mount
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
let cancelled = false;
|
|
88
|
+
const loadProjects = async () => {
|
|
89
|
+
setLoading(true);
|
|
90
|
+
setLoadError(null);
|
|
91
|
+
const result = await Effect.runPromise(Effect.either(projectManager.instance.discoverProjectsEffect(projectsDir)));
|
|
92
|
+
if (cancelled)
|
|
93
|
+
return;
|
|
94
|
+
if (result._tag === 'Left') {
|
|
95
|
+
setLoadError(formatErrorMessage(result.left));
|
|
96
|
+
setLoading(false);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setProjects(result.right);
|
|
100
|
+
setRecentProjects(projectManager.getRecentProjects(0));
|
|
101
|
+
setLoading(false);
|
|
102
|
+
};
|
|
103
|
+
loadProjects();
|
|
104
|
+
return () => {
|
|
105
|
+
cancelled = true;
|
|
106
|
+
};
|
|
107
|
+
}, [projectsDir]);
|
|
108
|
+
// Load session worktree data on mount + sessionRefreshKey
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
let cancelled = false;
|
|
111
|
+
const loadSessionData = async () => {
|
|
112
|
+
const projectPaths = globalSessionOrchestrator.getProjectPaths();
|
|
113
|
+
const entries = [];
|
|
114
|
+
const worktrees = [];
|
|
115
|
+
// Build a project lookup map
|
|
116
|
+
const projectByPath = new Map();
|
|
117
|
+
for (const p of projects) {
|
|
118
|
+
projectByPath.set(p.path, p);
|
|
119
|
+
}
|
|
120
|
+
const displayNames = resolveProjectDisplayNames(projects);
|
|
121
|
+
for (const projectPath of projectPaths) {
|
|
122
|
+
const sessions = globalSessionOrchestrator.getProjectSessions(projectPath);
|
|
123
|
+
if (sessions.length === 0)
|
|
124
|
+
continue;
|
|
125
|
+
// Load worktrees for this project to resolve branch names
|
|
126
|
+
const ws = new WorktreeService(projectPath);
|
|
127
|
+
const result = await Effect.runPromise(Effect.either(ws.getWorktreesEffect()));
|
|
128
|
+
if (cancelled)
|
|
129
|
+
return;
|
|
130
|
+
if (result._tag === 'Left')
|
|
131
|
+
continue;
|
|
132
|
+
const projectWorktrees = result.right;
|
|
133
|
+
const project = projectByPath.get(projectPath);
|
|
134
|
+
const projectName = displayNames.get(projectPath) ||
|
|
135
|
+
project?.name ||
|
|
136
|
+
projectPath.split('/').pop() ||
|
|
137
|
+
projectPath;
|
|
138
|
+
// Mark worktrees that have sessions
|
|
139
|
+
for (const wt of projectWorktrees) {
|
|
140
|
+
wt.hasSession = sessions.some(s => s.worktreePath === wt.path);
|
|
141
|
+
}
|
|
142
|
+
for (const session of sessions) {
|
|
143
|
+
const wt = projectWorktrees.find(w => w.path === session.worktreePath);
|
|
144
|
+
if (!wt)
|
|
145
|
+
continue;
|
|
146
|
+
entries.push({
|
|
147
|
+
session,
|
|
148
|
+
projectPath,
|
|
149
|
+
projectName,
|
|
150
|
+
worktree: wt,
|
|
151
|
+
});
|
|
152
|
+
worktrees.push(wt);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (cancelled)
|
|
156
|
+
return;
|
|
157
|
+
// Sort sessions: busy > waiting > idle, then by lastActivity desc
|
|
158
|
+
entries.sort((a, b) => {
|
|
159
|
+
const keyA = sessionSortKey(a.session);
|
|
160
|
+
const keyB = sessionSortKey(b.session);
|
|
161
|
+
if (keyA !== keyB)
|
|
162
|
+
return keyA - keyB;
|
|
163
|
+
return (b.session.lastActivity.getTime() - a.session.lastActivity.getTime());
|
|
164
|
+
});
|
|
165
|
+
setSessionEntries(entries);
|
|
166
|
+
setBaseSessionWorktrees(prev => {
|
|
167
|
+
// Avoid restarting git status polling if the set of paths hasn't changed
|
|
168
|
+
const prevPaths = prev
|
|
169
|
+
.map(w => w.path)
|
|
170
|
+
.sort()
|
|
171
|
+
.join('\0');
|
|
172
|
+
const newPaths = worktrees
|
|
173
|
+
.map(w => w.path)
|
|
174
|
+
.sort()
|
|
175
|
+
.join('\0');
|
|
176
|
+
return prevPaths === newPaths ? prev : worktrees;
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
loadSessionData();
|
|
180
|
+
return () => {
|
|
181
|
+
cancelled = true;
|
|
182
|
+
};
|
|
183
|
+
}, [sessionRefreshKey, projects]);
|
|
184
|
+
// Subscribe to session events from all managers
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const refresh = () => setSessionRefreshKey(k => k + 1);
|
|
187
|
+
const projectPaths = globalSessionOrchestrator.getProjectPaths();
|
|
188
|
+
const managers = projectPaths.map(p => globalSessionOrchestrator.getManagerForProject(p));
|
|
189
|
+
for (const mgr of managers) {
|
|
190
|
+
mgr.on('sessionCreated', refresh);
|
|
191
|
+
mgr.on('sessionDestroyed', refresh);
|
|
192
|
+
mgr.on('sessionStateChanged', refresh);
|
|
193
|
+
}
|
|
194
|
+
return () => {
|
|
195
|
+
for (const mgr of managers) {
|
|
196
|
+
mgr.off('sessionCreated', refresh);
|
|
197
|
+
mgr.off('sessionDestroyed', refresh);
|
|
198
|
+
mgr.off('sessionStateChanged', refresh);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}, [sessionRefreshKey]);
|
|
202
|
+
// Build display items
|
|
203
|
+
const projectDisplayNames = useMemo(() => resolveProjectDisplayNames(projects), [projects]);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const menuItems = [];
|
|
206
|
+
let currentIndex = 0;
|
|
207
|
+
// --- Active Sessions section ---
|
|
208
|
+
if (sessionEntries.length > 0) {
|
|
209
|
+
// Build WorktreeItems for column alignment
|
|
210
|
+
const sessionWorkItems = sessionEntries.map(entry => {
|
|
211
|
+
// Use enriched worktree if available (has git status)
|
|
212
|
+
const wt = enrichedWorktrees.find(w => w.path === entry.worktree.path) ||
|
|
213
|
+
entry.worktree;
|
|
214
|
+
const stateData = entry.session.stateMutex.getSnapshot();
|
|
215
|
+
const status = ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
|
|
216
|
+
const fullBranchName = wt.branch
|
|
217
|
+
? wt.branch.replace('refs/heads/', '')
|
|
218
|
+
: wt.path.split('/').pop() || 'detached';
|
|
219
|
+
const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
|
|
220
|
+
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
221
|
+
const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${status}`;
|
|
222
|
+
let fileChanges = '';
|
|
223
|
+
let aheadBehind = '';
|
|
224
|
+
let parentBranch = '';
|
|
225
|
+
let itemError = '';
|
|
226
|
+
if (wt.gitStatus) {
|
|
227
|
+
fileChanges = formatGitFileChanges(wt.gitStatus);
|
|
228
|
+
aheadBehind = formatGitAheadBehind(wt.gitStatus);
|
|
229
|
+
parentBranch = formatParentBranch(wt.gitStatus.parentBranch, fullBranchName);
|
|
230
|
+
}
|
|
231
|
+
else if (wt.gitStatusError) {
|
|
232
|
+
itemError = `\x1b[31m[git error]\x1b[0m`;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
fileChanges = '\x1b[90m[fetching...]\x1b[0m';
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
worktree: wt,
|
|
239
|
+
session: entry.session,
|
|
240
|
+
baseLabel,
|
|
241
|
+
fileChanges,
|
|
242
|
+
aheadBehind,
|
|
243
|
+
parentBranch,
|
|
244
|
+
error: itemError,
|
|
245
|
+
lengths: {
|
|
246
|
+
base: stripAnsi(baseLabel).length,
|
|
247
|
+
fileChanges: stripAnsi(fileChanges).length,
|
|
248
|
+
aheadBehind: stripAnsi(aheadBehind).length,
|
|
249
|
+
parentBranch: stripAnsi(parentBranch).length,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
const columns = calculateColumnPositions(sessionWorkItems);
|
|
254
|
+
if (!isSearchMode) {
|
|
255
|
+
menuItems.push({
|
|
256
|
+
type: 'common',
|
|
257
|
+
label: createSeparatorWithText('Active Sessions'),
|
|
258
|
+
value: 'separator-sessions',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Filter by search query
|
|
262
|
+
const filteredEntries = searchQuery
|
|
263
|
+
? sessionEntries.filter((_entry, i) => {
|
|
264
|
+
const item = sessionWorkItems[i];
|
|
265
|
+
return stripAnsi(item.baseLabel)
|
|
266
|
+
.toLowerCase()
|
|
267
|
+
.includes(searchQuery.toLowerCase());
|
|
268
|
+
})
|
|
269
|
+
: sessionEntries;
|
|
270
|
+
filteredEntries.forEach(entry => {
|
|
271
|
+
const itemIndex = sessionEntries.indexOf(entry);
|
|
272
|
+
const workItem = sessionWorkItems[itemIndex];
|
|
273
|
+
const label = assembleWorktreeLabel(workItem, columns);
|
|
274
|
+
const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
|
|
275
|
+
const project = {
|
|
276
|
+
path: entry.projectPath,
|
|
277
|
+
name: entry.projectName,
|
|
278
|
+
relativePath: projects.find(p => p.path === entry.projectPath)?.relativePath ||
|
|
279
|
+
entry.projectPath,
|
|
280
|
+
isValid: true,
|
|
281
|
+
};
|
|
282
|
+
menuItems.push({
|
|
283
|
+
type: 'session',
|
|
284
|
+
label: numberPrefix + label,
|
|
285
|
+
value: `session-${entry.session.id}`,
|
|
286
|
+
session: entry.session,
|
|
287
|
+
project,
|
|
288
|
+
});
|
|
289
|
+
currentIndex++;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// --- Projects section ---
|
|
293
|
+
const filteredRecentProjects = searchQuery
|
|
294
|
+
? recentProjects.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
295
|
+
: recentProjects;
|
|
296
|
+
const filteredProjects = searchQuery
|
|
297
|
+
? projects.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
298
|
+
: projects;
|
|
299
|
+
// Deduplicate: recent projects first, then remaining
|
|
300
|
+
const recentPaths = new Set(filteredRecentProjects.map(rp => rp.path));
|
|
301
|
+
const nonRecentProjects = filteredProjects.filter(p => !recentPaths.has(p.path));
|
|
302
|
+
// Build ordered project list: recent first, then alphabetical
|
|
303
|
+
const orderedProjects = [];
|
|
304
|
+
for (const rp of filteredRecentProjects) {
|
|
305
|
+
const full = projects.find(p => p.path === rp.path);
|
|
306
|
+
if (full)
|
|
307
|
+
orderedProjects.push(full);
|
|
308
|
+
}
|
|
309
|
+
orderedProjects.push(...nonRecentProjects);
|
|
310
|
+
if (orderedProjects.length > 0 && !isSearchMode) {
|
|
311
|
+
menuItems.push({
|
|
312
|
+
type: 'common',
|
|
313
|
+
label: createSeparatorWithText('Projects'),
|
|
314
|
+
value: 'separator-projects',
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
orderedProjects.forEach(project => {
|
|
318
|
+
const projectSessions = globalSessionOrchestrator.getProjectSessions(project.path);
|
|
319
|
+
const counts = SessionManager.getSessionCounts(projectSessions);
|
|
320
|
+
const countsFormatted = SessionManager.formatSessionCounts(counts);
|
|
321
|
+
const displayName = projectDisplayNames.get(project.path) || project.name;
|
|
322
|
+
const numberPrefix = !isSearchMode && currentIndex < 10 ? `${currentIndex} ❯ ` : '❯ ';
|
|
323
|
+
menuItems.push({
|
|
324
|
+
type: 'project',
|
|
325
|
+
label: numberPrefix + displayName + countsFormatted,
|
|
326
|
+
value: project.path,
|
|
327
|
+
project,
|
|
328
|
+
});
|
|
329
|
+
currentIndex++;
|
|
330
|
+
});
|
|
331
|
+
// --- Other section ---
|
|
332
|
+
if (!isSearchMode) {
|
|
333
|
+
menuItems.push({
|
|
334
|
+
type: 'common',
|
|
335
|
+
label: createSeparatorWithText('Other'),
|
|
336
|
+
value: 'separator-other',
|
|
337
|
+
});
|
|
338
|
+
menuItems.push({
|
|
339
|
+
type: 'common',
|
|
340
|
+
label: `R 🔄 Refresh`,
|
|
341
|
+
value: 'refresh',
|
|
342
|
+
});
|
|
343
|
+
menuItems.push({
|
|
344
|
+
type: 'common',
|
|
345
|
+
label: `Q ${MENU_ICONS.EXIT} Exit`,
|
|
346
|
+
value: 'exit',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
setItems(menuItems);
|
|
350
|
+
}, [
|
|
351
|
+
sessionEntries,
|
|
352
|
+
enrichedWorktrees,
|
|
353
|
+
projects,
|
|
354
|
+
recentProjects,
|
|
355
|
+
projectDisplayNames,
|
|
356
|
+
searchQuery,
|
|
357
|
+
isSearchMode,
|
|
358
|
+
]);
|
|
359
|
+
// Refresh handler
|
|
360
|
+
const refreshAll = () => {
|
|
361
|
+
setLoading(true);
|
|
362
|
+
setLoadError(null);
|
|
363
|
+
Effect.runPromise(Effect.either(projectManager.instance.discoverProjectsEffect(projectsDir))).then(result => {
|
|
364
|
+
if (result._tag === 'Left') {
|
|
365
|
+
setLoadError(formatErrorMessage(result.left));
|
|
366
|
+
setLoading(false);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
setProjects(result.right);
|
|
370
|
+
setRecentProjects(projectManager.getRecentProjects(0));
|
|
371
|
+
setLoading(false);
|
|
372
|
+
setSessionRefreshKey(k => k + 1);
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
// Handle hotkeys
|
|
376
|
+
useInput((input, _key) => {
|
|
377
|
+
if (!process.stdin.setRawMode)
|
|
378
|
+
return;
|
|
379
|
+
if (displayError && onDismissError) {
|
|
380
|
+
onDismissError();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (isSearchMode)
|
|
384
|
+
return;
|
|
385
|
+
const keyPressed = input.toLowerCase();
|
|
386
|
+
// Number keys 0-9 for quick selection
|
|
387
|
+
if (/^[0-9]$/.test(keyPressed)) {
|
|
388
|
+
const index = parseInt(keyPressed);
|
|
389
|
+
const selectableItems = items.filter(item => item.type === 'session' || item.type === 'project');
|
|
390
|
+
if (index < selectableItems.length && selectableItems[index]) {
|
|
391
|
+
const selected = selectableItems[index];
|
|
392
|
+
if (selected.type === 'session') {
|
|
393
|
+
onSelectSession(selected.session, selected.project);
|
|
394
|
+
}
|
|
395
|
+
else if (selected.type === 'project') {
|
|
396
|
+
onSelectProject(selected.project);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
switch (keyPressed) {
|
|
402
|
+
case 'r':
|
|
403
|
+
refreshAll();
|
|
404
|
+
break;
|
|
405
|
+
case 'q':
|
|
406
|
+
case 'x':
|
|
407
|
+
onSelectProject({
|
|
408
|
+
path: 'EXIT_APPLICATION',
|
|
409
|
+
name: '',
|
|
410
|
+
relativePath: '',
|
|
411
|
+
isValid: false,
|
|
412
|
+
});
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
const handleSelect = (item) => {
|
|
417
|
+
if (item.value.startsWith('separator'))
|
|
418
|
+
return;
|
|
419
|
+
if (item.type === 'session') {
|
|
420
|
+
onSelectSession(item.session, item.project);
|
|
421
|
+
}
|
|
422
|
+
else if (item.type === 'project') {
|
|
423
|
+
onSelectProject(item.project);
|
|
424
|
+
}
|
|
425
|
+
else if (item.value === 'refresh') {
|
|
426
|
+
refreshAll();
|
|
427
|
+
}
|
|
428
|
+
else if (item.value === 'exit') {
|
|
429
|
+
onSelectProject({
|
|
430
|
+
path: 'EXIT_APPLICATION',
|
|
431
|
+
name: '',
|
|
432
|
+
relativePath: '',
|
|
433
|
+
isValid: false,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { bold: true, color: "green", children: ["CCManager - Dashboard v", version] }) }), isSearchMode && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: "Search: " }), _jsx(TextInputWrapper, { value: searchQuery, onChange: setSearchQuery, focus: true, placeholder: "Type to filter..." })] })), loading ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "Discovering projects..." }) })) : projects.length === 0 && !displayError ? (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ["No git repositories found in ", projectsDir] }) })) : isSearchMode && items.length === 0 ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "No matches found" }) })) : isSearchMode ? (_jsx(Box, { flexDirection: "column", children: items.slice(0, limit).map((item, index) => (_jsxs(Text, { color: index === selectedIndex ? 'green' : undefined, children: [index === selectedIndex ? '❯ ' : ' ', item.label] }, item.value))) })) : (_jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), isFocused: !displayError, limit: limit, initialIndex: selectedIndex })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE] }), _jsx(Text, { dimColor: true, children: isSearchMode
|
|
438
|
+
? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
|
|
439
|
+
: searchQuery
|
|
440
|
+
? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
|
|
441
|
+
: 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search R-Refresh Q-Quit' })] })] }));
|
|
442
|
+
};
|
|
443
|
+
export default Dashboard;
|