ghcrawl 0.3.0 → 0.5.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 +33 -1
- package/dist/init-wizard.d.ts.map +1 -1
- package/dist/init-wizard.js +19 -2
- package/dist/init-wizard.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +51 -3
- package/dist/main.js.map +1 -1
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +467 -36
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/layout.d.ts +3 -2
- package/dist/tui/layout.d.ts.map +1 -1
- package/dist/tui/layout.js +16 -2
- package/dist/tui/layout.js.map +1 -1
- package/dist/tui/state.d.ts +3 -1
- package/dist/tui/state.d.ts.map +1 -1
- package/dist/tui/state.js +13 -7
- package/dist/tui/state.js.map +1 -1
- package/package.json +3 -3
package/dist/tui/app.js
CHANGED
|
@@ -20,7 +20,7 @@ function createScreen(options) {
|
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
22
|
const ACTIVITY_LOG_LIMIT = 200;
|
|
23
|
-
const FOOTER_LOG_LINES =
|
|
23
|
+
const FOOTER_LOG_LINES = 3;
|
|
24
24
|
const UPDATE_TASK_ORDER = ['sync', 'embed', 'cluster'];
|
|
25
25
|
export async function startTui(params) {
|
|
26
26
|
const selectedRepository = params.owner && params.repo ? { owner: params.owner, repo: params.repo } : null;
|
|
@@ -29,11 +29,15 @@ export async function startTui(params) {
|
|
|
29
29
|
let focusPane = 'clusters';
|
|
30
30
|
const initialPreference = selectedRepository
|
|
31
31
|
? getTuiRepositoryPreference(params.service.config, currentRepository.owner, currentRepository.repo)
|
|
32
|
-
: { sortMode: 'recent', minClusterSize: 10 };
|
|
32
|
+
: { sortMode: 'recent', minClusterSize: 10, wideLayout: 'columns' };
|
|
33
33
|
let sortMode = initialPreference.sortMode;
|
|
34
34
|
let minSize = initialPreference.minClusterSize;
|
|
35
|
+
let wideLayout = initialPreference.wideLayout;
|
|
36
|
+
let showClosed = true;
|
|
35
37
|
let search = '';
|
|
36
38
|
let snapshot = null;
|
|
39
|
+
let clusterItems = ['Pick a repository with p'];
|
|
40
|
+
let clusterIndexById = new Map();
|
|
37
41
|
let clusterDetail = null;
|
|
38
42
|
let threadDetail = null;
|
|
39
43
|
let selectedClusterId = null;
|
|
@@ -52,6 +56,22 @@ export async function startTui(params) {
|
|
|
52
56
|
clusterDetailCache.clear();
|
|
53
57
|
threadDetailCache.clear();
|
|
54
58
|
};
|
|
59
|
+
const rebuildClusterItems = () => {
|
|
60
|
+
if (!snapshot) {
|
|
61
|
+
clusterItems = ['Pick a repository with p'];
|
|
62
|
+
clusterIndexById = new Map();
|
|
63
|
+
widgets.clusters.setItems(clusterItems);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
clusterIndexById = new Map();
|
|
67
|
+
clusterItems = snapshot.clusters.map((cluster, index) => {
|
|
68
|
+
clusterIndexById.set(cluster.clusterId, index);
|
|
69
|
+
const updated = formatClusterDateColumn(cluster.latestUpdatedAt);
|
|
70
|
+
const label = `${String(cluster.totalCount).padStart(3, ' ')} C${String(cluster.clusterId).padStart(5, ' ')} ${String(cluster.pullRequestCount).padStart(2, ' ')}P/${String(cluster.issueCount).padStart(2, ' ')}I ${updated} ${cluster.displayTitle}`;
|
|
71
|
+
return cluster.isClosed ? `{gray-fg}${escapeBlessedText(label)}{/gray-fg}` : escapeBlessedText(label);
|
|
72
|
+
});
|
|
73
|
+
widgets.clusters.setItems(clusterItems);
|
|
74
|
+
};
|
|
55
75
|
const pushActivity = (message) => {
|
|
56
76
|
activityLines.push(`${formatActivityTimestamp()} ${message}`);
|
|
57
77
|
if (activityLines.length > ACTIVITY_LOG_LIMIT) {
|
|
@@ -67,6 +87,7 @@ export async function startTui(params) {
|
|
|
67
87
|
owner: currentRepository.owner,
|
|
68
88
|
repo: currentRepository.repo,
|
|
69
89
|
clusterId,
|
|
90
|
+
clusterRunId: snapshot?.clusterRunId ?? undefined,
|
|
70
91
|
});
|
|
71
92
|
clusterDetailCache.set(clusterId, detail);
|
|
72
93
|
return detail;
|
|
@@ -88,6 +109,48 @@ export async function startTui(params) {
|
|
|
88
109
|
const loadSelectedThreadDetail = (includeNeighbors) => {
|
|
89
110
|
threadDetail = selectedMemberThreadId !== null ? loadThreadDetail(selectedMemberThreadId, includeNeighbors) : null;
|
|
90
111
|
};
|
|
112
|
+
const jumpToThread = (threadId, clusterId) => {
|
|
113
|
+
if (clusterId == null) {
|
|
114
|
+
status = 'Selected thread is not assigned to a cluster';
|
|
115
|
+
render();
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const selectFromSnapshot = () => {
|
|
119
|
+
const cluster = snapshot?.clusters.find((item) => item.clusterId === clusterId) ?? null;
|
|
120
|
+
if (!cluster) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
selectedClusterId = cluster.clusterId;
|
|
124
|
+
try {
|
|
125
|
+
clusterDetail = loadClusterDetail(cluster.clusterId);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
status = `Cluster ${cluster.clusterId} changed; refreshing view`;
|
|
129
|
+
refreshAll(true);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
|
|
133
|
+
selectedMemberThreadId = threadId;
|
|
134
|
+
memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
|
|
135
|
+
loadSelectedThreadDetail(false);
|
|
136
|
+
resetDetailScroll();
|
|
137
|
+
status = `Cluster ${cluster.clusterId} / #${threadDetail?.thread.number ?? '?'}`;
|
|
138
|
+
render();
|
|
139
|
+
return true;
|
|
140
|
+
};
|
|
141
|
+
if (selectFromSnapshot()) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (minSize !== 0 || search) {
|
|
145
|
+
minSize = 0;
|
|
146
|
+
search = '';
|
|
147
|
+
refreshAll(false);
|
|
148
|
+
return selectFromSnapshot();
|
|
149
|
+
}
|
|
150
|
+
status = `Cluster ${clusterId} is not available in the current view`;
|
|
151
|
+
render();
|
|
152
|
+
return false;
|
|
153
|
+
};
|
|
91
154
|
const refreshAll = (preserveSelection) => {
|
|
92
155
|
const previousClusterId = preserveSelection ? selectedClusterId : null;
|
|
93
156
|
const previousMemberId = preserveSelection ? selectedMemberThreadId : null;
|
|
@@ -98,11 +161,30 @@ export async function startTui(params) {
|
|
|
98
161
|
minSize,
|
|
99
162
|
sort: sortMode,
|
|
100
163
|
search,
|
|
164
|
+
includeClosedClusters: showClosed,
|
|
101
165
|
});
|
|
102
166
|
selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), previousClusterId);
|
|
167
|
+
rebuildClusterItems();
|
|
103
168
|
if (selectedClusterId !== null) {
|
|
104
|
-
|
|
105
|
-
|
|
169
|
+
try {
|
|
170
|
+
clusterDetail = loadClusterDetail(selectedClusterId);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
snapshot = params.service.getTuiSnapshot({
|
|
174
|
+
owner: currentRepository.owner,
|
|
175
|
+
repo: currentRepository.repo,
|
|
176
|
+
minSize,
|
|
177
|
+
sort: sortMode,
|
|
178
|
+
search,
|
|
179
|
+
includeClosedClusters: showClosed,
|
|
180
|
+
});
|
|
181
|
+
rebuildClusterItems();
|
|
182
|
+
selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), null);
|
|
183
|
+
clusterDetail = selectedClusterId !== null ? loadClusterDetail(selectedClusterId) : null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (selectedClusterId !== null && clusterDetail) {
|
|
187
|
+
memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
|
|
106
188
|
selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), previousMemberId);
|
|
107
189
|
memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
|
|
108
190
|
loadSelectedThreadDetail(false);
|
|
@@ -133,7 +215,7 @@ export async function startTui(params) {
|
|
|
133
215
|
const render = () => {
|
|
134
216
|
const width = widgets.screen.width;
|
|
135
217
|
const height = widgets.screen.height;
|
|
136
|
-
const layout = computeTuiLayout(width, height);
|
|
218
|
+
const layout = computeTuiLayout(width, height, wideLayout);
|
|
137
219
|
applyRect(widgets.header, layout.header);
|
|
138
220
|
applyRect(widgets.clusters, layout.clusters);
|
|
139
221
|
applyRect(widgets.members, layout.members);
|
|
@@ -149,15 +231,8 @@ export async function startTui(params) {
|
|
|
149
231
|
const clusterStatus = snapshot?.stats.latestClusterRunId != null
|
|
150
232
|
? `#${snapshot.stats.latestClusterRunId} ${formatRelativeTime(snapshot.stats.latestClusterRunFinishedAt ?? null)}`
|
|
151
233
|
: 'never';
|
|
152
|
-
widgets.header.setContent(`{bold}${repoLabel}{/bold} {cyan-fg}${snapshot?.stats.openPullRequestCount ?? 0} PR{/cyan-fg} {green-fg}${snapshot?.stats.openIssueCount ?? 0} issues{/green-fg} GH:${ghStatus} Emb:${embedStatus} Cl:${clusterStatus} sort:${sortMode} min:${minSize === 0 ? 'all' : `${minSize}+`} filter:${search || 'none'}`);
|
|
153
|
-
const
|
|
154
|
-
? snapshot.clusters.map((cluster) => {
|
|
155
|
-
const updated = cluster.latestUpdatedAt ? cluster.latestUpdatedAt.slice(5, 16).replace('T', ' ') : 'unknown';
|
|
156
|
-
return `${String(cluster.totalCount).padStart(3, ' ')} ${String(cluster.pullRequestCount).padStart(2, ' ')}P/${String(cluster.issueCount).padStart(2, ' ')}I ${updated} ${cluster.displayTitle}`;
|
|
157
|
-
})
|
|
158
|
-
: ['Pick a repository with p'];
|
|
159
|
-
widgets.clusters.setItems(clusterItems);
|
|
160
|
-
const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, snapshot.clusters.findIndex((cluster) => cluster.clusterId === selectedClusterId)) : 0;
|
|
234
|
+
widgets.header.setContent(`{bold}${repoLabel}{/bold} {cyan-fg}${snapshot?.stats.openPullRequestCount ?? 0} PR{/cyan-fg} {green-fg}${snapshot?.stats.openIssueCount ?? 0} issues{/green-fg} GH:${ghStatus} Emb:${embedStatus} Cl:${clusterStatus} sort:${sortMode} min:${minSize === 0 ? 'all' : `${minSize}+`} layout:${wideLayout === 'columns' ? 'cols' : 'stack'} closed:${showClosed ? 'shown' : 'hidden'} filter:${search || 'none'}`);
|
|
235
|
+
const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, clusterIndexById.get(selectedClusterId) ?? -1) : 0;
|
|
161
236
|
widgets.clusters.select(clusterIndex);
|
|
162
237
|
widgets.members.setItems(memberRows.length > 0 ? memberRows.map((row) => row.label) : ['No members']);
|
|
163
238
|
if (memberIndex >= 0) {
|
|
@@ -173,7 +248,8 @@ export async function startTui(params) {
|
|
|
173
248
|
while (footerLines.length < FOOTER_LOG_LINES) {
|
|
174
249
|
footerLines.unshift('');
|
|
175
250
|
}
|
|
176
|
-
footerLines.push(`${status} | jobs:${activeJobs} |
|
|
251
|
+
footerLines.push(`${status} | jobs:${activeJobs} | h/? help g update p repos u author / filter s sort f min l layout x closed`);
|
|
252
|
+
footerLines.push(`Tab focus arrows move-or-scroll PgUp/PgDn page r refresh o open q quit`);
|
|
177
253
|
widgets.footer.setContent(footerLines.join('\n'));
|
|
178
254
|
widgets.screen.render();
|
|
179
255
|
};
|
|
@@ -255,31 +331,54 @@ export async function startTui(params) {
|
|
|
255
331
|
render();
|
|
256
332
|
}
|
|
257
333
|
};
|
|
258
|
-
const moveSelection = (delta) => {
|
|
334
|
+
const moveSelection = (delta, options) => {
|
|
259
335
|
if (!snapshot)
|
|
260
336
|
return;
|
|
337
|
+
const steps = Math.max(1, options?.steps ?? 1);
|
|
338
|
+
const wrap = options?.wrap ?? true;
|
|
261
339
|
if (focusPane === 'clusters') {
|
|
262
340
|
if (snapshot.clusters.length === 0)
|
|
263
341
|
return;
|
|
264
342
|
const currentIndex = Math.max(0, snapshot.clusters.findIndex((cluster) => cluster.clusterId === selectedClusterId));
|
|
265
|
-
|
|
343
|
+
let nextIndex = currentIndex + delta * steps;
|
|
344
|
+
if (wrap) {
|
|
345
|
+
nextIndex = ((nextIndex % snapshot.clusters.length) + snapshot.clusters.length) % snapshot.clusters.length;
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
nextIndex = Math.max(0, Math.min(snapshot.clusters.length - 1, nextIndex));
|
|
349
|
+
}
|
|
266
350
|
selectedClusterId = snapshot.clusters[nextIndex]?.clusterId ?? null;
|
|
267
351
|
if (selectedClusterId !== null) {
|
|
268
|
-
|
|
269
|
-
|
|
352
|
+
try {
|
|
353
|
+
clusterDetail = loadClusterDetail(selectedClusterId);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
status = 'Cluster data changed; refreshing view';
|
|
357
|
+
refreshAll(true);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
|
|
270
361
|
selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), null);
|
|
271
362
|
memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
|
|
272
363
|
loadSelectedThreadDetail(false);
|
|
273
364
|
resetDetailScroll();
|
|
274
365
|
}
|
|
275
|
-
status = `Cluster ${nextIndex + 1}/${snapshot.clusters.length}`;
|
|
366
|
+
status = selectedClusterId !== null ? `Cluster ${selectedClusterId} (${nextIndex + 1}/${snapshot.clusters.length})` : `Cluster ${nextIndex + 1}/${snapshot.clusters.length}`;
|
|
276
367
|
render();
|
|
277
368
|
return;
|
|
278
369
|
}
|
|
279
370
|
if (focusPane === 'members') {
|
|
280
371
|
if (memberRows.length === 0)
|
|
281
372
|
return;
|
|
282
|
-
|
|
373
|
+
let nextIndex = memberIndex < 0 ? 0 : memberIndex;
|
|
374
|
+
for (let index = 0; index < steps; index += 1) {
|
|
375
|
+
const candidateIndex = moveSelectableIndex(memberRows, nextIndex, delta);
|
|
376
|
+
if (!wrap && candidateIndex === nextIndex) {
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
nextIndex = candidateIndex;
|
|
380
|
+
}
|
|
381
|
+
memberIndex = nextIndex;
|
|
283
382
|
selectedMemberThreadId = selectedThreadIdFromRow(memberRows, memberIndex);
|
|
284
383
|
loadSelectedThreadDetail(false);
|
|
285
384
|
resetDetailScroll();
|
|
@@ -287,6 +386,17 @@ export async function startTui(params) {
|
|
|
287
386
|
render();
|
|
288
387
|
}
|
|
289
388
|
};
|
|
389
|
+
const getFocusedListPageSize = () => {
|
|
390
|
+
const listHeight = focusPane === 'clusters' ? Number(widgets.clusters.height) : Number(widgets.members.height);
|
|
391
|
+
return Math.max(1, listHeight - 4);
|
|
392
|
+
};
|
|
393
|
+
const pageFocusedPane = (delta) => {
|
|
394
|
+
if (focusPane === 'detail') {
|
|
395
|
+
scrollDetail(delta * 12);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
moveSelection(delta, { steps: getFocusedListPageSize(), wrap: false });
|
|
399
|
+
};
|
|
290
400
|
const promptFilter = () => {
|
|
291
401
|
modalOpen = true;
|
|
292
402
|
const prompt = blessed.prompt({
|
|
@@ -325,6 +435,50 @@ export async function startTui(params) {
|
|
|
325
435
|
status = `Opened ${url}`;
|
|
326
436
|
render();
|
|
327
437
|
};
|
|
438
|
+
const promptAuthorThreads = () => {
|
|
439
|
+
if (modalOpen)
|
|
440
|
+
return;
|
|
441
|
+
const authorLogin = threadDetail?.thread.authorLogin?.trim() ?? '';
|
|
442
|
+
if (!authorLogin) {
|
|
443
|
+
status = 'Selected thread has no author login';
|
|
444
|
+
render();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
void (async () => {
|
|
448
|
+
modalOpen = true;
|
|
449
|
+
try {
|
|
450
|
+
const response = params.service.listAuthorThreads({
|
|
451
|
+
owner: currentRepository.owner,
|
|
452
|
+
repo: currentRepository.repo,
|
|
453
|
+
login: authorLogin,
|
|
454
|
+
});
|
|
455
|
+
const choice = await promptAuthorThreadChoice(widgets.screen, response.authorLogin, response.threads);
|
|
456
|
+
if (!choice) {
|
|
457
|
+
render();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
jumpToThread(choice.threadId, choice.clusterId);
|
|
461
|
+
updateFocus('members');
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
modalOpen = false;
|
|
465
|
+
}
|
|
466
|
+
})();
|
|
467
|
+
};
|
|
468
|
+
const openHelp = () => {
|
|
469
|
+
if (modalOpen)
|
|
470
|
+
return;
|
|
471
|
+
void (async () => {
|
|
472
|
+
modalOpen = true;
|
|
473
|
+
try {
|
|
474
|
+
await promptHelp(widgets.screen);
|
|
475
|
+
render();
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
modalOpen = false;
|
|
479
|
+
}
|
|
480
|
+
})();
|
|
481
|
+
};
|
|
328
482
|
const promptUpdatePipeline = () => {
|
|
329
483
|
if (modalOpen || hasActiveJobs()) {
|
|
330
484
|
if (hasActiveJobs()) {
|
|
@@ -379,17 +533,49 @@ export async function startTui(params) {
|
|
|
379
533
|
repo: currentRepository.repo,
|
|
380
534
|
minClusterSize: minSize,
|
|
381
535
|
sortMode,
|
|
536
|
+
wideLayout,
|
|
537
|
+
});
|
|
538
|
+
};
|
|
539
|
+
const withLoadingOverlay = async (message, task) => {
|
|
540
|
+
const box = blessed.box({
|
|
541
|
+
parent: widgets.screen,
|
|
542
|
+
border: 'line',
|
|
543
|
+
label: ' Loading ',
|
|
544
|
+
width: '56%',
|
|
545
|
+
height: 7,
|
|
546
|
+
top: 'center',
|
|
547
|
+
left: 'center',
|
|
548
|
+
tags: true,
|
|
549
|
+
content: `${message}\n\nThis can take a few seconds on large repos.`,
|
|
550
|
+
style: {
|
|
551
|
+
border: { fg: '#5bc0eb' },
|
|
552
|
+
fg: 'white',
|
|
553
|
+
bg: '#101522',
|
|
554
|
+
},
|
|
382
555
|
});
|
|
556
|
+
widgets.screen.render();
|
|
557
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
558
|
+
try {
|
|
559
|
+
return await task();
|
|
560
|
+
}
|
|
561
|
+
finally {
|
|
562
|
+
box.destroy();
|
|
563
|
+
widgets.screen.render();
|
|
564
|
+
}
|
|
383
565
|
};
|
|
384
566
|
const switchRepository = (target, overrides) => {
|
|
385
567
|
currentRepository = target;
|
|
386
568
|
const preference = getTuiRepositoryPreference(params.service.config, target.owner, target.repo);
|
|
387
569
|
minSize = overrides?.minClusterSize ?? preference.minClusterSize;
|
|
388
570
|
sortMode = overrides?.sortMode ?? preference.sortMode;
|
|
571
|
+
wideLayout = preference.wideLayout;
|
|
389
572
|
persistRepositoryPreference();
|
|
390
573
|
clearCaches();
|
|
391
574
|
search = '';
|
|
392
575
|
snapshot = null;
|
|
576
|
+
clusterItems = ['Pick a repository with p'];
|
|
577
|
+
clusterIndexById = new Map();
|
|
578
|
+
widgets.clusters.setItems(clusterItems);
|
|
393
579
|
clusterDetail = null;
|
|
394
580
|
threadDetail = null;
|
|
395
581
|
selectedClusterId = null;
|
|
@@ -447,7 +633,9 @@ export async function startTui(params) {
|
|
|
447
633
|
return;
|
|
448
634
|
}
|
|
449
635
|
if (choice.kind === 'existing') {
|
|
450
|
-
|
|
636
|
+
await withLoadingOverlay(`Opening ${choice.target.owner}/${choice.target.repo}...`, async () => {
|
|
637
|
+
switchRepository(choice.target);
|
|
638
|
+
});
|
|
451
639
|
pushActivity(`[repo] switched to ${choice.target.owner}/${choice.target.repo}`);
|
|
452
640
|
updateFocus('clusters');
|
|
453
641
|
return;
|
|
@@ -476,7 +664,9 @@ export async function startTui(params) {
|
|
|
476
664
|
return false;
|
|
477
665
|
}
|
|
478
666
|
if (choice.kind === 'existing') {
|
|
479
|
-
|
|
667
|
+
await withLoadingOverlay(`Opening ${choice.target.owner}/${choice.target.repo}...`, async () => {
|
|
668
|
+
switchRepository(choice.target);
|
|
669
|
+
});
|
|
480
670
|
pushActivity(`[repo] opened ${choice.target.owner}/${choice.target.repo}`);
|
|
481
671
|
updateFocus('clusters');
|
|
482
672
|
return true;
|
|
@@ -496,20 +686,25 @@ export async function startTui(params) {
|
|
|
496
686
|
modalOpen = false;
|
|
497
687
|
}
|
|
498
688
|
};
|
|
499
|
-
widgets.screen.key(['q'
|
|
689
|
+
widgets.screen.key(['q'], () => {
|
|
690
|
+
if (modalOpen)
|
|
691
|
+
return;
|
|
692
|
+
widgets.screen.destroy();
|
|
693
|
+
});
|
|
694
|
+
widgets.screen.key(['C-c'], () => {
|
|
500
695
|
widgets.screen.destroy();
|
|
501
696
|
});
|
|
502
|
-
widgets.screen.key(['tab'], () => {
|
|
697
|
+
widgets.screen.key(['tab', 'right'], () => {
|
|
503
698
|
if (modalOpen)
|
|
504
699
|
return;
|
|
505
700
|
updateFocus(cycleFocusPane(focusPane, 1));
|
|
506
701
|
});
|
|
507
|
-
widgets.screen.key(['S-tab'], () => {
|
|
702
|
+
widgets.screen.key(['S-tab', 'left'], () => {
|
|
508
703
|
if (modalOpen)
|
|
509
704
|
return;
|
|
510
705
|
updateFocus(cycleFocusPane(focusPane, -1));
|
|
511
706
|
});
|
|
512
|
-
widgets.screen.key(['
|
|
707
|
+
widgets.screen.key(['down'], () => {
|
|
513
708
|
if (modalOpen)
|
|
514
709
|
return;
|
|
515
710
|
if (focusPane === 'detail') {
|
|
@@ -518,7 +713,7 @@ export async function startTui(params) {
|
|
|
518
713
|
}
|
|
519
714
|
moveSelection(1);
|
|
520
715
|
});
|
|
521
|
-
widgets.screen.key(['
|
|
716
|
+
widgets.screen.key(['up'], () => {
|
|
522
717
|
if (modalOpen)
|
|
523
718
|
return;
|
|
524
719
|
if (focusPane === 'detail') {
|
|
@@ -530,12 +725,12 @@ export async function startTui(params) {
|
|
|
530
725
|
widgets.screen.key(['pageup'], () => {
|
|
531
726
|
if (modalOpen)
|
|
532
727
|
return;
|
|
533
|
-
|
|
728
|
+
pageFocusedPane(-1);
|
|
534
729
|
});
|
|
535
730
|
widgets.screen.key(['pagedown'], () => {
|
|
536
731
|
if (modalOpen)
|
|
537
732
|
return;
|
|
538
|
-
|
|
733
|
+
pageFocusedPane(1);
|
|
539
734
|
});
|
|
540
735
|
widgets.screen.key(['home'], () => {
|
|
541
736
|
if (modalOpen)
|
|
@@ -582,11 +777,31 @@ export async function startTui(params) {
|
|
|
582
777
|
status = `Min size: ${minSize === 0 ? 'all' : `${minSize}+`}`;
|
|
583
778
|
refreshAll(false);
|
|
584
779
|
});
|
|
780
|
+
widgets.screen.key(['l'], () => {
|
|
781
|
+
if (modalOpen)
|
|
782
|
+
return;
|
|
783
|
+
wideLayout = wideLayout === 'columns' ? 'right-stack' : 'columns';
|
|
784
|
+
persistRepositoryPreference();
|
|
785
|
+
status = `Layout: ${wideLayout === 'columns' ? 'three columns' : 'wide left + stacked right'}`;
|
|
786
|
+
render();
|
|
787
|
+
});
|
|
788
|
+
widgets.screen.key(['x'], () => {
|
|
789
|
+
if (modalOpen)
|
|
790
|
+
return;
|
|
791
|
+
showClosed = !showClosed;
|
|
792
|
+
status = showClosed ? 'Showing closed clusters and members' : 'Hiding closed clusters and members';
|
|
793
|
+
refreshAll(true);
|
|
794
|
+
});
|
|
585
795
|
widgets.screen.key(['/'], () => {
|
|
586
796
|
if (modalOpen)
|
|
587
797
|
return;
|
|
588
798
|
promptFilter();
|
|
589
799
|
});
|
|
800
|
+
widgets.screen.key(['h', '?'], () => {
|
|
801
|
+
if (modalOpen)
|
|
802
|
+
return;
|
|
803
|
+
openHelp();
|
|
804
|
+
});
|
|
590
805
|
widgets.screen.key(['p'], () => browseRepositories());
|
|
591
806
|
widgets.screen.key(['g'], () => {
|
|
592
807
|
if (modalOpen)
|
|
@@ -604,6 +819,11 @@ export async function startTui(params) {
|
|
|
604
819
|
return;
|
|
605
820
|
openSelectedThread();
|
|
606
821
|
});
|
|
822
|
+
widgets.screen.key(['u'], () => {
|
|
823
|
+
if (modalOpen)
|
|
824
|
+
return;
|
|
825
|
+
promptAuthorThreads();
|
|
826
|
+
});
|
|
607
827
|
widgets.screen.on('resize', () => render());
|
|
608
828
|
widgets.screen.on('destroy', () => {
|
|
609
829
|
widgets.screen.program.showCursor();
|
|
@@ -642,7 +862,7 @@ function createWidgets(owner, repo) {
|
|
|
642
862
|
parent: screen,
|
|
643
863
|
border: 'line',
|
|
644
864
|
label: ' Clusters ',
|
|
645
|
-
tags:
|
|
865
|
+
tags: true,
|
|
646
866
|
keys: false,
|
|
647
867
|
style: {
|
|
648
868
|
border: { fg: '#5bc0eb' },
|
|
@@ -655,7 +875,7 @@ function createWidgets(owner, repo) {
|
|
|
655
875
|
parent: screen,
|
|
656
876
|
border: 'line',
|
|
657
877
|
label: ' Members ',
|
|
658
|
-
tags:
|
|
878
|
+
tags: true,
|
|
659
879
|
keys: false,
|
|
660
880
|
style: {
|
|
661
881
|
border: { fg: '#9bc53d' },
|
|
@@ -695,10 +915,19 @@ export function renderDetailPane(threadDetail, clusterDetail, focusPane) {
|
|
|
695
915
|
return 'No cluster selected.\n\nRun `ghcrawl cluster owner/repo` if you have not clustered this repository yet.';
|
|
696
916
|
}
|
|
697
917
|
if (!threadDetail) {
|
|
698
|
-
|
|
918
|
+
const representativeLabel = clusterDetail.representativeNumber !== null && clusterDetail.representativeKind !== null
|
|
919
|
+
? ` (#${clusterDetail.representativeNumber} representative ${clusterDetail.representativeKind === 'pull_request' ? 'pr' : 'issue'})`
|
|
920
|
+
: '';
|
|
921
|
+
return `{bold}Cluster ${clusterDetail.clusterId}${escapeBlessedText(representativeLabel)}{/bold}\n${escapeBlessedText(clusterDetail.displayTitle)}\n\nSelect a member to inspect thread details.`;
|
|
699
922
|
}
|
|
700
923
|
const thread = threadDetail.thread;
|
|
924
|
+
const representativeLabel = clusterDetail.representativeNumber !== null && clusterDetail.representativeKind !== null
|
|
925
|
+
? ` (#${clusterDetail.representativeNumber} representative ${clusterDetail.representativeKind === 'pull_request' ? 'pr' : 'issue'})`
|
|
926
|
+
: '';
|
|
701
927
|
const labels = thread.labels.length > 0 ? escapeBlessedText(thread.labels.join(', ')) : 'none';
|
|
928
|
+
const closedLabel = thread.isClosed
|
|
929
|
+
? `{bold}Closed:{/bold} ${escapeBlessedText(thread.closedAtLocal ?? thread.closedAtGh ?? 'yes')} ${thread.closeReasonLocal ? `(${escapeBlessedText(thread.closeReasonLocal)})` : ''}`.trimEnd()
|
|
930
|
+
: '{bold}Closed:{/bold} no';
|
|
702
931
|
const summaries = Object.entries(threadDetail.summaries)
|
|
703
932
|
.map(([key, value]) => `{bold}${key}:{/bold}\n${escapeBlessedText(value)}`)
|
|
704
933
|
.join('\n\n');
|
|
@@ -710,9 +939,12 @@ export function renderDetailPane(threadDetail, clusterDetail, focusPane) {
|
|
|
710
939
|
? 'No neighbors available.'
|
|
711
940
|
: 'Neighbors load when the detail pane is focused.';
|
|
712
941
|
return [
|
|
942
|
+
`{bold}Cluster ${clusterDetail.clusterId}${escapeBlessedText(representativeLabel)}{/bold}`,
|
|
943
|
+
'',
|
|
713
944
|
`{bold}${thread.kind} #${thread.number}{/bold} ${escapeBlessedText(thread.title)}`,
|
|
714
945
|
'',
|
|
715
946
|
`{bold}Author:{/bold} ${escapeBlessedText(thread.authorLogin ?? 'unknown')}`,
|
|
947
|
+
closedLabel,
|
|
716
948
|
`{bold}Updated:{/bold} ${thread.updatedAtGh ?? 'unknown'}`,
|
|
717
949
|
`{bold}Labels:{/bold} ${labels}`,
|
|
718
950
|
`{bold}URL:{/bold} ${escapeBlessedText(thread.htmlUrl)}`,
|
|
@@ -789,6 +1021,123 @@ export function buildUpdatePipelineLabels(stats, selection, now = new Date()) {
|
|
|
789
1021
|
return `${mark} ${title} ${describeUpdateTask(task, stats, now)}`;
|
|
790
1022
|
});
|
|
791
1023
|
}
|
|
1024
|
+
export function buildHelpContent() {
|
|
1025
|
+
return [
|
|
1026
|
+
'{bold}ghcrawl TUI Help{/bold}',
|
|
1027
|
+
'',
|
|
1028
|
+
'{bold}Navigation{/bold}',
|
|
1029
|
+
'Tab / Shift-Tab cycle focus across clusters, members, and detail',
|
|
1030
|
+
'Left / Right cycle focus backward or forward across panes',
|
|
1031
|
+
'Up / Down move selection, or scroll detail when detail is focused',
|
|
1032
|
+
'Enter clusters -> members, members -> detail',
|
|
1033
|
+
'PgUp / PgDn page through the focused pane or this help popup faster',
|
|
1034
|
+
'Home / End jump to the top or bottom of detail or help',
|
|
1035
|
+
'',
|
|
1036
|
+
'{bold}Views And Filters{/bold}',
|
|
1037
|
+
's cycle cluster sort mode',
|
|
1038
|
+
'f cycle minimum cluster size filter',
|
|
1039
|
+
'l toggle wide layout: columns vs. wide-left stacked-right',
|
|
1040
|
+
'x show or hide locally closed clusters and members',
|
|
1041
|
+
'/ filter clusters by title/member text',
|
|
1042
|
+
'r refresh the current local view from SQLite',
|
|
1043
|
+
'',
|
|
1044
|
+
'{bold}Actions{/bold}',
|
|
1045
|
+
'g open the staged update pipeline (GitHub, embeddings, clusters)',
|
|
1046
|
+
'p open the repository browser / sync a new repository',
|
|
1047
|
+
'u show all open threads for the selected author',
|
|
1048
|
+
'o open the selected thread URL in your browser',
|
|
1049
|
+
'',
|
|
1050
|
+
'{bold}Help And Exit{/bold}',
|
|
1051
|
+
'h or ? open this help popup',
|
|
1052
|
+
'q quit the TUI (or close this popup)',
|
|
1053
|
+
'Esc close this popup',
|
|
1054
|
+
'',
|
|
1055
|
+
'{bold}Notes{/bold}',
|
|
1056
|
+
'Clusters show C<clusterId> so the cluster id is easy to copy into CLI or skill flows.',
|
|
1057
|
+
'The footer only shows the short command list. Open help to see the full list.',
|
|
1058
|
+
'This popup scrolls. Use arrows, PgUp/PgDn, Home, and End if it does not fit.',
|
|
1059
|
+
].join('\n');
|
|
1060
|
+
}
|
|
1061
|
+
async function promptHelp(screen) {
|
|
1062
|
+
const modalWidth = '86%';
|
|
1063
|
+
const box = blessed.box({
|
|
1064
|
+
parent: screen,
|
|
1065
|
+
border: 'line',
|
|
1066
|
+
label: ' Help ',
|
|
1067
|
+
tags: true,
|
|
1068
|
+
scrollable: true,
|
|
1069
|
+
alwaysScroll: true,
|
|
1070
|
+
keys: true,
|
|
1071
|
+
vi: true,
|
|
1072
|
+
mouse: false,
|
|
1073
|
+
top: 'center',
|
|
1074
|
+
left: 'center',
|
|
1075
|
+
width: modalWidth,
|
|
1076
|
+
height: '80%',
|
|
1077
|
+
padding: {
|
|
1078
|
+
left: 1,
|
|
1079
|
+
right: 1,
|
|
1080
|
+
},
|
|
1081
|
+
scrollbar: {
|
|
1082
|
+
ch: ' ',
|
|
1083
|
+
},
|
|
1084
|
+
style: {
|
|
1085
|
+
border: { fg: '#5bc0eb' },
|
|
1086
|
+
fg: 'white',
|
|
1087
|
+
bg: '#101522',
|
|
1088
|
+
scrollbar: { bg: '#5bc0eb' },
|
|
1089
|
+
},
|
|
1090
|
+
content: buildHelpContent(),
|
|
1091
|
+
});
|
|
1092
|
+
const help = blessed.box({
|
|
1093
|
+
parent: screen,
|
|
1094
|
+
width: modalWidth,
|
|
1095
|
+
height: 1,
|
|
1096
|
+
bottom: 1,
|
|
1097
|
+
left: 'center',
|
|
1098
|
+
tags: false,
|
|
1099
|
+
content: 'Scroll with arrows, PgUp/PgDn, Home, End. Press Esc, q, h, ?, or Enter to close.',
|
|
1100
|
+
style: { fg: 'black', bg: '#5bc0eb' },
|
|
1101
|
+
});
|
|
1102
|
+
box.focus();
|
|
1103
|
+
box.setScroll(0);
|
|
1104
|
+
screen.render();
|
|
1105
|
+
return await new Promise((resolve) => {
|
|
1106
|
+
const finish = () => {
|
|
1107
|
+
screen.off('keypress', handleKeypress);
|
|
1108
|
+
box.destroy();
|
|
1109
|
+
help.destroy();
|
|
1110
|
+
screen.render();
|
|
1111
|
+
resolve();
|
|
1112
|
+
};
|
|
1113
|
+
const handleKeypress = (char, key) => {
|
|
1114
|
+
if (key.name === 'escape' || key.name === 'enter' || key.name === 'q' || key.name === 'h' || char === '?') {
|
|
1115
|
+
finish();
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (key.name === 'pageup') {
|
|
1119
|
+
box.scroll(-12);
|
|
1120
|
+
screen.render();
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (key.name === 'pagedown') {
|
|
1124
|
+
box.scroll(12);
|
|
1125
|
+
screen.render();
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
if (key.name === 'home') {
|
|
1129
|
+
box.setScroll(0);
|
|
1130
|
+
screen.render();
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (key.name === 'end') {
|
|
1134
|
+
box.setScrollPerc(100);
|
|
1135
|
+
screen.render();
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
screen.on('keypress', handleKeypress);
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
792
1141
|
async function promptUpdatePipelineSelection(screen, stats) {
|
|
793
1142
|
const selection = { sync: true, embed: true, cluster: true };
|
|
794
1143
|
const modalWidth = '76%';
|
|
@@ -818,7 +1167,7 @@ async function promptUpdatePipelineSelection(screen, stats) {
|
|
|
818
1167
|
height: 4,
|
|
819
1168
|
style: { fg: 'white', bg: '#101522' },
|
|
820
1169
|
content: 'Usually you want all three. Run order is fixed: GitHub sync/reconcile -> embeddings -> clusters.\n' +
|
|
821
|
-
'Toggle with space, move with
|
|
1170
|
+
'Toggle with space, move with arrows, Enter to start, Esc to cancel.',
|
|
822
1171
|
});
|
|
823
1172
|
box.focus();
|
|
824
1173
|
box.select(0);
|
|
@@ -842,7 +1191,7 @@ async function promptUpdatePipelineSelection(screen, stats) {
|
|
|
842
1191
|
resolve(value);
|
|
843
1192
|
};
|
|
844
1193
|
const handleKeypress = (_char, key) => {
|
|
845
|
-
if (key.name === 'escape') {
|
|
1194
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
846
1195
|
finish(null);
|
|
847
1196
|
return;
|
|
848
1197
|
}
|
|
@@ -875,6 +1224,67 @@ export function getRepositoryChoices(service, now = new Date()) {
|
|
|
875
1224
|
{ kind: 'new', label: '+ Sync a new repository' },
|
|
876
1225
|
];
|
|
877
1226
|
}
|
|
1227
|
+
async function promptAuthorThreadChoice(screen, authorLogin, threads) {
|
|
1228
|
+
const choices = threads.map((item) => {
|
|
1229
|
+
const match = item.strongestSameAuthorMatch;
|
|
1230
|
+
const matchLabel = match ? ` sim:${(match.score * 100).toFixed(1)}% -> #${match.number}` : ' sim:none';
|
|
1231
|
+
const clusterLabel = item.thread.clusterId ? `C${item.thread.clusterId}` : 'C-';
|
|
1232
|
+
return {
|
|
1233
|
+
threadId: item.thread.id,
|
|
1234
|
+
clusterId: item.thread.clusterId,
|
|
1235
|
+
label: `#${item.thread.number} ${item.thread.kind === 'pull_request' ? 'pr' : 'issue'} ${clusterLabel}${matchLabel} ${item.thread.title}`,
|
|
1236
|
+
};
|
|
1237
|
+
});
|
|
1238
|
+
const box = blessed.list({
|
|
1239
|
+
parent: screen,
|
|
1240
|
+
border: 'line',
|
|
1241
|
+
label: ` @${authorLogin} Threads `,
|
|
1242
|
+
keys: true,
|
|
1243
|
+
vi: true,
|
|
1244
|
+
mouse: false,
|
|
1245
|
+
top: 'center',
|
|
1246
|
+
left: 'center',
|
|
1247
|
+
width: '80%',
|
|
1248
|
+
height: '70%',
|
|
1249
|
+
style: {
|
|
1250
|
+
border: { fg: '#fde74c' },
|
|
1251
|
+
item: { fg: 'white' },
|
|
1252
|
+
selected: { bg: '#fde74c', fg: 'black', bold: true },
|
|
1253
|
+
},
|
|
1254
|
+
items: choices.length > 0 ? choices.map((choice) => choice.label) : ['No open threads for this author'],
|
|
1255
|
+
});
|
|
1256
|
+
const help = blessed.box({
|
|
1257
|
+
parent: screen,
|
|
1258
|
+
bottom: 0,
|
|
1259
|
+
left: 0,
|
|
1260
|
+
width: '100%',
|
|
1261
|
+
height: 1,
|
|
1262
|
+
content: 'Enter jumps to the selected thread. Esc cancels.',
|
|
1263
|
+
style: { fg: 'black', bg: '#fde74c' },
|
|
1264
|
+
});
|
|
1265
|
+
box.focus();
|
|
1266
|
+
box.select(0);
|
|
1267
|
+
screen.render();
|
|
1268
|
+
return await new Promise((resolve) => {
|
|
1269
|
+
const teardown = () => {
|
|
1270
|
+
screen.off('keypress', handleKeypress);
|
|
1271
|
+
box.destroy();
|
|
1272
|
+
help.destroy();
|
|
1273
|
+
screen.render();
|
|
1274
|
+
};
|
|
1275
|
+
const finish = (value) => {
|
|
1276
|
+
teardown();
|
|
1277
|
+
resolve(value);
|
|
1278
|
+
};
|
|
1279
|
+
const handleKeypress = (_char, key) => {
|
|
1280
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
1281
|
+
finish(null);
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
screen.on('keypress', handleKeypress);
|
|
1285
|
+
box.on('select', (_item, index) => finish(choices[index] ?? null));
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
878
1288
|
async function promptRepositoryChoice(screen, service) {
|
|
879
1289
|
const choices = getRepositoryChoices(service);
|
|
880
1290
|
const box = blessed.list({
|
|
@@ -919,7 +1329,7 @@ async function promptRepositoryChoice(screen, service) {
|
|
|
919
1329
|
resolve(value);
|
|
920
1330
|
};
|
|
921
1331
|
const handleKeypress = (_char, key) => {
|
|
922
|
-
if (key.name === 'escape') {
|
|
1332
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
923
1333
|
finish(null);
|
|
924
1334
|
return;
|
|
925
1335
|
}
|
|
@@ -989,6 +1399,7 @@ async function runColdStartSetup(service, screen, target, log, footer) {
|
|
|
989
1399
|
repo: target.repo,
|
|
990
1400
|
minClusterSize: 1,
|
|
991
1401
|
sortMode: 'recent',
|
|
1402
|
+
wideLayout: 'columns',
|
|
992
1403
|
});
|
|
993
1404
|
log?.log('[setup] initial setup complete');
|
|
994
1405
|
return true;
|
|
@@ -1014,6 +1425,26 @@ function parseDateOrNull(value) {
|
|
|
1014
1425
|
const parsed = Date.parse(value);
|
|
1015
1426
|
return Number.isNaN(parsed) ? null : parsed;
|
|
1016
1427
|
}
|
|
1428
|
+
export function formatClusterDateColumn(value, locales) {
|
|
1429
|
+
if (!value)
|
|
1430
|
+
return 'unknown';
|
|
1431
|
+
const parsed = new Date(value);
|
|
1432
|
+
if (Number.isNaN(parsed.getTime()))
|
|
1433
|
+
return value;
|
|
1434
|
+
const month = String(parsed.getMonth() + 1).padStart(2, '0');
|
|
1435
|
+
const day = String(parsed.getDate()).padStart(2, '0');
|
|
1436
|
+
const hour = String(parsed.getHours()).padStart(2, '0');
|
|
1437
|
+
const minute = String(parsed.getMinutes()).padStart(2, '0');
|
|
1438
|
+
const ordering = new Intl.DateTimeFormat(locales, {
|
|
1439
|
+
month: '2-digit',
|
|
1440
|
+
day: '2-digit',
|
|
1441
|
+
})
|
|
1442
|
+
.formatToParts(parsed)
|
|
1443
|
+
.filter((part) => part.type === 'month' || part.type === 'day')
|
|
1444
|
+
.map((part) => part.type);
|
|
1445
|
+
const date = ordering[0] === 'day' ? `${day}-${month}` : `${month}-${day}`;
|
|
1446
|
+
return `${date} ${hour}:${minute}`;
|
|
1447
|
+
}
|
|
1017
1448
|
function formatAge(diffMs) {
|
|
1018
1449
|
const safeDiffMs = Math.max(0, diffMs);
|
|
1019
1450
|
const minuteMs = 60_000;
|