ghcrawl 0.4.0 → 0.6.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 +2 -2
- 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.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/tui/app.d.ts +1 -0
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +422 -157
- package/dist/tui/app.js.map +1 -1
- package/package.json +3 -3
package/dist/tui/app.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
2
6
|
import blessed from 'neo-blessed';
|
|
3
7
|
import { getTuiRepositoryPreference, writeTuiRepositoryPreference } from '@ghcrawl/api-core';
|
|
4
8
|
import { buildMemberRows, cycleFocusPane, cycleMinSizeFilter, cycleSortMode, findSelectableIndex, moveSelectableIndex, preserveSelectedId, selectedThreadIdFromRow, } from './state.js';
|
|
@@ -22,6 +26,30 @@ function createScreen(options) {
|
|
|
22
26
|
const ACTIVITY_LOG_LIMIT = 200;
|
|
23
27
|
const FOOTER_LOG_LINES = 3;
|
|
24
28
|
const UPDATE_TASK_ORDER = ['sync', 'embed', 'cluster'];
|
|
29
|
+
export function buildRefreshCliArgs(target, selection) {
|
|
30
|
+
const args = ['refresh', `${target.owner}/${target.repo}`];
|
|
31
|
+
if (!selection.sync)
|
|
32
|
+
args.push('--no-sync');
|
|
33
|
+
if (!selection.embed)
|
|
34
|
+
args.push('--no-embed');
|
|
35
|
+
if (!selection.cluster)
|
|
36
|
+
args.push('--no-cluster');
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
function createCliLaunch(args) {
|
|
40
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const distEntrypoint = path.resolve(here, '..', 'main.js');
|
|
42
|
+
if (existsSync(distEntrypoint)) {
|
|
43
|
+
return { command: process.execPath, args: [distEntrypoint, ...args] };
|
|
44
|
+
}
|
|
45
|
+
const sourceEntrypoint = path.resolve(here, '..', 'main.ts');
|
|
46
|
+
const require = createRequire(import.meta.url);
|
|
47
|
+
const tsxLoader = require.resolve('tsx');
|
|
48
|
+
return {
|
|
49
|
+
command: process.execPath,
|
|
50
|
+
args: ['--conditions=development', '--import', tsxLoader, sourceEntrypoint, ...args],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
25
53
|
export async function startTui(params) {
|
|
26
54
|
const selectedRepository = params.owner && params.repo ? { owner: params.owner, repo: params.repo } : null;
|
|
27
55
|
let currentRepository = selectedRepository ?? { owner: '', repo: '' };
|
|
@@ -36,6 +64,8 @@ export async function startTui(params) {
|
|
|
36
64
|
let showClosed = true;
|
|
37
65
|
let search = '';
|
|
38
66
|
let snapshot = null;
|
|
67
|
+
let clusterItems = ['Pick a repository with p'];
|
|
68
|
+
let clusterIndexById = new Map();
|
|
39
69
|
let clusterDetail = null;
|
|
40
70
|
let threadDetail = null;
|
|
41
71
|
let selectedClusterId = null;
|
|
@@ -49,18 +79,41 @@ export async function startTui(params) {
|
|
|
49
79
|
let syncJobRunning = false;
|
|
50
80
|
let embedJobRunning = false;
|
|
51
81
|
let clusterJobRunning = false;
|
|
82
|
+
let activeJob = null;
|
|
52
83
|
let modalOpen = false;
|
|
84
|
+
let exitRequested = false;
|
|
53
85
|
const clearCaches = () => {
|
|
54
86
|
clusterDetailCache.clear();
|
|
55
87
|
threadDetailCache.clear();
|
|
56
88
|
};
|
|
57
|
-
const
|
|
58
|
-
|
|
89
|
+
const rebuildClusterItems = () => {
|
|
90
|
+
if (!snapshot) {
|
|
91
|
+
clusterItems = ['Pick a repository with p'];
|
|
92
|
+
clusterIndexById = new Map();
|
|
93
|
+
widgets.clusters.setItems(clusterItems);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
clusterIndexById = new Map();
|
|
97
|
+
clusterItems = snapshot.clusters.map((cluster, index) => {
|
|
98
|
+
clusterIndexById.set(cluster.clusterId, index);
|
|
99
|
+
const updated = formatClusterDateColumn(cluster.latestUpdatedAt);
|
|
100
|
+
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}`;
|
|
101
|
+
return cluster.isClosed ? `{gray-fg}${escapeBlessedText(label)}{/gray-fg}` : escapeBlessedText(label);
|
|
102
|
+
});
|
|
103
|
+
widgets.clusters.setItems(clusterItems);
|
|
104
|
+
};
|
|
105
|
+
const pushActivity = (message, options) => {
|
|
106
|
+
activityLines.push(options?.raw === true ? message : `${formatActivityTimestamp()} ${message}`);
|
|
59
107
|
if (activityLines.length > ACTIVITY_LOG_LIMIT) {
|
|
60
108
|
activityLines.splice(0, activityLines.length - ACTIVITY_LOG_LIMIT);
|
|
61
109
|
}
|
|
62
110
|
render();
|
|
63
111
|
};
|
|
112
|
+
const setActiveJobFlags = (selection) => {
|
|
113
|
+
syncJobRunning = selection?.sync === true;
|
|
114
|
+
embedJobRunning = selection?.embed === true;
|
|
115
|
+
clusterJobRunning = selection?.cluster === true;
|
|
116
|
+
};
|
|
64
117
|
const loadClusterDetail = (clusterId) => {
|
|
65
118
|
const cached = clusterDetailCache.get(clusterId);
|
|
66
119
|
if (cached)
|
|
@@ -69,6 +122,7 @@ export async function startTui(params) {
|
|
|
69
122
|
owner: currentRepository.owner,
|
|
70
123
|
repo: currentRepository.repo,
|
|
71
124
|
clusterId,
|
|
125
|
+
clusterRunId: snapshot?.clusterRunId ?? undefined,
|
|
72
126
|
});
|
|
73
127
|
clusterDetailCache.set(clusterId, detail);
|
|
74
128
|
return detail;
|
|
@@ -102,7 +156,14 @@ export async function startTui(params) {
|
|
|
102
156
|
return false;
|
|
103
157
|
}
|
|
104
158
|
selectedClusterId = cluster.clusterId;
|
|
105
|
-
|
|
159
|
+
try {
|
|
160
|
+
clusterDetail = loadClusterDetail(cluster.clusterId);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
status = `Cluster ${cluster.clusterId} changed; refreshing view`;
|
|
164
|
+
refreshAll(true);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
106
167
|
memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
|
|
107
168
|
selectedMemberThreadId = threadId;
|
|
108
169
|
memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
|
|
@@ -138,8 +199,26 @@ export async function startTui(params) {
|
|
|
138
199
|
includeClosedClusters: showClosed,
|
|
139
200
|
});
|
|
140
201
|
selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), previousClusterId);
|
|
202
|
+
rebuildClusterItems();
|
|
141
203
|
if (selectedClusterId !== null) {
|
|
142
|
-
|
|
204
|
+
try {
|
|
205
|
+
clusterDetail = loadClusterDetail(selectedClusterId);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
snapshot = params.service.getTuiSnapshot({
|
|
209
|
+
owner: currentRepository.owner,
|
|
210
|
+
repo: currentRepository.repo,
|
|
211
|
+
minSize,
|
|
212
|
+
sort: sortMode,
|
|
213
|
+
search,
|
|
214
|
+
includeClosedClusters: showClosed,
|
|
215
|
+
});
|
|
216
|
+
rebuildClusterItems();
|
|
217
|
+
selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), null);
|
|
218
|
+
clusterDetail = selectedClusterId !== null ? loadClusterDetail(selectedClusterId) : null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (selectedClusterId !== null && clusterDetail) {
|
|
143
222
|
memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
|
|
144
223
|
selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), previousMemberId);
|
|
145
224
|
memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
|
|
@@ -188,15 +267,7 @@ export async function startTui(params) {
|
|
|
188
267
|
? `#${snapshot.stats.latestClusterRunId} ${formatRelativeTime(snapshot.stats.latestClusterRunFinishedAt ?? null)}`
|
|
189
268
|
: 'never';
|
|
190
269
|
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'}`);
|
|
191
|
-
const
|
|
192
|
-
? snapshot.clusters.map((cluster) => {
|
|
193
|
-
const updated = formatClusterDateColumn(cluster.latestUpdatedAt);
|
|
194
|
-
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}`;
|
|
195
|
-
return cluster.isClosed ? `{gray-fg}${escapeBlessedText(label)}{/gray-fg}` : escapeBlessedText(label);
|
|
196
|
-
})
|
|
197
|
-
: ['Pick a repository with p'];
|
|
198
|
-
widgets.clusters.setItems(clusterItems);
|
|
199
|
-
const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, snapshot.clusters.findIndex((cluster) => cluster.clusterId === selectedClusterId)) : 0;
|
|
270
|
+
const clusterIndex = snapshot && selectedClusterId !== null ? Math.max(0, clusterIndexById.get(selectedClusterId) ?? -1) : 0;
|
|
200
271
|
widgets.clusters.select(clusterIndex);
|
|
201
272
|
widgets.members.setItems(memberRows.length > 0 ? memberRows.map((row) => row.label) : ['No members']);
|
|
202
273
|
if (memberIndex >= 0) {
|
|
@@ -212,8 +283,8 @@ export async function startTui(params) {
|
|
|
212
283
|
while (footerLines.length < FOOTER_LOG_LINES) {
|
|
213
284
|
footerLines.unshift('');
|
|
214
285
|
}
|
|
215
|
-
footerLines.push(`${status} | jobs:${activeJobs} | h/? help g update p repos u author / filter s sort f min l layout x closed`);
|
|
216
|
-
footerLines.push(`Tab focus
|
|
286
|
+
footerLines.push(`${status} | jobs:${activeJobs} | h/? help # jump g update p repos u author / filter s sort f min l layout x closed`);
|
|
287
|
+
footerLines.push(`Tab focus arrows move-or-scroll PgUp/PgDn page r refresh o open q quit`);
|
|
217
288
|
widgets.footer.setContent(footerLines.join('\n'));
|
|
218
289
|
widgets.screen.render();
|
|
219
290
|
};
|
|
@@ -226,86 +297,148 @@ export async function startTui(params) {
|
|
|
226
297
|
widgets.detail.scroll(offset);
|
|
227
298
|
widgets.screen.render();
|
|
228
299
|
};
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
render();
|
|
250
|
-
}
|
|
300
|
+
const consumeStreamLines = (stream, onLine) => {
|
|
301
|
+
let buffer = '';
|
|
302
|
+
stream.setEncoding('utf8');
|
|
303
|
+
stream.on('data', (chunk) => {
|
|
304
|
+
buffer += chunk;
|
|
305
|
+
while (true) {
|
|
306
|
+
const newlineIndex = buffer.indexOf('\n');
|
|
307
|
+
if (newlineIndex === -1)
|
|
308
|
+
break;
|
|
309
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, '').trimEnd();
|
|
310
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
311
|
+
if (line.length > 0)
|
|
312
|
+
onLine(line);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
stream.on('end', () => {
|
|
316
|
+
const line = buffer.replace(/\r$/, '').trim();
|
|
317
|
+
if (line.length > 0)
|
|
318
|
+
onLine(line);
|
|
319
|
+
});
|
|
251
320
|
};
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
repo:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
321
|
+
const finalizeBackgroundJob = (job) => {
|
|
322
|
+
void (async () => {
|
|
323
|
+
const result = await job.exitPromise;
|
|
324
|
+
if (activeJob === job) {
|
|
325
|
+
activeJob = null;
|
|
326
|
+
}
|
|
327
|
+
setActiveJobFlags(null);
|
|
328
|
+
if (job.terminatedByUser) {
|
|
329
|
+
pushActivity(`[jobs] update pipeline terminated for ${job.repo.owner}/${job.repo.repo}`);
|
|
330
|
+
}
|
|
331
|
+
else if (result.error) {
|
|
332
|
+
pushActivity(`[jobs] update pipeline failed for ${job.repo.owner}/${job.repo.repo}: ${result.error.message}`);
|
|
333
|
+
}
|
|
334
|
+
else if (result.code === 0) {
|
|
335
|
+
pushActivity(`[jobs] update pipeline complete for ${job.repo.owner}/${job.repo.repo}`);
|
|
336
|
+
try {
|
|
337
|
+
const parsed = JSON.parse(result.stdout.trim());
|
|
338
|
+
const summaryParts = [
|
|
339
|
+
parsed.sync ? `sync:${parsed.sync.threadsSynced ?? 0} threads` : null,
|
|
340
|
+
parsed.sync ? `closed:${parsed.sync.threadsClosed ?? 0}` : null,
|
|
341
|
+
parsed.embed ? `embed:${parsed.embed.embedded ?? 0}` : null,
|
|
342
|
+
parsed.cluster ? `cluster:${parsed.cluster.clusters ?? 0}` : null,
|
|
343
|
+
parsed.cluster ? `edges:${parsed.cluster.edges ?? 0}` : null,
|
|
344
|
+
].filter((value) => value !== null);
|
|
345
|
+
if (summaryParts.length > 0) {
|
|
346
|
+
pushActivity(`[jobs] result ${summaryParts.join(' ')}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// Ignore malformed stdout; progress is already visible in the activity log.
|
|
351
|
+
}
|
|
352
|
+
if (currentRepository.owner === job.repo.owner && currentRepository.repo === job.repo.repo) {
|
|
353
|
+
refreshAll(true);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const exitSuffix = result.signal !== null ? `signal=${result.signal}` : `code=${result.code ?? 1}`;
|
|
358
|
+
pushActivity(`[jobs] update pipeline failed for ${job.repo.owner}/${job.repo.repo}: exited ${exitSuffix}`);
|
|
359
|
+
}
|
|
271
360
|
status = 'Ready';
|
|
272
|
-
|
|
273
|
-
|
|
361
|
+
if (!exitRequested) {
|
|
362
|
+
render();
|
|
363
|
+
}
|
|
364
|
+
})();
|
|
274
365
|
};
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
clusterJobRunning = true;
|
|
280
|
-
status = 'Running cluster refresh';
|
|
281
|
-
pushActivity('[jobs] starting cluster refresh');
|
|
282
|
-
render();
|
|
283
|
-
try {
|
|
284
|
-
const result = params.service.clusterRepository({
|
|
285
|
-
owner: currentRepository.owner,
|
|
286
|
-
repo: currentRepository.repo,
|
|
287
|
-
onProgress: pushActivity,
|
|
288
|
-
});
|
|
289
|
-
pushActivity(`[jobs] cluster refresh complete clusters=${result.clusters} edges=${result.edges}`);
|
|
290
|
-
refreshAll(true);
|
|
366
|
+
const startBackgroundUpdatePipeline = (target, selection) => {
|
|
367
|
+
if (activeJob !== null) {
|
|
368
|
+
pushActivity('[jobs] another update pipeline is already running');
|
|
369
|
+
return false;
|
|
291
370
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
render();
|
|
371
|
+
if (!selection.sync && !selection.embed && !selection.cluster) {
|
|
372
|
+
pushActivity('[jobs] select at least one update step');
|
|
373
|
+
return false;
|
|
296
374
|
}
|
|
375
|
+
const cliArgs = buildRefreshCliArgs(target, selection);
|
|
376
|
+
const launch = createCliLaunch(cliArgs);
|
|
377
|
+
const child = spawn(launch.command, launch.args, {
|
|
378
|
+
env: process.env,
|
|
379
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
380
|
+
});
|
|
381
|
+
const job = {
|
|
382
|
+
child,
|
|
383
|
+
repo: target,
|
|
384
|
+
selection,
|
|
385
|
+
stdoutBuffer: '',
|
|
386
|
+
terminatedByUser: false,
|
|
387
|
+
exitPromise: new Promise((resolve) => {
|
|
388
|
+
let resolved = false;
|
|
389
|
+
const finish = (result) => {
|
|
390
|
+
if (resolved)
|
|
391
|
+
return;
|
|
392
|
+
resolved = true;
|
|
393
|
+
resolve(result);
|
|
394
|
+
};
|
|
395
|
+
child.on('error', (error) => {
|
|
396
|
+
finish({ code: null, signal: null, stdout: job.stdoutBuffer, error });
|
|
397
|
+
});
|
|
398
|
+
child.on('close', (code, signal) => {
|
|
399
|
+
finish({ code, signal, stdout: job.stdoutBuffer, error: null });
|
|
400
|
+
});
|
|
401
|
+
}),
|
|
402
|
+
};
|
|
403
|
+
child.stdout.setEncoding('utf8');
|
|
404
|
+
child.stdout.on('data', (chunk) => {
|
|
405
|
+
job.stdoutBuffer += chunk;
|
|
406
|
+
});
|
|
407
|
+
consumeStreamLines(child.stderr, (line) => pushActivity(line, { raw: true }));
|
|
408
|
+
activeJob = job;
|
|
409
|
+
setActiveJobFlags(selection);
|
|
410
|
+
status = `Running update pipeline for ${target.owner}/${target.repo}`;
|
|
411
|
+
pushActivity(`[jobs] starting update pipeline for ${target.owner}/${target.repo}: ${UPDATE_TASK_ORDER.filter((task) => selection[task]).join(' -> ')}`);
|
|
412
|
+
render();
|
|
413
|
+
finalizeBackgroundJob(job);
|
|
414
|
+
return true;
|
|
297
415
|
};
|
|
298
|
-
const moveSelection = (delta) => {
|
|
416
|
+
const moveSelection = (delta, options) => {
|
|
299
417
|
if (!snapshot)
|
|
300
418
|
return;
|
|
419
|
+
const steps = Math.max(1, options?.steps ?? 1);
|
|
420
|
+
const wrap = options?.wrap ?? true;
|
|
301
421
|
if (focusPane === 'clusters') {
|
|
302
422
|
if (snapshot.clusters.length === 0)
|
|
303
423
|
return;
|
|
304
|
-
const currentIndex = Math.max(0,
|
|
305
|
-
|
|
424
|
+
const currentIndex = Math.max(0, selectedClusterId === null ? -1 : (clusterIndexById.get(selectedClusterId) ?? -1));
|
|
425
|
+
let nextIndex = currentIndex + delta * steps;
|
|
426
|
+
if (wrap) {
|
|
427
|
+
nextIndex = ((nextIndex % snapshot.clusters.length) + snapshot.clusters.length) % snapshot.clusters.length;
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
nextIndex = Math.max(0, Math.min(snapshot.clusters.length - 1, nextIndex));
|
|
431
|
+
}
|
|
306
432
|
selectedClusterId = snapshot.clusters[nextIndex]?.clusterId ?? null;
|
|
307
433
|
if (selectedClusterId !== null) {
|
|
308
|
-
|
|
434
|
+
try {
|
|
435
|
+
clusterDetail = loadClusterDetail(selectedClusterId);
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
status = 'Cluster data changed; refreshing view';
|
|
439
|
+
refreshAll(true);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
309
442
|
memberRows = buildMemberRows(clusterDetail, { includeClosedMembers: showClosed });
|
|
310
443
|
selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), null);
|
|
311
444
|
memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
|
|
@@ -319,7 +452,15 @@ export async function startTui(params) {
|
|
|
319
452
|
if (focusPane === 'members') {
|
|
320
453
|
if (memberRows.length === 0)
|
|
321
454
|
return;
|
|
322
|
-
|
|
455
|
+
let nextIndex = memberIndex < 0 ? 0 : memberIndex;
|
|
456
|
+
for (let index = 0; index < steps; index += 1) {
|
|
457
|
+
const candidateIndex = moveSelectableIndex(memberRows, nextIndex, delta);
|
|
458
|
+
if (!wrap && candidateIndex === nextIndex) {
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
nextIndex = candidateIndex;
|
|
462
|
+
}
|
|
463
|
+
memberIndex = nextIndex;
|
|
323
464
|
selectedMemberThreadId = selectedThreadIdFromRow(memberRows, memberIndex);
|
|
324
465
|
loadSelectedThreadDetail(false);
|
|
325
466
|
resetDetailScroll();
|
|
@@ -327,6 +468,17 @@ export async function startTui(params) {
|
|
|
327
468
|
render();
|
|
328
469
|
}
|
|
329
470
|
};
|
|
471
|
+
const getFocusedListPageSize = () => {
|
|
472
|
+
const listHeight = focusPane === 'clusters' ? Number(widgets.clusters.height) : Number(widgets.members.height);
|
|
473
|
+
return Math.max(1, listHeight - 4);
|
|
474
|
+
};
|
|
475
|
+
const pageFocusedPane = (delta) => {
|
|
476
|
+
if (focusPane === 'detail') {
|
|
477
|
+
scrollDetail(delta * 12);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
moveSelection(delta, { steps: getFocusedListPageSize(), wrap: false });
|
|
481
|
+
};
|
|
330
482
|
const promptFilter = () => {
|
|
331
483
|
modalOpen = true;
|
|
332
484
|
const prompt = blessed.prompt({
|
|
@@ -354,6 +506,56 @@ export async function startTui(params) {
|
|
|
354
506
|
updateFocus('clusters');
|
|
355
507
|
});
|
|
356
508
|
};
|
|
509
|
+
const promptThreadJump = () => {
|
|
510
|
+
if (modalOpen)
|
|
511
|
+
return;
|
|
512
|
+
modalOpen = true;
|
|
513
|
+
const prompt = blessed.prompt({
|
|
514
|
+
parent: widgets.screen,
|
|
515
|
+
border: 'line',
|
|
516
|
+
height: 7,
|
|
517
|
+
width: '60%',
|
|
518
|
+
top: 'center',
|
|
519
|
+
left: 'center',
|
|
520
|
+
label: ' Jump To Issue/PR ',
|
|
521
|
+
tags: true,
|
|
522
|
+
keys: true,
|
|
523
|
+
vi: true,
|
|
524
|
+
style: {
|
|
525
|
+
border: { fg: '#fde74c' },
|
|
526
|
+
bg: '#101522',
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
prompt.input('Issue or PR number', '', (_error, value) => {
|
|
530
|
+
prompt.destroy();
|
|
531
|
+
modalOpen = false;
|
|
532
|
+
const parsed = Number((value ?? '').trim());
|
|
533
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
534
|
+
status = 'Enter a positive issue or PR number';
|
|
535
|
+
render();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
const detail = params.service.getTuiThreadDetail({
|
|
540
|
+
owner: currentRepository.owner,
|
|
541
|
+
repo: currentRepository.repo,
|
|
542
|
+
threadNumber: parsed,
|
|
543
|
+
includeNeighbors: false,
|
|
544
|
+
});
|
|
545
|
+
const jumped = jumpToThread(detail.thread.id, detail.thread.clusterId ?? null);
|
|
546
|
+
if (jumped) {
|
|
547
|
+
status = `Jumped to #${detail.thread.number} in cluster ${detail.thread.clusterId ?? '?'}`;
|
|
548
|
+
updateFocus('members');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
render();
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
status = error instanceof Error ? error.message : `Thread #${parsed} was not found`;
|
|
555
|
+
render();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
};
|
|
357
559
|
const openSelectedThread = () => {
|
|
358
560
|
const url = threadDetail?.thread.htmlUrl;
|
|
359
561
|
if (!url) {
|
|
@@ -409,6 +611,76 @@ export async function startTui(params) {
|
|
|
409
611
|
}
|
|
410
612
|
})();
|
|
411
613
|
};
|
|
614
|
+
const promptConfirm = async (label, message) => {
|
|
615
|
+
const box = blessed.box({
|
|
616
|
+
parent: widgets.screen,
|
|
617
|
+
border: 'line',
|
|
618
|
+
label: ` ${label} `,
|
|
619
|
+
tags: true,
|
|
620
|
+
top: 'center',
|
|
621
|
+
left: 'center',
|
|
622
|
+
width: '68%',
|
|
623
|
+
height: 9,
|
|
624
|
+
padding: {
|
|
625
|
+
left: 1,
|
|
626
|
+
right: 1,
|
|
627
|
+
},
|
|
628
|
+
style: {
|
|
629
|
+
border: { fg: '#fde74c' },
|
|
630
|
+
fg: 'white',
|
|
631
|
+
bg: '#101522',
|
|
632
|
+
},
|
|
633
|
+
content: `${message}\n\nPress y or Enter to confirm. Press n or Esc to cancel.`,
|
|
634
|
+
});
|
|
635
|
+
widgets.screen.render();
|
|
636
|
+
return await new Promise((resolve) => {
|
|
637
|
+
const finish = (value) => {
|
|
638
|
+
widgets.screen.off('keypress', handleKeypress);
|
|
639
|
+
box.destroy();
|
|
640
|
+
widgets.screen.render();
|
|
641
|
+
resolve(value);
|
|
642
|
+
};
|
|
643
|
+
const handleKeypress = (char, key) => {
|
|
644
|
+
if (key.name === 'enter' || char.toLowerCase() === 'y') {
|
|
645
|
+
finish(true);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (key.name === 'escape' || char.toLowerCase() === 'n' || key.name === 'q') {
|
|
649
|
+
finish(false);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
widgets.screen.on('keypress', handleKeypress);
|
|
653
|
+
});
|
|
654
|
+
};
|
|
655
|
+
const requestQuit = () => {
|
|
656
|
+
if (modalOpen)
|
|
657
|
+
return;
|
|
658
|
+
void (async () => {
|
|
659
|
+
if (activeJob === null) {
|
|
660
|
+
widgets.screen.destroy();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
modalOpen = true;
|
|
664
|
+
try {
|
|
665
|
+
const confirmed = await promptConfirm('Stop Update Pipeline', `A background update pipeline is still running for ${activeJob.repo.owner}/${activeJob.repo.repo}.\nQuitting now will send SIGTERM to that refresh process and wait for it to exit.`);
|
|
666
|
+
if (!confirmed) {
|
|
667
|
+
render();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
exitRequested = true;
|
|
671
|
+
status = 'Stopping background update pipeline';
|
|
672
|
+
pushActivity(`[jobs] stopping update pipeline for ${activeJob.repo.owner}/${activeJob.repo.repo}`);
|
|
673
|
+
render();
|
|
674
|
+
activeJob.terminatedByUser = true;
|
|
675
|
+
activeJob.child.kill('SIGTERM');
|
|
676
|
+
await activeJob.exitPromise;
|
|
677
|
+
widgets.screen.destroy();
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
modalOpen = false;
|
|
681
|
+
}
|
|
682
|
+
})();
|
|
683
|
+
};
|
|
412
684
|
const promptUpdatePipeline = () => {
|
|
413
685
|
if (modalOpen || hasActiveJobs()) {
|
|
414
686
|
if (hasActiveJobs()) {
|
|
@@ -426,7 +698,7 @@ export async function startTui(params) {
|
|
|
426
698
|
}
|
|
427
699
|
const selectedTasks = UPDATE_TASK_ORDER.filter((task) => selection[task]).join(' -> ');
|
|
428
700
|
pushActivity(`[jobs] queued update pipeline: ${selectedTasks}`);
|
|
429
|
-
|
|
701
|
+
startBackgroundUpdatePipeline(currentRepository, selection);
|
|
430
702
|
updateFocus('clusters');
|
|
431
703
|
}
|
|
432
704
|
finally {
|
|
@@ -434,29 +706,7 @@ export async function startTui(params) {
|
|
|
434
706
|
}
|
|
435
707
|
})();
|
|
436
708
|
};
|
|
437
|
-
const hasActiveJobs = () =>
|
|
438
|
-
const runUpdatePipeline = async (selection) => {
|
|
439
|
-
if (hasActiveJobs()) {
|
|
440
|
-
pushActivity('[jobs] another update pipeline is already running');
|
|
441
|
-
return false;
|
|
442
|
-
}
|
|
443
|
-
try {
|
|
444
|
-
if (selection.sync) {
|
|
445
|
-
await runSyncStep();
|
|
446
|
-
}
|
|
447
|
-
if (selection.embed) {
|
|
448
|
-
await runEmbedStep();
|
|
449
|
-
}
|
|
450
|
-
if (selection.cluster) {
|
|
451
|
-
await runClusterStep();
|
|
452
|
-
}
|
|
453
|
-
return true;
|
|
454
|
-
}
|
|
455
|
-
catch (error) {
|
|
456
|
-
pushActivity(`[jobs] update pipeline failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
457
|
-
return false;
|
|
458
|
-
}
|
|
459
|
-
};
|
|
709
|
+
const hasActiveJobs = () => activeJob !== null;
|
|
460
710
|
const persistRepositoryPreference = () => {
|
|
461
711
|
writeTuiRepositoryPreference(params.service.config, {
|
|
462
712
|
owner: currentRepository.owner,
|
|
@@ -466,6 +716,33 @@ export async function startTui(params) {
|
|
|
466
716
|
wideLayout,
|
|
467
717
|
});
|
|
468
718
|
};
|
|
719
|
+
const withLoadingOverlay = async (message, task) => {
|
|
720
|
+
const box = blessed.box({
|
|
721
|
+
parent: widgets.screen,
|
|
722
|
+
border: 'line',
|
|
723
|
+
label: ' Loading ',
|
|
724
|
+
width: '56%',
|
|
725
|
+
height: 7,
|
|
726
|
+
top: 'center',
|
|
727
|
+
left: 'center',
|
|
728
|
+
tags: true,
|
|
729
|
+
content: `${message}\n\nThis can take a few seconds on large repos.`,
|
|
730
|
+
style: {
|
|
731
|
+
border: { fg: '#5bc0eb' },
|
|
732
|
+
fg: 'white',
|
|
733
|
+
bg: '#101522',
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
widgets.screen.render();
|
|
737
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
738
|
+
try {
|
|
739
|
+
return await task();
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
box.destroy();
|
|
743
|
+
widgets.screen.render();
|
|
744
|
+
}
|
|
745
|
+
};
|
|
469
746
|
const switchRepository = (target, overrides) => {
|
|
470
747
|
currentRepository = target;
|
|
471
748
|
const preference = getTuiRepositoryPreference(params.service.config, target.owner, target.repo);
|
|
@@ -476,6 +753,9 @@ export async function startTui(params) {
|
|
|
476
753
|
clearCaches();
|
|
477
754
|
search = '';
|
|
478
755
|
snapshot = null;
|
|
756
|
+
clusterItems = ['Pick a repository with p'];
|
|
757
|
+
clusterIndexById = new Map();
|
|
758
|
+
widgets.clusters.setItems(clusterItems);
|
|
479
759
|
clusterDetail = null;
|
|
480
760
|
threadDetail = null;
|
|
481
761
|
selectedClusterId = null;
|
|
@@ -485,37 +765,14 @@ export async function startTui(params) {
|
|
|
485
765
|
status = `Switched to ${target.owner}/${target.repo}`;
|
|
486
766
|
refreshAll(false);
|
|
487
767
|
};
|
|
488
|
-
const runRepositoryBootstrap =
|
|
768
|
+
const runRepositoryBootstrap = (target) => {
|
|
489
769
|
if (hasActiveJobs()) {
|
|
490
770
|
pushActivity('[repo] repository setup is blocked while jobs are already running');
|
|
491
771
|
return false;
|
|
492
772
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
pushActivity(`[repo] starting initial update pipeline for ${target.owner}/${target.repo}`);
|
|
497
|
-
const previousRepository = currentRepository;
|
|
498
|
-
let ok = false;
|
|
499
|
-
try {
|
|
500
|
-
currentRepository = target;
|
|
501
|
-
ok = await runUpdatePipeline({ sync: true, embed: true, cluster: true });
|
|
502
|
-
}
|
|
503
|
-
finally {
|
|
504
|
-
currentRepository = previousRepository;
|
|
505
|
-
}
|
|
506
|
-
if (!ok) {
|
|
507
|
-
return false;
|
|
508
|
-
}
|
|
509
|
-
pushActivity(`[repo] initial setup complete for ${target.owner}/${target.repo}`);
|
|
510
|
-
switchRepository(target, { minClusterSize: 1 });
|
|
511
|
-
return true;
|
|
512
|
-
}
|
|
513
|
-
catch (error) {
|
|
514
|
-
pushActivity(`[repo] initial setup failed for ${target.owner}/${target.repo}: ${error instanceof Error ? error.message : String(error)}`);
|
|
515
|
-
status = 'Ready';
|
|
516
|
-
render();
|
|
517
|
-
return false;
|
|
518
|
-
}
|
|
773
|
+
switchRepository(target, { minClusterSize: 1 });
|
|
774
|
+
pushActivity(`[repo] opened ${target.owner}/${target.repo}; starting initial update pipeline in the background`);
|
|
775
|
+
return startBackgroundUpdatePipeline(target, { sync: true, embed: true, cluster: true });
|
|
519
776
|
};
|
|
520
777
|
const browseRepositories = () => {
|
|
521
778
|
if (modalOpen)
|
|
@@ -533,7 +790,9 @@ export async function startTui(params) {
|
|
|
533
790
|
return;
|
|
534
791
|
}
|
|
535
792
|
if (choice.kind === 'existing') {
|
|
536
|
-
|
|
793
|
+
await withLoadingOverlay(`Opening ${choice.target.owner}/${choice.target.repo}...`, async () => {
|
|
794
|
+
switchRepository(choice.target);
|
|
795
|
+
});
|
|
537
796
|
pushActivity(`[repo] switched to ${choice.target.owner}/${choice.target.repo}`);
|
|
538
797
|
updateFocus('clusters');
|
|
539
798
|
return;
|
|
@@ -543,7 +802,7 @@ export async function startTui(params) {
|
|
|
543
802
|
render();
|
|
544
803
|
return;
|
|
545
804
|
}
|
|
546
|
-
|
|
805
|
+
runRepositoryBootstrap(target);
|
|
547
806
|
updateFocus('clusters');
|
|
548
807
|
}
|
|
549
808
|
finally {
|
|
@@ -562,7 +821,9 @@ export async function startTui(params) {
|
|
|
562
821
|
return false;
|
|
563
822
|
}
|
|
564
823
|
if (choice.kind === 'existing') {
|
|
565
|
-
|
|
824
|
+
await withLoadingOverlay(`Opening ${choice.target.owner}/${choice.target.repo}...`, async () => {
|
|
825
|
+
switchRepository(choice.target);
|
|
826
|
+
});
|
|
566
827
|
pushActivity(`[repo] opened ${choice.target.owner}/${choice.target.repo}`);
|
|
567
828
|
updateFocus('clusters');
|
|
568
829
|
return true;
|
|
@@ -571,7 +832,7 @@ export async function startTui(params) {
|
|
|
571
832
|
if (!target) {
|
|
572
833
|
return false;
|
|
573
834
|
}
|
|
574
|
-
const ready =
|
|
835
|
+
const ready = runRepositoryBootstrap(target);
|
|
575
836
|
if (!ready) {
|
|
576
837
|
return false;
|
|
577
838
|
}
|
|
@@ -583,24 +844,22 @@ export async function startTui(params) {
|
|
|
583
844
|
}
|
|
584
845
|
};
|
|
585
846
|
widgets.screen.key(['q'], () => {
|
|
586
|
-
|
|
587
|
-
return;
|
|
588
|
-
widgets.screen.destroy();
|
|
847
|
+
requestQuit();
|
|
589
848
|
});
|
|
590
849
|
widgets.screen.key(['C-c'], () => {
|
|
591
|
-
|
|
850
|
+
requestQuit();
|
|
592
851
|
});
|
|
593
|
-
widgets.screen.key(['tab'], () => {
|
|
852
|
+
widgets.screen.key(['tab', 'right'], () => {
|
|
594
853
|
if (modalOpen)
|
|
595
854
|
return;
|
|
596
855
|
updateFocus(cycleFocusPane(focusPane, 1));
|
|
597
856
|
});
|
|
598
|
-
widgets.screen.key(['S-tab'], () => {
|
|
857
|
+
widgets.screen.key(['S-tab', 'left'], () => {
|
|
599
858
|
if (modalOpen)
|
|
600
859
|
return;
|
|
601
860
|
updateFocus(cycleFocusPane(focusPane, -1));
|
|
602
861
|
});
|
|
603
|
-
widgets.screen.key(['
|
|
862
|
+
widgets.screen.key(['down'], () => {
|
|
604
863
|
if (modalOpen)
|
|
605
864
|
return;
|
|
606
865
|
if (focusPane === 'detail') {
|
|
@@ -609,7 +868,7 @@ export async function startTui(params) {
|
|
|
609
868
|
}
|
|
610
869
|
moveSelection(1);
|
|
611
870
|
});
|
|
612
|
-
widgets.screen.key(['
|
|
871
|
+
widgets.screen.key(['up'], () => {
|
|
613
872
|
if (modalOpen)
|
|
614
873
|
return;
|
|
615
874
|
if (focusPane === 'detail') {
|
|
@@ -621,12 +880,12 @@ export async function startTui(params) {
|
|
|
621
880
|
widgets.screen.key(['pageup'], () => {
|
|
622
881
|
if (modalOpen)
|
|
623
882
|
return;
|
|
624
|
-
|
|
883
|
+
pageFocusedPane(-1);
|
|
625
884
|
});
|
|
626
885
|
widgets.screen.key(['pagedown'], () => {
|
|
627
886
|
if (modalOpen)
|
|
628
887
|
return;
|
|
629
|
-
|
|
888
|
+
pageFocusedPane(1);
|
|
630
889
|
});
|
|
631
890
|
widgets.screen.key(['home'], () => {
|
|
632
891
|
if (modalOpen)
|
|
@@ -693,6 +952,11 @@ export async function startTui(params) {
|
|
|
693
952
|
return;
|
|
694
953
|
promptFilter();
|
|
695
954
|
});
|
|
955
|
+
widgets.screen.key(['#'], () => {
|
|
956
|
+
if (modalOpen)
|
|
957
|
+
return;
|
|
958
|
+
promptThreadJump();
|
|
959
|
+
});
|
|
696
960
|
widgets.screen.key(['h', '?'], () => {
|
|
697
961
|
if (modalOpen)
|
|
698
962
|
return;
|
|
@@ -923,13 +1187,14 @@ export function buildHelpContent() {
|
|
|
923
1187
|
'',
|
|
924
1188
|
'{bold}Navigation{/bold}',
|
|
925
1189
|
'Tab / Shift-Tab cycle focus across clusters, members, and detail',
|
|
926
|
-
'
|
|
927
|
-
'Up / Down
|
|
1190
|
+
'Left / Right cycle focus backward or forward across panes',
|
|
1191
|
+
'Up / Down move selection, or scroll detail when detail is focused',
|
|
928
1192
|
'Enter clusters -> members, members -> detail',
|
|
929
|
-
'PgUp / PgDn
|
|
1193
|
+
'PgUp / PgDn page through the focused pane or this help popup faster',
|
|
930
1194
|
'Home / End jump to the top or bottom of detail or help',
|
|
931
1195
|
'',
|
|
932
1196
|
'{bold}Views And Filters{/bold}',
|
|
1197
|
+
'# jump directly to an issue or PR number',
|
|
933
1198
|
's cycle cluster sort mode',
|
|
934
1199
|
'f cycle minimum cluster size filter',
|
|
935
1200
|
'l toggle wide layout: columns vs. wide-left stacked-right',
|
|
@@ -938,20 +1203,20 @@ export function buildHelpContent() {
|
|
|
938
1203
|
'r refresh the current local view from SQLite',
|
|
939
1204
|
'',
|
|
940
1205
|
'{bold}Actions{/bold}',
|
|
941
|
-
'g
|
|
1206
|
+
'g start the staged update pipeline in the background (GitHub, embeddings, clusters)',
|
|
942
1207
|
'p open the repository browser / sync a new repository',
|
|
943
1208
|
'u show all open threads for the selected author',
|
|
944
1209
|
'o open the selected thread URL in your browser',
|
|
945
1210
|
'',
|
|
946
1211
|
'{bold}Help And Exit{/bold}',
|
|
947
1212
|
'h or ? open this help popup',
|
|
948
|
-
'q quit the TUI (or close this popup)',
|
|
1213
|
+
'q quit the TUI (or close this popup); warns if a background update is running',
|
|
949
1214
|
'Esc close this popup',
|
|
950
1215
|
'',
|
|
951
1216
|
'{bold}Notes{/bold}',
|
|
952
1217
|
'Clusters show C<clusterId> so the cluster id is easy to copy into CLI or skill flows.',
|
|
953
1218
|
'The footer only shows the short command list. Open help to see the full list.',
|
|
954
|
-
'This popup scrolls. Use
|
|
1219
|
+
'This popup scrolls. Use arrows, PgUp/PgDn, Home, and End if it does not fit.',
|
|
955
1220
|
].join('\n');
|
|
956
1221
|
}
|
|
957
1222
|
async function promptHelp(screen) {
|
|
@@ -992,7 +1257,7 @@ async function promptHelp(screen) {
|
|
|
992
1257
|
bottom: 1,
|
|
993
1258
|
left: 'center',
|
|
994
1259
|
tags: false,
|
|
995
|
-
content: 'Scroll with
|
|
1260
|
+
content: 'Scroll with arrows, PgUp/PgDn, Home, End. Press Esc, q, h, ?, or Enter to close.',
|
|
996
1261
|
style: { fg: 'black', bg: '#5bc0eb' },
|
|
997
1262
|
});
|
|
998
1263
|
box.focus();
|
|
@@ -1063,7 +1328,7 @@ async function promptUpdatePipelineSelection(screen, stats) {
|
|
|
1063
1328
|
height: 4,
|
|
1064
1329
|
style: { fg: 'white', bg: '#101522' },
|
|
1065
1330
|
content: 'Usually you want all three. Run order is fixed: GitHub sync/reconcile -> embeddings -> clusters.\n' +
|
|
1066
|
-
'Toggle with space, move with
|
|
1331
|
+
'Toggle with space, move with arrows, Enter to start, Esc to cancel.',
|
|
1067
1332
|
});
|
|
1068
1333
|
box.focus();
|
|
1069
1334
|
box.select(0);
|
|
@@ -1285,7 +1550,7 @@ async function runColdStartSetup(service, screen, target, log, footer) {
|
|
|
1285
1550
|
repo: target.repo,
|
|
1286
1551
|
onProgress: reporter,
|
|
1287
1552
|
});
|
|
1288
|
-
service.clusterRepository({
|
|
1553
|
+
await service.clusterRepository({
|
|
1289
1554
|
owner: target.owner,
|
|
1290
1555
|
repo: target.repo,
|
|
1291
1556
|
onProgress: reporter,
|