@contentful/experience-design-system-cli 2.7.1-dev-build-02830eb.0 → 2.7.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.
- package/dist/package.json +1 -1
- package/dist/src/analyze/select/tui/App.js +475 -54
- package/dist/src/analyze/select/tui/components/ComponentDetail.d.ts +5 -3
- package/dist/src/analyze/select/tui/components/ComponentDetail.js +3 -3
- package/dist/src/analyze/select/tui/components/FinalizeDialog.d.ts +1 -1
- package/dist/src/analyze/select/tui/components/FinalizeDialog.js +10 -2
- package/dist/src/analyze/select/tui/components/HelpOverlay.d.ts +1 -1
- package/dist/src/analyze/select/tui/components/HelpOverlay.js +7 -2
- package/dist/src/analyze/select/tui/components/JsonEditor.d.ts +5 -7
- package/dist/src/analyze/select/tui/components/JsonEditor.js +146 -15
- package/dist/src/analyze/select/tui/components/QuitDialog.d.ts +1 -1
- package/dist/src/analyze/select/tui/components/QuitDialog.js +10 -2
- package/dist/src/analyze/select/tui/components/Sidebar.d.ts +2 -0
- package/dist/src/analyze/select/tui/components/StatusBar.d.ts +2 -0
- package/dist/src/analyze/select/tui/hooks/useImmediateInput.d.ts +0 -5
- package/dist/src/analyze/select/tui/hooks/useImmediateInput.js +4 -7
- package/dist/src/analyze/select/tui/hooks/useKeymap.d.ts +23 -0
- package/dist/src/analyze/select/tui/hooks/useKeymap.js +67 -1
- package/dist/src/analyze/select/tui/hooks/useUndo.js +21 -15
- package/dist/src/analyze/tui/AnalyzeView.js +0 -2
- package/dist/src/import/tui/WizardApp.js +0 -2
- package/dist/src/import/tui/steps/GenerateReviewStep.js +11 -4
- package/dist/src/print/validate/tui/ValidateView.js +0 -2
- package/package.json +2 -2
- package/dist/src/analyze/select/tui/hooks/useRawMode.d.ts +0 -6
- package/dist/src/analyze/select/tui/hooks/useRawMode.js +0 -16
- package/dist/src/analyze/select/tui/hooks/useSideEffects.d.ts +0 -17
- package/dist/src/analyze/select/tui/hooks/useSideEffects.js +0 -226
- package/dist/src/analyze/select/tui/inputToAction.d.ts +0 -13
- package/dist/src/analyze/select/tui/inputToAction.js +0 -68
- package/dist/src/analyze/select/tui/state.d.ts +0 -136
- package/dist/src/analyze/select/tui/state.js +0 -385
- package/dist/src/analyze/select/tui/utils.d.ts +0 -5
- package/dist/src/analyze/select/tui/utils.js +0 -11
package/dist/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect,
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { Box, Text, useStdout } from 'ink';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { createReviewSessionDetail } from '../types.js';
|
|
6
|
+
import { stripScoringFields } from '../../../types.js';
|
|
4
7
|
import { TopBar } from './components/TopBar.js';
|
|
5
8
|
import { Sidebar } from './components/Sidebar.js';
|
|
6
9
|
import { ComponentDetail } from './components/ComponentDetail.js';
|
|
@@ -9,69 +12,367 @@ import { HelpOverlay } from './components/HelpOverlay.js';
|
|
|
9
12
|
import { FinalizeDialog } from './components/FinalizeDialog.js';
|
|
10
13
|
import { QuitDialog } from './components/QuitDialog.js';
|
|
11
14
|
import { PreviewSummaryBar } from './components/PreviewSummaryBar.js';
|
|
15
|
+
import { useKeymap } from './hooks/useKeymap.js';
|
|
12
16
|
import { useImmediateInput } from './hooks/useImmediateInput.js';
|
|
13
|
-
import { useRawMode } from './hooks/useRawMode.js';
|
|
14
17
|
import { useSession } from './hooks/useSession.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
18
|
+
import { openPipelineDb, storeRawComponents, loadCDFComponents } from '../../../session/db.js';
|
|
19
|
+
import { ImportApiClient } from '../../../apply/api-client.js';
|
|
20
|
+
import { readTokensFromPath } from '../../../apply/manifest.js';
|
|
21
|
+
import { buildManifest } from '@contentful/experience-design-system-types';
|
|
19
22
|
export function App({ sessionId, artifactsRoot, reviewRoot }) {
|
|
20
23
|
const { stdout } = useStdout();
|
|
21
24
|
const terminalWidth = stdout?.columns ?? 80;
|
|
22
|
-
const visibleCount = Math.max(1, (stdout?.rows ?? 24) - 5);
|
|
23
|
-
useRawMode();
|
|
24
25
|
const { session: loadedSession, paths, loading, error: sessionError, saveState, appendEvent, } = useSession({
|
|
25
26
|
sessionId,
|
|
26
27
|
artifactsRoot,
|
|
27
28
|
reviewRoot,
|
|
28
29
|
});
|
|
29
|
-
const [
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
const [session, setSession] = useState(null);
|
|
31
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
32
|
+
const [draftsByComponentId, setDraftsByComponentId] = useState({});
|
|
33
|
+
const [sidebarFocused, setSidebarFocused] = useState(true);
|
|
34
|
+
const [editMode, setEditMode] = useState(false);
|
|
35
|
+
const [sourceVisible, setSourceVisible] = useState(false);
|
|
36
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
37
|
+
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false);
|
|
38
|
+
const [showQuitDialog, setShowQuitDialog] = useState(false);
|
|
39
|
+
const [isSaving] = useState(false);
|
|
40
|
+
const [saveError, setSaveError] = useState(null);
|
|
41
|
+
const [finalizedResult, setFinalizedResult] = useState(null);
|
|
42
|
+
const [sidebarScrollOffset, setSidebarScrollOffset] = useState(0);
|
|
43
|
+
const [jsonScrollOffset, setJsonScrollOffset] = useState(0);
|
|
44
|
+
// Keeps the visual sort order in sync for use inside keymap handlers (which close over a stale render)
|
|
45
|
+
const sortedIdsRef = useRef([]);
|
|
46
|
+
// Source code kept separate from session state so lazy loading never mutates
|
|
47
|
+
// session.components — that would invalidate useMemo(sessionSummary) and flash the sidebar
|
|
48
|
+
const [sourceCodeById, setSourceCodeById] = useState({});
|
|
49
|
+
const [previewAnnotations, setPreviewAnnotations] = useState(() => {
|
|
50
|
+
const raw = process.env['EDS_PREVIEW_ANNOTATIONS'];
|
|
51
|
+
if (!raw)
|
|
52
|
+
return {};
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
42
59
|
});
|
|
43
|
-
|
|
60
|
+
const [previewLoading, setPreviewLoading] = useState(false);
|
|
61
|
+
const [previewError, setPreviewError] = useState(null);
|
|
62
|
+
const [previewResponse, setPreviewResponse] = useState(null);
|
|
63
|
+
const refreshPreview = useCallback(async () => {
|
|
64
|
+
const cmaToken = process.env['EDS_CMA_TOKEN'];
|
|
65
|
+
const spaceId = process.env['EDS_SPACE_ID'];
|
|
66
|
+
const environmentId = process.env['EDS_ENVIRONMENT_ID'];
|
|
67
|
+
const tokensPath = process.env['EDS_TOKENS_PATH'];
|
|
68
|
+
if (!cmaToken || !spaceId || !environmentId)
|
|
69
|
+
return;
|
|
70
|
+
setPreviewLoading(true);
|
|
71
|
+
try {
|
|
72
|
+
const db = openPipelineDb();
|
|
73
|
+
let components = [];
|
|
74
|
+
try {
|
|
75
|
+
components = loadCDFComponents(db, sessionId);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
db.close();
|
|
79
|
+
}
|
|
80
|
+
let tokens = [];
|
|
81
|
+
if (tokensPath)
|
|
82
|
+
tokens = await readTokensFromPath('tokens', tokensPath);
|
|
83
|
+
const manifest = buildManifest(components, tokens);
|
|
84
|
+
if (!manifest.componentsManifest)
|
|
85
|
+
manifest.componentsManifest = {};
|
|
86
|
+
const client = new ImportApiClient({ cmaToken, spaceId, environmentId });
|
|
87
|
+
const preview = await client.previewImport(manifest);
|
|
88
|
+
const annotations = {};
|
|
89
|
+
for (const item of preview.components.new) {
|
|
90
|
+
const name = item.name ?? '';
|
|
91
|
+
if (name)
|
|
92
|
+
annotations[name] = 'new';
|
|
93
|
+
}
|
|
94
|
+
for (const item of preview.components.removed) {
|
|
95
|
+
annotations[item.name] = 'removed';
|
|
96
|
+
}
|
|
97
|
+
for (const item of preview.components.changed) {
|
|
98
|
+
if (item.changeClassification?.classification === 'breaking') {
|
|
99
|
+
annotations[item.current.name] = 'breaking';
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
annotations[item.current.name] = 'changed';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
setPreviewAnnotations(annotations);
|
|
106
|
+
setPreviewResponse(preview);
|
|
107
|
+
setPreviewError(null);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
setPreviewError(msg.includes('token') ? 'Preview request failed' : msg);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
setPreviewLoading(false);
|
|
115
|
+
}
|
|
116
|
+
}, [sessionId]);
|
|
117
|
+
const syncSessionToDb = useCallback((currentSession) => {
|
|
118
|
+
const db = openPipelineDb();
|
|
119
|
+
try {
|
|
120
|
+
db.prepare(`UPDATE raw_components SET status = 'extracted' WHERE session_id = ?`).run(sessionId);
|
|
121
|
+
const acceptedNames = currentSession.components
|
|
122
|
+
.filter((c) => c.status === 'accepted' || c.status === 'reviewed')
|
|
123
|
+
.map((c) => c.name);
|
|
124
|
+
if (acceptedNames.length > 0) {
|
|
125
|
+
db.prepare(`UPDATE raw_components SET status = 'generated' WHERE session_id = ? AND name IN (${acceptedNames.map(() => '?').join(',')})`).run(sessionId, ...acceptedNames);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
db.close();
|
|
130
|
+
}
|
|
131
|
+
}, [sessionId]);
|
|
132
|
+
const previewDebounceRef = useRef(null);
|
|
133
|
+
const debouncedRefreshPreview = useCallback((currentSession) => {
|
|
134
|
+
if (previewDebounceRef.current)
|
|
135
|
+
clearTimeout(previewDebounceRef.current);
|
|
136
|
+
previewDebounceRef.current = setTimeout(() => {
|
|
137
|
+
syncSessionToDb(currentSession);
|
|
138
|
+
void refreshPreview();
|
|
139
|
+
}, 500);
|
|
140
|
+
}, [syncSessionToDb, refreshPreview]);
|
|
44
141
|
useEffect(() => {
|
|
45
|
-
|
|
46
|
-
|
|
142
|
+
return () => {
|
|
143
|
+
if (previewDebounceRef.current)
|
|
144
|
+
clearTimeout(previewDebounceRef.current);
|
|
145
|
+
};
|
|
146
|
+
}, []);
|
|
147
|
+
// Sync loaded session into local state; selectedId is set to null here
|
|
148
|
+
// and will be corrected to the first sorted item on first render via the effect below
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (loadedSession && !session) {
|
|
151
|
+
setSession(loadedSession);
|
|
152
|
+
setSelectedId(null);
|
|
47
153
|
}
|
|
48
154
|
}, [loadedSession]);
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
155
|
+
// Once the sort order is known (after first render), select the first sorted item.
|
|
156
|
+
// Runs only when session transitions from null → loaded.
|
|
157
|
+
const sessionLoaded = session !== null;
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (sessionLoaded && selectedId === null && sortedIdsRef.current.length > 0) {
|
|
160
|
+
setSelectedId(sortedIdsRef.current[0]);
|
|
161
|
+
}
|
|
162
|
+
}, [sessionLoaded]);
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (session && !previewResponse) {
|
|
165
|
+
const raw = process.env['EDS_PREVIEW_COUNTS'];
|
|
166
|
+
if (raw) {
|
|
167
|
+
try {
|
|
168
|
+
const counts = JSON.parse(raw);
|
|
169
|
+
setPreviewResponse({
|
|
170
|
+
components: {
|
|
171
|
+
new: Array(counts.compNew).fill({}),
|
|
172
|
+
changed: Array(counts.compChanged).fill({}),
|
|
173
|
+
removed: Array(counts.compRemoved).fill({}),
|
|
174
|
+
unchanged: Array(counts.compUnchanged).fill({}),
|
|
175
|
+
},
|
|
176
|
+
tokens: {
|
|
177
|
+
new: Array(counts.tokNew).fill({}),
|
|
178
|
+
changed: Array(counts.tokChanged).fill({}),
|
|
179
|
+
removed: Array(counts.tokRemoved).fill({}),
|
|
180
|
+
unchanged: Array(counts.tokUnchanged).fill({}),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
process.stderr.write(`[eds] failed to parse EDS_PREVIEW_COUNTS: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}, [session]);
|
|
190
|
+
// Lazy source code loading — writes to sourceCodeById, NOT session.components,
|
|
191
|
+
// so it doesn't invalidate useMemo(sessionSummary) and cause a sidebar flash
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!session || !selectedId)
|
|
194
|
+
return;
|
|
195
|
+
if (sourceCodeById[selectedId] !== undefined)
|
|
196
|
+
return; // already loaded
|
|
197
|
+
const selectedComponent = session.components.find((c) => c.id === selectedId);
|
|
198
|
+
if (!selectedComponent)
|
|
199
|
+
return;
|
|
200
|
+
readFile(selectedComponent.resolvedSourcePath, 'utf8')
|
|
201
|
+
.then((code) => {
|
|
202
|
+
setSourceCodeById((prev) => ({ ...prev, [selectedId]: code }));
|
|
203
|
+
})
|
|
204
|
+
.catch(() => {
|
|
205
|
+
setSourceCodeById((prev) => ({ ...prev, [selectedId]: '' }));
|
|
206
|
+
});
|
|
207
|
+
}, [selectedId]);
|
|
208
|
+
// SIGINT handler
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const handler = () => {
|
|
211
|
+
if (Object.keys(draftsByComponentId).length > 0) {
|
|
212
|
+
setShowQuitDialog(true);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
process.on('SIGINT', handler);
|
|
219
|
+
return () => {
|
|
220
|
+
process.off('SIGINT', handler);
|
|
221
|
+
};
|
|
222
|
+
}, [draftsByComponentId]);
|
|
223
|
+
const updateStatus = async (componentId, newStatus) => {
|
|
224
|
+
if (!session || !paths)
|
|
225
|
+
return;
|
|
226
|
+
const updatedSession = {
|
|
227
|
+
...session,
|
|
228
|
+
components: session.components.map((c) => (c.id === componentId ? { ...c, status: newStatus } : c)),
|
|
229
|
+
};
|
|
230
|
+
setSession(updatedSession);
|
|
231
|
+
await saveState(updatedSession);
|
|
232
|
+
const component = session.components.find((c) => c.id === componentId);
|
|
233
|
+
await appendEvent({
|
|
234
|
+
type: 'status_changed',
|
|
235
|
+
payload: { componentId, from: component?.status, to: newStatus },
|
|
236
|
+
});
|
|
237
|
+
debouncedRefreshPreview(updatedSession);
|
|
238
|
+
};
|
|
239
|
+
const dialogOpen = showHelp || showFinalizeDialog || showQuitDialog;
|
|
240
|
+
useKeymap({
|
|
241
|
+
sidebarFocused,
|
|
242
|
+
editMode,
|
|
243
|
+
dialogOpen,
|
|
244
|
+
disabled: isSaving,
|
|
245
|
+
}, {
|
|
246
|
+
onSidebarUp: () => {
|
|
247
|
+
if (!session)
|
|
248
|
+
return;
|
|
249
|
+
const ids = sortedIdsRef.current;
|
|
250
|
+
const idx = ids.indexOf(selectedId ?? '');
|
|
251
|
+
if (idx > 0) {
|
|
252
|
+
setSelectedId(ids[idx - 1]);
|
|
253
|
+
setJsonScrollOffset(0);
|
|
254
|
+
setSidebarScrollOffset((prev) => Math.min(prev, idx - 1));
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
onSidebarDown: () => {
|
|
258
|
+
if (!session)
|
|
259
|
+
return;
|
|
260
|
+
const ids = sortedIdsRef.current;
|
|
261
|
+
const idx = ids.indexOf(selectedId ?? '');
|
|
262
|
+
if (idx < ids.length - 1) {
|
|
263
|
+
setSelectedId(ids[idx + 1]);
|
|
264
|
+
setJsonScrollOffset(0);
|
|
265
|
+
setSidebarScrollOffset((prev) => {
|
|
266
|
+
const newIdx = idx + 1;
|
|
267
|
+
return newIdx >= prev + visibleCount ? newIdx - visibleCount + 1 : prev;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
onSidebarSelect: () => { },
|
|
272
|
+
onAccept: () => {
|
|
273
|
+
if (selectedId)
|
|
274
|
+
void updateStatus(selectedId, 'accepted');
|
|
275
|
+
},
|
|
276
|
+
onReject: () => {
|
|
277
|
+
if (selectedId)
|
|
278
|
+
void updateStatus(selectedId, 'rejected');
|
|
279
|
+
},
|
|
280
|
+
onEnterEditMode: () => {
|
|
281
|
+
if (!session || !selectedId)
|
|
282
|
+
return;
|
|
283
|
+
const component = session.components.find((c) => c.id === selectedId);
|
|
284
|
+
if (!component)
|
|
285
|
+
return;
|
|
286
|
+
setEditMode(true);
|
|
287
|
+
setDraftsByComponentId((prev) => ({
|
|
288
|
+
...prev,
|
|
289
|
+
[selectedId]: prev[selectedId] ?? JSON.stringify(stripScoringFields(component.editedProposal), null, 2),
|
|
290
|
+
}));
|
|
291
|
+
},
|
|
292
|
+
onToggleSource: () => {
|
|
293
|
+
if (terminalWidth < 120) {
|
|
294
|
+
setSaveError('Terminal too narrow for source panel (need 120+ cols)');
|
|
295
|
+
setTimeout(() => setSaveError(null), 3000);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
setSourceVisible((prev) => !prev);
|
|
299
|
+
},
|
|
300
|
+
onScrollUp: () => {
|
|
301
|
+
setJsonScrollOffset((prev) => Math.max(0, prev - 1));
|
|
302
|
+
},
|
|
303
|
+
onScrollDown: () => {
|
|
304
|
+
setJsonScrollOffset((prev) => prev + 1);
|
|
305
|
+
},
|
|
306
|
+
onToggleFocus: () => setSidebarFocused((prev) => !prev),
|
|
307
|
+
onApproveAll: async () => {
|
|
308
|
+
if (!session || !paths)
|
|
309
|
+
return;
|
|
310
|
+
const updatedComponents = session.components.map((c) => c.status === 'needs-review' ? { ...c, status: 'accepted' } : c);
|
|
311
|
+
const affected = updatedComponents.filter((c) => c.status === 'accepted').length -
|
|
312
|
+
session.components.filter((c) => c.status === 'accepted').length;
|
|
313
|
+
const updatedSession = { ...session, components: updatedComponents };
|
|
314
|
+
setSession(updatedSession);
|
|
315
|
+
await saveState(updatedSession);
|
|
316
|
+
await appendEvent({ type: 'approve_all', payload: { affected } });
|
|
317
|
+
syncSessionToDb(updatedSession);
|
|
318
|
+
void refreshPreview();
|
|
319
|
+
},
|
|
320
|
+
onFinalize: () => setShowFinalizeDialog(true),
|
|
321
|
+
onQuit: () => {
|
|
322
|
+
if (Object.keys(draftsByComponentId).length > 0) {
|
|
323
|
+
setShowQuitDialog(true);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
onToggleHelp: () => setShowHelp((prev) => !prev),
|
|
330
|
+
});
|
|
331
|
+
// Sorted order: flagged+unresolved first, then ascending confidence.
|
|
332
|
+
// Memoised above early returns (React rules of hooks) so the array reference
|
|
333
|
+
// is stable between renders — prevents Sidebar repainting on scroll/select.
|
|
334
|
+
const sessionSummary = useMemo(() => (session?.components ?? [])
|
|
335
|
+
.map((c) => ({
|
|
336
|
+
id: c.id,
|
|
337
|
+
name: c.name,
|
|
338
|
+
status: c.status,
|
|
339
|
+
previewAnnotation: previewAnnotations[c.name],
|
|
340
|
+
extractionConfidence: c.originalProposal.extractionConfidence ?? null,
|
|
341
|
+
needsReview: c.originalProposal.needsReview ?? false,
|
|
342
|
+
}))
|
|
343
|
+
.sort((a, b) => {
|
|
344
|
+
const aFlagged = a.needsReview && a.status === 'needs-review' ? 0 : 1;
|
|
345
|
+
const bFlagged = b.needsReview && b.status === 'needs-review' ? 0 : 1;
|
|
346
|
+
if (aFlagged !== bFlagged)
|
|
347
|
+
return aFlagged - bFlagged;
|
|
348
|
+
// null (unscored) sorts last; lower numeric score sorts first (most concerning)
|
|
349
|
+
const aConf = a.extractionConfidence ?? 6;
|
|
350
|
+
const bConf = b.extractionConfidence ?? 6;
|
|
351
|
+
return aConf - bConf;
|
|
352
|
+
}), [session?.components, previewAnnotations]);
|
|
353
|
+
// Stable ID order — kept in a ref for use inside keymap handlers
|
|
354
|
+
const sortedIds = useMemo(() => sessionSummary.map((c) => c.id), [sessionSummary]);
|
|
355
|
+
sortedIdsRef.current = sortedIds;
|
|
356
|
+
if (loading) {
|
|
53
357
|
return _jsx(Text, { children: "Loading session..." });
|
|
54
|
-
|
|
358
|
+
}
|
|
359
|
+
if (sessionError) {
|
|
55
360
|
return (_jsxs(Text, { color: "red", children: [sessionError, '\nPress q to exit.'] }));
|
|
56
|
-
|
|
361
|
+
}
|
|
362
|
+
if (!session || !paths) {
|
|
57
363
|
return _jsx(Text, { color: "red", children: "Session unavailable." });
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
return _jsx(FinalizedScreen, { result:
|
|
364
|
+
}
|
|
365
|
+
if (finalizedResult) {
|
|
366
|
+
return _jsx(FinalizedScreen, { result: finalizedResult });
|
|
367
|
+
}
|
|
61
368
|
const selectedRecord = session.components.find((c) => c.id === selectedId) ?? null;
|
|
62
369
|
const sessionDetail = selectedRecord ? createReviewSessionDetail({ ...session, components: [selectedRecord] }) : null;
|
|
63
370
|
const selectedDetail = sessionDetail?.components[0] ?? null;
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
const dialogOpen = mode.type === 'dialog';
|
|
71
|
-
const isEditing = mode.type === 'editing';
|
|
72
|
-
const sourceVisible = mode.type === 'browsing' && mode.sourceVisible;
|
|
73
|
-
const sidebarFocused = mode.type === 'browsing' && mode.sidebarFocused;
|
|
74
|
-
const hints = isEditing
|
|
371
|
+
const acceptedCount = session.components.filter((c) => c.status === 'accepted').length;
|
|
372
|
+
const rejectedCount = session.components.filter((c) => c.status === 'rejected').length;
|
|
373
|
+
const reviewedCount = session.components.filter((c) => c.status === 'reviewed').length;
|
|
374
|
+
const needsReviewCount = session.components.filter((c) => c.status === 'needs-review').length;
|
|
375
|
+
const hints = editMode
|
|
75
376
|
? [
|
|
76
377
|
{ key: 'Ctrl+S', label: 'save' },
|
|
77
378
|
{ key: 'Esc', label: 'discard' },
|
|
@@ -83,21 +384,141 @@ export function App({ sessionId, artifactsRoot, reviewRoot }) {
|
|
|
83
384
|
{ key: 'q', label: 'quit' },
|
|
84
385
|
];
|
|
85
386
|
const collapsed = terminalWidth < 80;
|
|
86
|
-
|
|
387
|
+
// TopBar(1) + statusbar(1) + footer(1) + border padding(2) = ~5 chrome rows
|
|
388
|
+
const CHROME_ROWS = 5;
|
|
389
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
390
|
+
const visibleCount = Math.max(1, terminalRows - CHROME_ROWS);
|
|
391
|
+
const longestName = session.components.reduce((max, c) => Math.max(max, c.name.length), 0);
|
|
392
|
+
// icon + space + name + 2 border chars; min 14, max 22
|
|
87
393
|
const sidebarWidth = collapsed ? 3 : Math.min(Math.max(longestName + 4, 14), 22);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
394
|
+
const handleDraftSave = async () => {
|
|
395
|
+
if (!selectedId || !session || !paths)
|
|
396
|
+
return;
|
|
397
|
+
const draft = draftsByComponentId[selectedId];
|
|
398
|
+
if (!draft)
|
|
399
|
+
return;
|
|
400
|
+
try {
|
|
401
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
402
|
+
const parsed = JSON.parse(draft);
|
|
403
|
+
const currentStatus = session.components.find((c) => c.id === selectedId)?.status;
|
|
404
|
+
const newStatus = currentStatus === 'needs-review' ? 'reviewed' : currentStatus;
|
|
405
|
+
const updatedSession = {
|
|
406
|
+
...session,
|
|
407
|
+
components: session.components.map((c) => c.id === selectedId
|
|
408
|
+
? {
|
|
409
|
+
...c,
|
|
410
|
+
editedProposal: parsed,
|
|
411
|
+
status: newStatus,
|
|
412
|
+
}
|
|
413
|
+
: c),
|
|
414
|
+
};
|
|
415
|
+
const { [selectedId]: _removed, ...remainingDrafts } = draftsByComponentId;
|
|
416
|
+
setSession(updatedSession);
|
|
417
|
+
setDraftsByComponentId(remainingDrafts);
|
|
418
|
+
setEditMode(false);
|
|
419
|
+
await saveState(updatedSession);
|
|
420
|
+
await appendEvent({
|
|
421
|
+
type: 'draft_saved',
|
|
422
|
+
payload: { componentId: selectedId },
|
|
423
|
+
});
|
|
424
|
+
// Sync all edited proposals to pipeline DB (keep 'generated' for preview)
|
|
425
|
+
const allEdited = updatedSession.components.map((c) => c.editedProposal);
|
|
426
|
+
const syncDb = openPipelineDb();
|
|
427
|
+
try {
|
|
428
|
+
storeRawComponents(syncDb, sessionId, allEdited, {
|
|
429
|
+
status: 'generated',
|
|
430
|
+
preserveCDF: true,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
syncDb.close();
|
|
435
|
+
}
|
|
436
|
+
void refreshPreview();
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// JSON parse error — JsonEditor already shows the error inline
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const handleDraftDiscard = () => {
|
|
443
|
+
if (!selectedId)
|
|
444
|
+
return;
|
|
445
|
+
const { [selectedId]: _removed, ...remainingDrafts } = draftsByComponentId;
|
|
446
|
+
setDraftsByComponentId(remainingDrafts);
|
|
447
|
+
setEditMode(false);
|
|
448
|
+
};
|
|
449
|
+
const handleFinalize = async () => {
|
|
450
|
+
if (!session || !paths)
|
|
451
|
+
return;
|
|
452
|
+
try {
|
|
453
|
+
await appendEvent({
|
|
454
|
+
type: 'finalized',
|
|
455
|
+
payload: {
|
|
456
|
+
accepted: acceptedCount,
|
|
457
|
+
rejected: rejectedCount,
|
|
458
|
+
excluded: needsReviewCount,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
// Write all components back to DB, marking accepted as 'generated'
|
|
462
|
+
// so loadCDFComponents (push/preview) only picks up accepted ones,
|
|
463
|
+
// but loadRawComponents (editor re-entry) still finds all of them
|
|
464
|
+
const acceptedNames = new Set(session.components.filter((c) => c.status === 'accepted').map((c) => c.name));
|
|
465
|
+
const db = openPipelineDb();
|
|
466
|
+
try {
|
|
467
|
+
storeRawComponents(db, sessionId, session.components.map((c) => c.editedProposal), { status: 'extracted', preserveCDF: true });
|
|
468
|
+
if (acceptedNames.size > 0) {
|
|
469
|
+
db.prepare(`UPDATE raw_components SET status = 'generated' WHERE session_id = ? AND name IN (${[...acceptedNames].map(() => '?').join(',')})`).run(sessionId, ...acceptedNames);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
db.close();
|
|
474
|
+
}
|
|
475
|
+
const output = JSON.stringify({
|
|
476
|
+
status: 'finalized',
|
|
477
|
+
sessionDir: paths.sessionDir,
|
|
478
|
+
accepted: acceptedCount,
|
|
479
|
+
rejected: rejectedCount,
|
|
480
|
+
excluded: needsReviewCount,
|
|
481
|
+
}, null, 2) + '\n';
|
|
482
|
+
process.stdout.write(output);
|
|
483
|
+
setFinalizedResult({
|
|
484
|
+
accepted: acceptedCount,
|
|
485
|
+
rejected: rejectedCount,
|
|
486
|
+
excluded: needsReviewCount,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
setSaveError(String(err));
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBar, { subcommand: "analyze select", hints: hints }), showHelp && _jsx(HelpOverlay, { mode: "review", onClose: () => setShowHelp(false) }), showFinalizeDialog && (_jsx(FinalizeDialog, { accepted: acceptedCount, rejected: rejectedCount, needsReview: needsReviewCount, onConfirm: () => {
|
|
494
|
+
void handleFinalize();
|
|
495
|
+
}, onCancel: () => setShowFinalizeDialog(false) })), showQuitDialog && (_jsx(QuitDialog, { hasUnsavedDrafts: Object.keys(draftsByComponentId).length > 0, onConfirm: async () => {
|
|
496
|
+
if (paths) {
|
|
497
|
+
await appendEvent({
|
|
498
|
+
type: 'session_quit',
|
|
499
|
+
payload: { reason: 'user_quit' },
|
|
500
|
+
});
|
|
501
|
+
}
|
|
92
502
|
process.exit(1);
|
|
93
|
-
}, onCancel: () =>
|
|
94
|
-
|
|
95
|
-
|
|
503
|
+
}, onCancel: () => setShowQuitDialog(false) })), !showHelp && !showFinalizeDialog && !showQuitDialog && (_jsxs(Box, { flexGrow: 1, children: [_jsx(Sidebar, { components: sessionSummary, selectedId: selectedId, focused: sidebarFocused, scrollOffset: sidebarScrollOffset, visibleCount: visibleCount, onSelect: (id) => {
|
|
504
|
+
setSelectedId(id);
|
|
505
|
+
setJsonScrollOffset(0);
|
|
506
|
+
}, onScrollChange: setSidebarScrollOffset, collapsed: collapsed, width: sidebarWidth }), _jsx(Box, { flexGrow: 1, paddingLeft: 1, children: selectedDetail ? (_jsx(ComponentDetail, { component: selectedDetail, sourceCode: selectedId ? (sourceCodeById[selectedId] ?? null) : null, draftValue: selectedId ? (draftsByComponentId[selectedId] ?? '') : '', editMode: editMode, sourceVisible: sourceVisible, jsonScrollOffset: jsonScrollOffset, sourceScrollX: 0, sourceScrollY: 0, terminalWidth: terminalWidth, previewAnnotation: selectedRecord ? previewAnnotations[selectedRecord.name] : undefined, onDraftChange: (value) => {
|
|
507
|
+
if (!selectedId)
|
|
508
|
+
return;
|
|
509
|
+
setDraftsByComponentId((prev) => ({
|
|
510
|
+
...prev,
|
|
511
|
+
[selectedId]: value,
|
|
512
|
+
}));
|
|
513
|
+
}, onSaveDraft: () => {
|
|
514
|
+
void handleDraftSave();
|
|
515
|
+
}, onDiscardDraft: handleDraftDiscard, onScrollChange: setJsonScrollOffset })) : (_jsx(Text, { dimColor: true, children: "No component selected" })) })] })), _jsx(PreviewSummaryBar, { preview: previewResponse, loading: previewLoading }), previewError && _jsx(Text, { color: "yellow", children: '⚠ Preview: ' + previewError }), saveError && _jsx(Text, { color: "red", children: '⚠ ' + saveError }), !dialogOpen && (_jsx(StatusBar, { accepted: acceptedCount, rejected: rejectedCount, reviewed: reviewedCount, needsReview: needsReviewCount, onApproveAll: () => { }, onFinalize: () => setShowFinalizeDialog(true) }))] }));
|
|
96
516
|
}
|
|
97
517
|
function FinalizedScreen({ result, }) {
|
|
98
518
|
useImmediateInput((_input, key) => {
|
|
99
|
-
if (key.return || _input === 'q' || key.escape)
|
|
519
|
+
if (key.return || _input === 'q' || key.escape) {
|
|
100
520
|
process.exit(0);
|
|
521
|
+
}
|
|
101
522
|
});
|
|
102
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, gap: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Finalized" }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "green", children: "\u2713" }), _jsxs(Text, { children: [result.accepted, " accepted"] })] }), result.rejected > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "red", children: "\u2717" }), _jsxs(Text, { children: [result.rejected, " rejected"] })] })), result.excluded > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u00B7" }), _jsxs(Text, { dimColor: true, children: [result.excluded, " excluded"] })] }))] }), _jsx(Text, { dimColor: true, children: "Decisions saved. Ready for the next step." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[Enter / q] Exit" }) })] }));
|
|
523
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, gap: 1, children: [_jsx(Text, { bold: true, color: "green", children: "\u2713 Finalized" }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "green", children: "\u2713" }), _jsxs(Text, { children: [result.accepted, " accepted"] })] }), result.rejected > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "red", children: "\u2717" }), _jsxs(Text, { children: [result.rejected, " rejected"] })] })), result.excluded > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u00B7" }), _jsxs(Text, { dimColor: true, children: [result.excluded, " excluded (unresolved)"] })] }))] }), _jsx(Text, { dimColor: true, children: "Decisions saved. Ready for the next step." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[Enter / q] Exit" }) })] }));
|
|
103
524
|
}
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { PreviewAnnotation, ReviewComponentDetail } from '../../types.js';
|
|
3
|
-
import type { EditorState } from '../state.js';
|
|
4
3
|
type ComponentDetailProps = {
|
|
5
4
|
component: ReviewComponentDetail;
|
|
6
5
|
sourceCode: string | null;
|
|
7
6
|
draftValue: string;
|
|
8
7
|
editMode: boolean;
|
|
9
|
-
editorState: EditorState | null;
|
|
10
8
|
sourceVisible: boolean;
|
|
11
9
|
jsonScrollOffset: number;
|
|
12
10
|
sourceScrollX: number;
|
|
13
11
|
sourceScrollY: number;
|
|
14
12
|
terminalWidth: number;
|
|
15
13
|
previewAnnotation?: PreviewAnnotation;
|
|
14
|
+
onDraftChange: (value: string) => void;
|
|
15
|
+
onSaveDraft: () => void;
|
|
16
|
+
onDiscardDraft: () => void;
|
|
17
|
+
onScrollChange: (offset: number) => void;
|
|
16
18
|
};
|
|
17
|
-
export declare function ComponentDetail({ component, sourceCode, draftValue, editMode,
|
|
19
|
+
export declare function ComponentDetail({ component, sourceCode, draftValue, editMode, sourceVisible, jsonScrollOffset, sourceScrollX, sourceScrollY, terminalWidth, previewAnnotation, onDraftChange, onSaveDraft, onDiscardDraft, }: ComponentDetailProps): React.ReactElement;
|
|
18
20
|
export {};
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { stripScoringFields } from '../../../../types.js';
|
|
4
4
|
import { JsonPanel } from './JsonPanel.js';
|
|
5
|
-
import {
|
|
5
|
+
import { FieldEditor } from './FieldEditor.js';
|
|
6
6
|
import { SourcePanel } from './SourcePanel.js';
|
|
7
7
|
function annotationLabel(annotation) {
|
|
8
8
|
switch (annotation) {
|
|
@@ -18,7 +18,7 @@ function annotationLabel(annotation) {
|
|
|
18
18
|
return null;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
export function ComponentDetail({ component, sourceCode, draftValue, editMode,
|
|
21
|
+
export function ComponentDetail({ component, sourceCode, draftValue, editMode, sourceVisible, jsonScrollOffset, sourceScrollX, sourceScrollY, terminalWidth, previewAnnotation, onDraftChange, onSaveDraft, onDiscardDraft, }) {
|
|
22
22
|
const sidebarWidth = terminalWidth < 80 ? 5 : 20;
|
|
23
23
|
const availableWidth = terminalWidth - sidebarWidth - 2;
|
|
24
24
|
const panelHeight = 20;
|
|
@@ -44,5 +44,5 @@ export function ComponentDetail({ component, sourceCode, draftValue, editMode, e
|
|
|
44
44
|
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: component.name }), (() => {
|
|
45
45
|
const ann = annotationLabel(previewAnnotation);
|
|
46
46
|
return ann ? _jsx(Text, { color: ann.color, children: ann.text }) : null;
|
|
47
|
-
})(), _jsx(Text, { color: confColor, children: ' — ' + confLabel }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: [sourceVisible ? '[s] hide src' : '[s] src', editMode ? '' : ' [e] edit'] })] }), _jsxs(Box, { children: [_jsx(JsonPanel, { label: "ORIGINAL (read-only)", value: originalJson, scrollOffset: jsonScrollOffset, width: originalWidth, height: panelHeight, active: false }), _jsx(Text, { children: " " }), editMode
|
|
47
|
+
})(), _jsx(Text, { color: confColor, children: ' — ' + confLabel }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: [sourceVisible ? '[s] hide src' : '[s] src', editMode ? '' : ' [e] edit'] })] }), _jsxs(Box, { children: [_jsx(JsonPanel, { label: "ORIGINAL (read-only)", value: originalJson, scrollOffset: jsonScrollOffset, width: originalWidth, height: panelHeight, active: false }), _jsx(Text, { children: " " }), editMode ? (_jsx(FieldEditor, { value: draftValue || editedJson, width: editWidth, height: panelHeight, onChange: onDraftChange, onSave: onSaveDraft, onDiscard: onDiscardDraft })) : (_jsx(JsonPanel, { label: "EDIT (draft)", value: draftValue || editedJson, scrollOffset: jsonScrollOffset, width: editWidth, height: panelHeight, active: true })), sourceVisible && sourceWidth > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(SourcePanel, { sourceCode: sourceCode, filePath: component.originalProposal.source, width: sourceWidth, height: panelHeight, scrollX: sourceScrollX, scrollY: sourceScrollY })] }))] }), !editMode && _jsx(Text, { dimColor: true, children: ' [a] accept [r] reject [e] edit [A] accept all [↑↓] scroll' })] }));
|
|
48
48
|
}
|
|
@@ -6,5 +6,5 @@ type FinalizeDialogProps = {
|
|
|
6
6
|
onConfirm: () => void;
|
|
7
7
|
onCancel: () => void;
|
|
8
8
|
};
|
|
9
|
-
export declare function FinalizeDialog({ accepted, rejected, needsReview, onConfirm
|
|
9
|
+
export declare function FinalizeDialog({ accepted, rejected, needsReview, onConfirm, onCancel, }: FinalizeDialogProps): React.ReactElement;
|
|
10
10
|
export {};
|