@allpepper/task-orchestrator-tui 1.0.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 +78 -0
- package/package.json +54 -0
- package/src/tui/app.tsx +308 -0
- package/src/tui/components/column-filter-bar.tsx +52 -0
- package/src/tui/components/confirm-dialog.tsx +45 -0
- package/src/tui/components/dependency-list.tsx +115 -0
- package/src/tui/components/empty-state.tsx +28 -0
- package/src/tui/components/entity-table.tsx +120 -0
- package/src/tui/components/error-message.tsx +41 -0
- package/src/tui/components/feature-kanban-card.tsx +216 -0
- package/src/tui/components/footer.tsx +34 -0
- package/src/tui/components/form-dialog.tsx +338 -0
- package/src/tui/components/header.tsx +54 -0
- package/src/tui/components/index.ts +16 -0
- package/src/tui/components/kanban-board.tsx +335 -0
- package/src/tui/components/kanban-card.tsx +70 -0
- package/src/tui/components/kanban-column.tsx +173 -0
- package/src/tui/components/priority-badge.tsx +16 -0
- package/src/tui/components/section-list.tsx +96 -0
- package/src/tui/components/status-actions.tsx +87 -0
- package/src/tui/components/status-badge.tsx +22 -0
- package/src/tui/components/tree-view.tsx +295 -0
- package/src/tui/components/view-mode-chips.tsx +23 -0
- package/src/tui/index.tsx +33 -0
- package/src/tui/screens/dashboard.tsx +248 -0
- package/src/tui/screens/feature-detail.tsx +312 -0
- package/src/tui/screens/index.ts +6 -0
- package/src/tui/screens/kanban-view.tsx +251 -0
- package/src/tui/screens/project-detail.tsx +305 -0
- package/src/tui/screens/project-view.tsx +498 -0
- package/src/tui/screens/search.tsx +257 -0
- package/src/tui/screens/task-detail.tsx +294 -0
- package/src/ui/adapters/direct.ts +429 -0
- package/src/ui/adapters/index.ts +14 -0
- package/src/ui/adapters/types.ts +269 -0
- package/src/ui/context/adapter-context.tsx +31 -0
- package/src/ui/context/theme-context.tsx +43 -0
- package/src/ui/hooks/index.ts +20 -0
- package/src/ui/hooks/use-data.ts +919 -0
- package/src/ui/hooks/use-debounce.ts +37 -0
- package/src/ui/hooks/use-feature-kanban.ts +151 -0
- package/src/ui/hooks/use-kanban.ts +96 -0
- package/src/ui/hooks/use-navigation.tsx +94 -0
- package/src/ui/index.ts +73 -0
- package/src/ui/lib/colors.ts +79 -0
- package/src/ui/lib/format.ts +114 -0
- package/src/ui/lib/types.ts +157 -0
- package/src/ui/themes/dark.ts +63 -0
- package/src/ui/themes/light.ts +63 -0
- package/src/ui/themes/types.ts +71 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
3
|
+
import { useFeatureKanban } from '../../ui/hooks/use-feature-kanban';
|
|
4
|
+
import { useAdapter } from '../../ui/context/adapter-context';
|
|
5
|
+
import { KanbanBoard } from '../components/kanban-board';
|
|
6
|
+
|
|
7
|
+
interface KanbanViewProps {
|
|
8
|
+
projectId: string;
|
|
9
|
+
activeColumnIndex: number;
|
|
10
|
+
onActiveColumnIndexChange: (index: number) => void;
|
|
11
|
+
selectedFeatureIndex: number;
|
|
12
|
+
onSelectedFeatureIndexChange: (index: number) => void;
|
|
13
|
+
expandedFeatureId: string | null;
|
|
14
|
+
onExpandedFeatureIdChange: (id: string | null) => void;
|
|
15
|
+
selectedTaskIndex: number;
|
|
16
|
+
onSelectedTaskIndexChange: (index: number) => void;
|
|
17
|
+
onSelectTask: (taskId: string) => void;
|
|
18
|
+
onBack: () => void;
|
|
19
|
+
// Filter state (lifted to App for persistence)
|
|
20
|
+
activeStatuses: Set<string>;
|
|
21
|
+
onActiveStatusesChange: (statuses: Set<string>) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function KanbanView({
|
|
25
|
+
projectId,
|
|
26
|
+
activeColumnIndex,
|
|
27
|
+
onActiveColumnIndexChange,
|
|
28
|
+
selectedFeatureIndex,
|
|
29
|
+
onSelectedFeatureIndexChange,
|
|
30
|
+
expandedFeatureId,
|
|
31
|
+
onExpandedFeatureIdChange,
|
|
32
|
+
selectedTaskIndex,
|
|
33
|
+
onSelectedTaskIndexChange,
|
|
34
|
+
onSelectTask,
|
|
35
|
+
onBack,
|
|
36
|
+
activeStatuses,
|
|
37
|
+
onActiveStatusesChange,
|
|
38
|
+
}: KanbanViewProps) {
|
|
39
|
+
const { adapter } = useAdapter();
|
|
40
|
+
const { columns, loading, error, refresh, moveFeature } = useFeatureKanban(projectId);
|
|
41
|
+
const [projectName, setProjectName] = useState<string>('');
|
|
42
|
+
const { stdout } = useStdout();
|
|
43
|
+
|
|
44
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
45
|
+
const terminalCols = stdout?.columns ?? 120;
|
|
46
|
+
const availableHeight = Math.max(10, terminalRows - 6);
|
|
47
|
+
|
|
48
|
+
// Filter mode state (local — only active while interacting with chips)
|
|
49
|
+
const [isFilterMode, setIsFilterMode] = useState(false);
|
|
50
|
+
const [filterCursorIndex, setFilterCursorIndex] = useState(0);
|
|
51
|
+
|
|
52
|
+
// Auto-populate activeStatuses from data on first load (only if empty)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (activeStatuses.size === 0 && columns.length > 0) {
|
|
55
|
+
const populated = new Set<string>();
|
|
56
|
+
for (const col of columns) {
|
|
57
|
+
if (col.features.length > 0) {
|
|
58
|
+
populated.add(col.status);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// If nothing has features, show all columns
|
|
62
|
+
if (populated.size === 0) {
|
|
63
|
+
for (const col of columns) {
|
|
64
|
+
populated.add(col.status);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
onActiveStatusesChange(populated);
|
|
68
|
+
}
|
|
69
|
+
}, [columns, activeStatuses.size, onActiveStatusesChange]);
|
|
70
|
+
|
|
71
|
+
// Compute filtered columns
|
|
72
|
+
const filteredColumns = useMemo(() => {
|
|
73
|
+
if (activeStatuses.size === 0) return columns;
|
|
74
|
+
return columns.filter((c) => activeStatuses.has(c.status));
|
|
75
|
+
}, [columns, activeStatuses]);
|
|
76
|
+
|
|
77
|
+
// Fetch project name
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const fetchProject = async () => {
|
|
80
|
+
const result = await adapter.getProject(projectId);
|
|
81
|
+
if (result.success) {
|
|
82
|
+
setProjectName(result.data.name);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
fetchProject();
|
|
86
|
+
}, [adapter, projectId]);
|
|
87
|
+
|
|
88
|
+
// Reset selectedFeatureIndex when activeColumnIndex changes
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (filteredColumns.length > 0 && activeColumnIndex < filteredColumns.length) {
|
|
91
|
+
const activeColumn = filteredColumns[activeColumnIndex];
|
|
92
|
+
if (activeColumn) {
|
|
93
|
+
if (activeColumn.features.length === 0) {
|
|
94
|
+
onSelectedFeatureIndexChange(-1);
|
|
95
|
+
} else if (selectedFeatureIndex >= activeColumn.features.length) {
|
|
96
|
+
onSelectedFeatureIndexChange(0);
|
|
97
|
+
} else if (selectedFeatureIndex < 0 && activeColumn.features.length > 0) {
|
|
98
|
+
onSelectedFeatureIndexChange(0);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}, [activeColumnIndex, filteredColumns, selectedFeatureIndex, onSelectedFeatureIndexChange]);
|
|
103
|
+
|
|
104
|
+
// Collapse expanded feature if it no longer exists in the active column
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (expandedFeatureId && filteredColumns.length > 0 && activeColumnIndex < filteredColumns.length) {
|
|
107
|
+
const activeColumn = filteredColumns[activeColumnIndex];
|
|
108
|
+
if (activeColumn && !activeColumn.features.some((f) => f.id === expandedFeatureId)) {
|
|
109
|
+
onExpandedFeatureIdChange(null);
|
|
110
|
+
onSelectedTaskIndexChange(-1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}, [filteredColumns, activeColumnIndex, expandedFeatureId, onExpandedFeatureIdChange, onSelectedTaskIndexChange]);
|
|
114
|
+
|
|
115
|
+
// Clamp activeColumnIndex when filtered columns change
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (filteredColumns.length > 0 && activeColumnIndex >= filteredColumns.length) {
|
|
118
|
+
onActiveColumnIndexChange(Math.max(0, filteredColumns.length - 1));
|
|
119
|
+
}
|
|
120
|
+
}, [filteredColumns.length, activeColumnIndex, onActiveColumnIndexChange]);
|
|
121
|
+
|
|
122
|
+
// Handle toggle status
|
|
123
|
+
const handleToggleStatus = (status: string) => {
|
|
124
|
+
const next = new Set(activeStatuses);
|
|
125
|
+
if (next.has(status)) {
|
|
126
|
+
// Don't allow removing the last status
|
|
127
|
+
if (next.size > 1) {
|
|
128
|
+
next.delete(status);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
next.add(status);
|
|
132
|
+
}
|
|
133
|
+
onActiveStatusesChange(next);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Handle keyboard
|
|
137
|
+
useInput((input, key) => {
|
|
138
|
+
// Don't handle keys in filter mode — board handles them
|
|
139
|
+
if (isFilterMode) return;
|
|
140
|
+
|
|
141
|
+
if (key.escape) {
|
|
142
|
+
if (expandedFeatureId) {
|
|
143
|
+
// Let KanbanBoard handle Esc in task mode
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
onBack();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (input === 'b' && !expandedFeatureId) {
|
|
150
|
+
onBack();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (input === 'r') {
|
|
154
|
+
refresh();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Handle move feature
|
|
160
|
+
const handleMoveFeature = async (featureId: string, newStatus: string) => {
|
|
161
|
+
await moveFeature(featureId, newStatus);
|
|
162
|
+
// After move, adjust indices if needed
|
|
163
|
+
if (filteredColumns.length > 0 && activeColumnIndex < filteredColumns.length) {
|
|
164
|
+
const activeColumn = filteredColumns[activeColumnIndex];
|
|
165
|
+
if (activeColumn) {
|
|
166
|
+
if (activeColumn.features.length === 0) {
|
|
167
|
+
onSelectedFeatureIndexChange(-1);
|
|
168
|
+
} else if (selectedFeatureIndex >= activeColumn.features.length) {
|
|
169
|
+
onSelectedFeatureIndexChange(Math.max(0, activeColumn.features.length - 1));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (loading) {
|
|
176
|
+
return (
|
|
177
|
+
<Box padding={1}>
|
|
178
|
+
<Text>Loading kanban board...</Text>
|
|
179
|
+
</Box>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (error) {
|
|
184
|
+
return (
|
|
185
|
+
<Box padding={1}>
|
|
186
|
+
<Text color="red">Error: {error}</Text>
|
|
187
|
+
</Box>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Clamp indices
|
|
192
|
+
const clampedActiveColumnIndex = Math.min(activeColumnIndex, Math.max(0, filteredColumns.length - 1));
|
|
193
|
+
const effectiveActiveColumnIndex = filteredColumns.length > 0 ? clampedActiveColumnIndex : 0;
|
|
194
|
+
|
|
195
|
+
const activeColumn = filteredColumns[effectiveActiveColumnIndex];
|
|
196
|
+
const maxFeatureIndex = activeColumn ? Math.max(0, activeColumn.features.length - 1) : 0;
|
|
197
|
+
const clampedFeatureIndex = activeColumn && activeColumn.features.length > 0
|
|
198
|
+
? Math.min(selectedFeatureIndex, maxFeatureIndex)
|
|
199
|
+
: -1;
|
|
200
|
+
|
|
201
|
+
const isTaskMode = expandedFeatureId !== null;
|
|
202
|
+
const footerHint = isFilterMode
|
|
203
|
+
? 'h/l: navigate chips Space: toggle Esc: exit filter'
|
|
204
|
+
: isTaskMode
|
|
205
|
+
? 'j/k: tasks Enter: open task Esc/h: collapse r: refresh'
|
|
206
|
+
: 'h/l: columns j/k: features Enter: expand m: move f: filter r: refresh Esc: back';
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<Box flexDirection="column" padding={1}>
|
|
210
|
+
{/* Header */}
|
|
211
|
+
<Box marginBottom={1}>
|
|
212
|
+
<Text bold>{projectName}</Text>
|
|
213
|
+
<Text> - </Text>
|
|
214
|
+
<Text>Feature Board</Text>
|
|
215
|
+
</Box>
|
|
216
|
+
|
|
217
|
+
{/* Kanban Board */}
|
|
218
|
+
{filteredColumns.length === 0 ? (
|
|
219
|
+
<Text dimColor>No features in this project yet.</Text>
|
|
220
|
+
) : (
|
|
221
|
+
<KanbanBoard
|
|
222
|
+
columns={filteredColumns}
|
|
223
|
+
activeColumnIndex={effectiveActiveColumnIndex}
|
|
224
|
+
selectedFeatureIndex={clampedFeatureIndex}
|
|
225
|
+
expandedFeatureId={expandedFeatureId}
|
|
226
|
+
selectedTaskIndex={selectedTaskIndex}
|
|
227
|
+
onColumnChange={onActiveColumnIndexChange}
|
|
228
|
+
onFeatureChange={onSelectedFeatureIndexChange}
|
|
229
|
+
onExpandFeature={onExpandedFeatureIdChange}
|
|
230
|
+
onTaskChange={onSelectedTaskIndexChange}
|
|
231
|
+
onSelectTask={onSelectTask}
|
|
232
|
+
onMoveFeature={handleMoveFeature}
|
|
233
|
+
isActive={true}
|
|
234
|
+
availableHeight={availableHeight}
|
|
235
|
+
availableWidth={terminalCols}
|
|
236
|
+
activeStatuses={activeStatuses}
|
|
237
|
+
isFilterMode={isFilterMode}
|
|
238
|
+
filterCursorIndex={filterCursorIndex}
|
|
239
|
+
onToggleStatus={handleToggleStatus}
|
|
240
|
+
onFilterCursorChange={setFilterCursorIndex}
|
|
241
|
+
onFilterModeChange={setIsFilterMode}
|
|
242
|
+
/>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{/* Footer hints */}
|
|
246
|
+
<Box marginTop={1}>
|
|
247
|
+
<Text dimColor>{footerHint}</Text>
|
|
248
|
+
</Box>
|
|
249
|
+
</Box>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useAdapter } from '../../ui/context/adapter-context';
|
|
4
|
+
import type { Project, Feature, ProjectStatus, Section, EntityType } from 'task-orchestrator-bun/src/domain/types';
|
|
5
|
+
import { StatusBadge } from '../components/status-badge';
|
|
6
|
+
import { timeAgo } from '../../ui/lib/format';
|
|
7
|
+
import { FormDialog } from '../components/form-dialog';
|
|
8
|
+
import { ErrorMessage } from '../components/error-message';
|
|
9
|
+
import { EmptyState } from '../components/empty-state';
|
|
10
|
+
import { useTheme } from '../../ui/context/theme-context';
|
|
11
|
+
import { SectionList } from '../components/section-list';
|
|
12
|
+
|
|
13
|
+
interface ProjectDetailProps {
|
|
14
|
+
projectId: string;
|
|
15
|
+
onSelectFeature: (featureId: string) => void;
|
|
16
|
+
onBack: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDetailProps) {
|
|
20
|
+
const { adapter } = useAdapter();
|
|
21
|
+
const { theme } = useTheme();
|
|
22
|
+
const [project, setProject] = useState<Project | null>(null);
|
|
23
|
+
const [features, setFeatures] = useState<Feature[]>([]);
|
|
24
|
+
const [sections, setSections] = useState<Section[]>([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [selectedFeatureIndex, setSelectedFeatureIndex] = useState(0);
|
|
28
|
+
const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
|
|
29
|
+
const [mode, setMode] = useState<'idle' | 'edit-project' | 'project-status'>('idle');
|
|
30
|
+
const [localError, setLocalError] = useState<string | null>(null);
|
|
31
|
+
const [transitions, setTransitions] = useState<string[]>([]);
|
|
32
|
+
const [transitionIndex, setTransitionIndex] = useState(0);
|
|
33
|
+
|
|
34
|
+
const load = async () => {
|
|
35
|
+
setLoading(true);
|
|
36
|
+
setError(null);
|
|
37
|
+
const [projectResult, featuresResult, sectionsResult] = await Promise.all([
|
|
38
|
+
adapter.getProject(projectId),
|
|
39
|
+
adapter.getFeatures({ projectId }),
|
|
40
|
+
adapter.getSections('PROJECT' as EntityType, projectId),
|
|
41
|
+
]);
|
|
42
|
+
if (projectResult.success) {
|
|
43
|
+
setProject(projectResult.data);
|
|
44
|
+
} else {
|
|
45
|
+
setError(projectResult.error);
|
|
46
|
+
}
|
|
47
|
+
if (featuresResult.success) {
|
|
48
|
+
setFeatures(featuresResult.data);
|
|
49
|
+
}
|
|
50
|
+
if (sectionsResult.success) {
|
|
51
|
+
setSections(sectionsResult.data);
|
|
52
|
+
}
|
|
53
|
+
setLoading(false);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
load();
|
|
58
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
59
|
+
}, [projectId]);
|
|
60
|
+
|
|
61
|
+
// Handle keyboard navigation
|
|
62
|
+
useInput((input, key) => {
|
|
63
|
+
if (mode !== 'idle') return;
|
|
64
|
+
if (key.escape || input === 'h' || key.leftArrow) {
|
|
65
|
+
onBack();
|
|
66
|
+
}
|
|
67
|
+
if (input === 'r') {
|
|
68
|
+
load();
|
|
69
|
+
}
|
|
70
|
+
if (input === 'e' && project) {
|
|
71
|
+
setMode('edit-project');
|
|
72
|
+
}
|
|
73
|
+
if (input === 's' && project) {
|
|
74
|
+
adapter.getAllowedTransitions('PROJECT', project.status).then((result) => {
|
|
75
|
+
if (result.success) {
|
|
76
|
+
setTransitions(result.data);
|
|
77
|
+
setTransitionIndex(0);
|
|
78
|
+
setMode('project-status');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (features.length > 0) {
|
|
83
|
+
if (input === 'j' || key.downArrow) {
|
|
84
|
+
setSelectedFeatureIndex((prev) => Math.min(prev + 1, features.length - 1));
|
|
85
|
+
}
|
|
86
|
+
if (input === 'k' || key.upArrow) {
|
|
87
|
+
setSelectedFeatureIndex((prev) => Math.max(prev - 1, 0));
|
|
88
|
+
}
|
|
89
|
+
if (key.return) {
|
|
90
|
+
const selectedFeature = features[selectedFeatureIndex];
|
|
91
|
+
if (selectedFeature) {
|
|
92
|
+
onSelectFeature(selectedFeature.id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
useInput((input, key) => {
|
|
99
|
+
if (mode !== 'project-status' || !project) return;
|
|
100
|
+
if (input === 'j' || key.downArrow) {
|
|
101
|
+
setTransitionIndex((prev) => (prev + 1) % Math.max(1, transitions.length));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (input === 'k' || key.upArrow) {
|
|
105
|
+
setTransitionIndex((prev) => (prev - 1 + Math.max(1, transitions.length)) % Math.max(1, transitions.length));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (key.escape) {
|
|
109
|
+
setMode('idle');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (key.return) {
|
|
113
|
+
const nextStatus = transitions[transitionIndex] as ProjectStatus | undefined;
|
|
114
|
+
if (!nextStatus) return;
|
|
115
|
+
adapter.setProjectStatus(project.id, nextStatus, project.version).then((result) => {
|
|
116
|
+
if (!result.success) setLocalError(result.error);
|
|
117
|
+
load();
|
|
118
|
+
setMode('idle');
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}, { isActive: mode === 'project-status' });
|
|
122
|
+
|
|
123
|
+
if (loading) {
|
|
124
|
+
return (
|
|
125
|
+
<Box padding={1}>
|
|
126
|
+
<Text>Loading project...</Text>
|
|
127
|
+
</Box>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (error) {
|
|
132
|
+
return (
|
|
133
|
+
<Box padding={1}>
|
|
134
|
+
<Text color={theme.colors.danger}>Error: {error}</Text>
|
|
135
|
+
</Box>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!project) {
|
|
140
|
+
return (
|
|
141
|
+
<Box padding={1}>
|
|
142
|
+
<Text>Project not found</Text>
|
|
143
|
+
</Box>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<Box flexDirection="column" padding={1}>
|
|
149
|
+
{/* Project Header */}
|
|
150
|
+
<Box marginBottom={1}>
|
|
151
|
+
<Text bold>{project.name}</Text>
|
|
152
|
+
<Text> </Text>
|
|
153
|
+
<StatusBadge status={project.status} />
|
|
154
|
+
</Box>
|
|
155
|
+
|
|
156
|
+
{/* Divider */}
|
|
157
|
+
<Box marginY={0}>
|
|
158
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
159
|
+
</Box>
|
|
160
|
+
|
|
161
|
+
{/* Project Metadata */}
|
|
162
|
+
<Box marginBottom={1}>
|
|
163
|
+
<Text>Modified: </Text>
|
|
164
|
+
<Text dimColor>{timeAgo(new Date(project.modifiedAt))}</Text>
|
|
165
|
+
</Box>
|
|
166
|
+
|
|
167
|
+
{/* Divider */}
|
|
168
|
+
<Box marginY={0}>
|
|
169
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
170
|
+
</Box>
|
|
171
|
+
|
|
172
|
+
{/* Project Details (summary) */}
|
|
173
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
174
|
+
<Text bold>Details</Text>
|
|
175
|
+
<Box marginLeft={1}>
|
|
176
|
+
<Text wrap="wrap">{project.summary}</Text>
|
|
177
|
+
</Box>
|
|
178
|
+
</Box>
|
|
179
|
+
|
|
180
|
+
{/* Divider */}
|
|
181
|
+
{project.description && (
|
|
182
|
+
<Box marginY={0}>
|
|
183
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
184
|
+
</Box>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Project Description */}
|
|
188
|
+
{project.description && (
|
|
189
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
190
|
+
<Text bold>Description</Text>
|
|
191
|
+
<Box marginLeft={1}>
|
|
192
|
+
<Text wrap="wrap">{project.description}</Text>
|
|
193
|
+
</Box>
|
|
194
|
+
</Box>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{/* Divider */}
|
|
198
|
+
<Box marginY={0}>
|
|
199
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
200
|
+
</Box>
|
|
201
|
+
|
|
202
|
+
{/* Features List */}
|
|
203
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
204
|
+
<Text bold>Features ({features.length})</Text>
|
|
205
|
+
{features.length === 0 ? (
|
|
206
|
+
<Box marginLeft={1}><EmptyState message="No features" hint="" /></Box>
|
|
207
|
+
) : (
|
|
208
|
+
<Box flexDirection="column" marginLeft={1}>
|
|
209
|
+
{features.map((feature, index) => {
|
|
210
|
+
const isSelected = index === selectedFeatureIndex;
|
|
211
|
+
return (
|
|
212
|
+
<Box key={feature.id}>
|
|
213
|
+
<Text color={isSelected ? theme.colors.highlight : undefined}>
|
|
214
|
+
{isSelected ? '▎' : ' '}
|
|
215
|
+
</Text>
|
|
216
|
+
<Text> </Text>
|
|
217
|
+
<StatusBadge status={feature.status} />
|
|
218
|
+
<Text> </Text>
|
|
219
|
+
<Text bold={isSelected}>
|
|
220
|
+
{feature.name}
|
|
221
|
+
</Text>
|
|
222
|
+
</Box>
|
|
223
|
+
);
|
|
224
|
+
})}
|
|
225
|
+
</Box>
|
|
226
|
+
)}
|
|
227
|
+
</Box>
|
|
228
|
+
|
|
229
|
+
{/* Divider */}
|
|
230
|
+
{sections.length > 0 && (
|
|
231
|
+
<Box marginY={0}>
|
|
232
|
+
<Text dimColor>{'─'.repeat(40)}</Text>
|
|
233
|
+
</Box>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Sections Panel - only show if there are sections */}
|
|
237
|
+
{sections.length > 0 && (
|
|
238
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
239
|
+
<Text bold>Sections</Text>
|
|
240
|
+
<SectionList
|
|
241
|
+
sections={sections}
|
|
242
|
+
selectedIndex={selectedSectionIndex}
|
|
243
|
+
onSelectedIndexChange={setSelectedSectionIndex}
|
|
244
|
+
isActive={true}
|
|
245
|
+
/>
|
|
246
|
+
</Box>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{/* Help Footer */}
|
|
250
|
+
<Box marginTop={1}>
|
|
251
|
+
<Text dimColor>
|
|
252
|
+
ESC/h: Back | r: Refresh | e: Edit | s: Status{features.length > 0 ? ' | j/k: Navigate | Enter: Select Feature' : ''}
|
|
253
|
+
</Text>
|
|
254
|
+
</Box>
|
|
255
|
+
|
|
256
|
+
{localError ? <ErrorMessage message={localError} onDismiss={() => setLocalError(null)} /> : null}
|
|
257
|
+
|
|
258
|
+
{mode === 'edit-project' ? (
|
|
259
|
+
<FormDialog
|
|
260
|
+
title="Edit Project"
|
|
261
|
+
fields={[
|
|
262
|
+
{ key: 'name', label: 'Name', required: true, value: project.name },
|
|
263
|
+
{ key: 'summary', label: 'Summary', required: true, value: project.summary },
|
|
264
|
+
{ key: 'description', label: 'Description', value: project.description ?? '' },
|
|
265
|
+
]}
|
|
266
|
+
onCancel={() => setMode('idle')}
|
|
267
|
+
onSubmit={(values) => {
|
|
268
|
+
adapter.updateProject(project.id, {
|
|
269
|
+
name: values.name ?? '',
|
|
270
|
+
summary: values.summary ?? '',
|
|
271
|
+
description: values.description || undefined,
|
|
272
|
+
version: project.version,
|
|
273
|
+
}).then((result) => {
|
|
274
|
+
if (!result.success) setLocalError(result.error);
|
|
275
|
+
load();
|
|
276
|
+
setMode('idle');
|
|
277
|
+
});
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
) : null}
|
|
281
|
+
|
|
282
|
+
{mode === 'project-status' ? (
|
|
283
|
+
<Box flexDirection="column" borderStyle="round" borderColor={theme.colors.accent} paddingX={1} marginTop={1}>
|
|
284
|
+
<Text bold>Set Project Status</Text>
|
|
285
|
+
{transitions.length === 0 ? (
|
|
286
|
+
<Text dimColor>No transitions available</Text>
|
|
287
|
+
) : (
|
|
288
|
+
transitions.map((status, idx) => {
|
|
289
|
+
const isSelected = idx === transitionIndex;
|
|
290
|
+
return (
|
|
291
|
+
<Box key={status}>
|
|
292
|
+
<Text color={isSelected ? theme.colors.highlight : undefined}>
|
|
293
|
+
{isSelected ? '▎' : ' '}
|
|
294
|
+
</Text>
|
|
295
|
+
<Text bold={isSelected}> {status}</Text>
|
|
296
|
+
</Box>
|
|
297
|
+
);
|
|
298
|
+
})
|
|
299
|
+
)}
|
|
300
|
+
<Text dimColor>Enter apply • Esc cancel</Text>
|
|
301
|
+
</Box>
|
|
302
|
+
) : null}
|
|
303
|
+
</Box>
|
|
304
|
+
);
|
|
305
|
+
}
|