ghcrawl 0.0.1 → 0.1.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/README.md +27 -4
- package/bin/ghcrawl.js +38 -0
- package/dist/init-wizard.d.ts +41 -0
- package/dist/init-wizard.d.ts.map +1 -0
- package/dist/init-wizard.js +255 -0
- package/dist/init-wizard.js.map +1 -0
- package/dist/main.d.ts +18 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +398 -0
- package/dist/main.js.map +1 -0
- package/dist/tui/app.d.ts +37 -0
- package/dist/tui/app.d.ts.map +1 -0
- package/dist/tui/app.js +1055 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/layout.d.ts +17 -0
- package/dist/tui/layout.d.ts.map +1 -0
- package/dist/tui/layout.js +34 -0
- package/dist/tui/layout.js.map +1 -0
- package/dist/tui/state.d.ts +30 -0
- package/dist/tui/state.d.ts.map +1 -0
- package/dist/tui/state.js +101 -0
- package/dist/tui/state.js.map +1 -0
- package/package.json +32 -16
- package/index.mjs +0 -12
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import blessed from 'neo-blessed';
|
|
3
|
+
import { getTuiRepositoryPreference, writeTuiRepositoryPreference } from '@ghcrawl/api-core';
|
|
4
|
+
import { buildMemberRows, cycleFocusPane, cycleMinSizeFilter, cycleSortMode, findSelectableIndex, moveSelectableIndex, preserveSelectedId, selectedThreadIdFromRow, } from './state.js';
|
|
5
|
+
import { computeTuiLayout } from './layout.js';
|
|
6
|
+
export function resolveBlessedTerminal(env = process.env) {
|
|
7
|
+
const term = env.TERM;
|
|
8
|
+
if (!term) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if (term === 'xterm-ghostty') {
|
|
12
|
+
return 'xterm-256color';
|
|
13
|
+
}
|
|
14
|
+
return term;
|
|
15
|
+
}
|
|
16
|
+
function createScreen(options) {
|
|
17
|
+
return blessed.screen({
|
|
18
|
+
...options,
|
|
19
|
+
terminal: resolveBlessedTerminal(),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const ACTIVITY_LOG_LIMIT = 200;
|
|
23
|
+
const FOOTER_LOG_LINES = 4;
|
|
24
|
+
const UPDATE_TASK_ORDER = ['sync', 'embed', 'cluster'];
|
|
25
|
+
export async function startTui(params) {
|
|
26
|
+
const selectedRepository = params.owner && params.repo ? { owner: params.owner, repo: params.repo } : null;
|
|
27
|
+
let currentRepository = selectedRepository ?? { owner: '', repo: '' };
|
|
28
|
+
const widgets = createWidgets(currentRepository.owner, currentRepository.repo);
|
|
29
|
+
let focusPane = 'clusters';
|
|
30
|
+
const initialPreference = selectedRepository
|
|
31
|
+
? getTuiRepositoryPreference(params.service.config, currentRepository.owner, currentRepository.repo)
|
|
32
|
+
: { sortMode: 'recent', minClusterSize: 10 };
|
|
33
|
+
let sortMode = initialPreference.sortMode;
|
|
34
|
+
let minSize = initialPreference.minClusterSize;
|
|
35
|
+
let search = '';
|
|
36
|
+
let snapshot = null;
|
|
37
|
+
let clusterDetail = null;
|
|
38
|
+
let threadDetail = null;
|
|
39
|
+
let selectedClusterId = null;
|
|
40
|
+
let selectedMemberThreadId = null;
|
|
41
|
+
let memberRows = [];
|
|
42
|
+
let memberIndex = -1;
|
|
43
|
+
let status = 'Ready';
|
|
44
|
+
const activityLines = [];
|
|
45
|
+
const clusterDetailCache = new Map();
|
|
46
|
+
const threadDetailCache = new Map();
|
|
47
|
+
let syncJobRunning = false;
|
|
48
|
+
let embedJobRunning = false;
|
|
49
|
+
let clusterJobRunning = false;
|
|
50
|
+
let modalOpen = false;
|
|
51
|
+
const clearCaches = () => {
|
|
52
|
+
clusterDetailCache.clear();
|
|
53
|
+
threadDetailCache.clear();
|
|
54
|
+
};
|
|
55
|
+
const pushActivity = (message) => {
|
|
56
|
+
activityLines.push(`${formatActivityTimestamp()} ${message}`);
|
|
57
|
+
if (activityLines.length > ACTIVITY_LOG_LIMIT) {
|
|
58
|
+
activityLines.splice(0, activityLines.length - ACTIVITY_LOG_LIMIT);
|
|
59
|
+
}
|
|
60
|
+
render();
|
|
61
|
+
};
|
|
62
|
+
const loadClusterDetail = (clusterId) => {
|
|
63
|
+
const cached = clusterDetailCache.get(clusterId);
|
|
64
|
+
if (cached)
|
|
65
|
+
return cached;
|
|
66
|
+
const detail = params.service.getTuiClusterDetail({
|
|
67
|
+
owner: currentRepository.owner,
|
|
68
|
+
repo: currentRepository.repo,
|
|
69
|
+
clusterId,
|
|
70
|
+
});
|
|
71
|
+
clusterDetailCache.set(clusterId, detail);
|
|
72
|
+
return detail;
|
|
73
|
+
};
|
|
74
|
+
const loadThreadDetail = (threadId, includeNeighbors) => {
|
|
75
|
+
const cached = threadDetailCache.get(threadId);
|
|
76
|
+
if (cached && (cached.hasNeighbors || !includeNeighbors)) {
|
|
77
|
+
return cached.detail;
|
|
78
|
+
}
|
|
79
|
+
const detail = params.service.getTuiThreadDetail({
|
|
80
|
+
owner: currentRepository.owner,
|
|
81
|
+
repo: currentRepository.repo,
|
|
82
|
+
threadId,
|
|
83
|
+
includeNeighbors,
|
|
84
|
+
});
|
|
85
|
+
threadDetailCache.set(threadId, { detail, hasNeighbors: includeNeighbors });
|
|
86
|
+
return detail;
|
|
87
|
+
};
|
|
88
|
+
const loadSelectedThreadDetail = (includeNeighbors) => {
|
|
89
|
+
threadDetail = selectedMemberThreadId !== null ? loadThreadDetail(selectedMemberThreadId, includeNeighbors) : null;
|
|
90
|
+
};
|
|
91
|
+
const refreshAll = (preserveSelection) => {
|
|
92
|
+
const previousClusterId = preserveSelection ? selectedClusterId : null;
|
|
93
|
+
const previousMemberId = preserveSelection ? selectedMemberThreadId : null;
|
|
94
|
+
clearCaches();
|
|
95
|
+
snapshot = params.service.getTuiSnapshot({
|
|
96
|
+
owner: currentRepository.owner,
|
|
97
|
+
repo: currentRepository.repo,
|
|
98
|
+
minSize,
|
|
99
|
+
sort: sortMode,
|
|
100
|
+
search,
|
|
101
|
+
});
|
|
102
|
+
selectedClusterId = preserveSelectedId(snapshot.clusters.map((cluster) => cluster.clusterId), previousClusterId);
|
|
103
|
+
if (selectedClusterId !== null) {
|
|
104
|
+
clusterDetail = loadClusterDetail(selectedClusterId);
|
|
105
|
+
memberRows = buildMemberRows(clusterDetail);
|
|
106
|
+
selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), previousMemberId);
|
|
107
|
+
memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
|
|
108
|
+
loadSelectedThreadDetail(false);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
clusterDetail = null;
|
|
112
|
+
memberRows = [];
|
|
113
|
+
selectedMemberThreadId = null;
|
|
114
|
+
memberIndex = -1;
|
|
115
|
+
threadDetail = null;
|
|
116
|
+
}
|
|
117
|
+
status = `Loaded ${snapshot.clusters.length} cluster(s)`;
|
|
118
|
+
render();
|
|
119
|
+
};
|
|
120
|
+
const updateFocus = (nextFocus) => {
|
|
121
|
+
focusPane = nextFocus;
|
|
122
|
+
if (focusPane === 'detail' && selectedMemberThreadId !== null) {
|
|
123
|
+
loadSelectedThreadDetail(true);
|
|
124
|
+
}
|
|
125
|
+
if (focusPane === 'clusters')
|
|
126
|
+
widgets.clusters.focus();
|
|
127
|
+
if (focusPane === 'members')
|
|
128
|
+
widgets.members.focus();
|
|
129
|
+
if (focusPane === 'detail')
|
|
130
|
+
widgets.detail.focus();
|
|
131
|
+
render();
|
|
132
|
+
};
|
|
133
|
+
const render = () => {
|
|
134
|
+
const width = widgets.screen.width;
|
|
135
|
+
const height = widgets.screen.height;
|
|
136
|
+
const layout = computeTuiLayout(width, height);
|
|
137
|
+
applyRect(widgets.header, layout.header);
|
|
138
|
+
applyRect(widgets.clusters, layout.clusters);
|
|
139
|
+
applyRect(widgets.members, layout.members);
|
|
140
|
+
applyRect(widgets.detail, layout.detail);
|
|
141
|
+
applyRect(widgets.footer, layout.footer);
|
|
142
|
+
widgets.screen.title = currentRepository.owner && currentRepository.repo ? `ghcrawl ${currentRepository.owner}/${currentRepository.repo}` : 'ghcrawl';
|
|
143
|
+
const repoLabel = snapshot?.repository.fullName ?? (currentRepository.owner && currentRepository.repo ? `${currentRepository.owner}/${currentRepository.repo}` : 'ghcrawl');
|
|
144
|
+
const ghStatus = formatRelativeTime(snapshot?.stats.lastGithubReconciliationAt ?? null);
|
|
145
|
+
const embedAge = formatRelativeTime(snapshot?.stats.lastEmbedRefreshAt ?? null);
|
|
146
|
+
const embedStatus = snapshot && snapshot.stats.staleEmbedThreadCount > 0
|
|
147
|
+
? `${snapshot.stats.staleEmbedThreadCount} stale / ${embedAge}`
|
|
148
|
+
: embedAge;
|
|
149
|
+
const clusterStatus = snapshot?.stats.latestClusterRunId != null
|
|
150
|
+
? `#${snapshot.stats.latestClusterRunId} ${formatRelativeTime(snapshot.stats.latestClusterRunFinishedAt ?? null)}`
|
|
151
|
+
: '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 clusterItems = snapshot
|
|
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;
|
|
161
|
+
widgets.clusters.select(clusterIndex);
|
|
162
|
+
widgets.members.setItems(memberRows.length > 0 ? memberRows.map((row) => row.label) : ['No members']);
|
|
163
|
+
if (memberIndex >= 0) {
|
|
164
|
+
widgets.members.select(memberIndex);
|
|
165
|
+
}
|
|
166
|
+
widgets.detail.setContent(renderDetailPane(threadDetail, clusterDetail, focusPane));
|
|
167
|
+
updatePaneStyles(widgets, focusPane);
|
|
168
|
+
const activeJobs = [syncJobRunning ? 'sync' : null, embedJobRunning ? 'embed' : null, clusterJobRunning ? 'cluster' : null]
|
|
169
|
+
.filter(Boolean)
|
|
170
|
+
.join(', ') || 'idle';
|
|
171
|
+
const logLines = activityLines.slice(-FOOTER_LOG_LINES);
|
|
172
|
+
const footerLines = [...logLines];
|
|
173
|
+
while (footerLines.length < FOOTER_LOG_LINES) {
|
|
174
|
+
footerLines.unshift('');
|
|
175
|
+
}
|
|
176
|
+
footerLines.push(`${status} | jobs:${activeJobs} | Tab focus j/k move-or-scroll PgUp/PgDn scroll p repos g update s sort f min / filter r refresh o open q quit`);
|
|
177
|
+
widgets.footer.setContent(footerLines.join('\n'));
|
|
178
|
+
widgets.screen.render();
|
|
179
|
+
};
|
|
180
|
+
const resetDetailScroll = () => {
|
|
181
|
+
widgets.detail.setScroll(0);
|
|
182
|
+
};
|
|
183
|
+
const scrollDetail = (offset) => {
|
|
184
|
+
if (focusPane !== 'detail')
|
|
185
|
+
return;
|
|
186
|
+
widgets.detail.scroll(offset);
|
|
187
|
+
widgets.screen.render();
|
|
188
|
+
};
|
|
189
|
+
const runSyncStep = async () => {
|
|
190
|
+
if (syncJobRunning) {
|
|
191
|
+
throw new Error('GitHub reconciliation already running');
|
|
192
|
+
}
|
|
193
|
+
syncJobRunning = true;
|
|
194
|
+
status = 'Running GitHub reconciliation';
|
|
195
|
+
pushActivity('[jobs] starting GitHub reconciliation');
|
|
196
|
+
render();
|
|
197
|
+
try {
|
|
198
|
+
const result = await params.service.syncRepository({
|
|
199
|
+
owner: currentRepository.owner,
|
|
200
|
+
repo: currentRepository.repo,
|
|
201
|
+
onProgress: pushActivity,
|
|
202
|
+
});
|
|
203
|
+
pushActivity(`[jobs] GitHub reconciliation complete threads=${result.threadsSynced} comments=${result.commentsSynced} closed=${result.threadsClosed}`);
|
|
204
|
+
refreshAll(true);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
syncJobRunning = false;
|
|
208
|
+
status = 'Ready';
|
|
209
|
+
render();
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const runEmbedStep = async () => {
|
|
213
|
+
if (embedJobRunning) {
|
|
214
|
+
throw new Error('embed refresh already running');
|
|
215
|
+
}
|
|
216
|
+
embedJobRunning = true;
|
|
217
|
+
status = 'Running embed refresh';
|
|
218
|
+
pushActivity('[jobs] starting embed refresh');
|
|
219
|
+
render();
|
|
220
|
+
try {
|
|
221
|
+
const result = await params.service.embedRepository({
|
|
222
|
+
owner: currentRepository.owner,
|
|
223
|
+
repo: currentRepository.repo,
|
|
224
|
+
onProgress: pushActivity,
|
|
225
|
+
});
|
|
226
|
+
pushActivity(`[jobs] embed refresh complete embeddings=${result.embedded}`);
|
|
227
|
+
refreshAll(true);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
embedJobRunning = false;
|
|
231
|
+
status = 'Ready';
|
|
232
|
+
render();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
const runClusterStep = async () => {
|
|
236
|
+
if (clusterJobRunning) {
|
|
237
|
+
throw new Error('cluster refresh already running');
|
|
238
|
+
}
|
|
239
|
+
clusterJobRunning = true;
|
|
240
|
+
status = 'Running cluster refresh';
|
|
241
|
+
pushActivity('[jobs] starting cluster refresh');
|
|
242
|
+
render();
|
|
243
|
+
try {
|
|
244
|
+
const result = params.service.clusterRepository({
|
|
245
|
+
owner: currentRepository.owner,
|
|
246
|
+
repo: currentRepository.repo,
|
|
247
|
+
onProgress: pushActivity,
|
|
248
|
+
});
|
|
249
|
+
pushActivity(`[jobs] cluster refresh complete clusters=${result.clusters} edges=${result.edges}`);
|
|
250
|
+
refreshAll(true);
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
clusterJobRunning = false;
|
|
254
|
+
status = 'Ready';
|
|
255
|
+
render();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const moveSelection = (delta) => {
|
|
259
|
+
if (!snapshot)
|
|
260
|
+
return;
|
|
261
|
+
if (focusPane === 'clusters') {
|
|
262
|
+
if (snapshot.clusters.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
const currentIndex = Math.max(0, snapshot.clusters.findIndex((cluster) => cluster.clusterId === selectedClusterId));
|
|
265
|
+
const nextIndex = (currentIndex + delta + snapshot.clusters.length) % snapshot.clusters.length;
|
|
266
|
+
selectedClusterId = snapshot.clusters[nextIndex]?.clusterId ?? null;
|
|
267
|
+
if (selectedClusterId !== null) {
|
|
268
|
+
clusterDetail = loadClusterDetail(selectedClusterId);
|
|
269
|
+
memberRows = buildMemberRows(clusterDetail);
|
|
270
|
+
selectedMemberThreadId = preserveSelectedId(memberRows.filter((row) => row.selectable).map((row) => row.threadId), null);
|
|
271
|
+
memberIndex = findSelectableIndex(memberRows, selectedMemberThreadId);
|
|
272
|
+
loadSelectedThreadDetail(false);
|
|
273
|
+
resetDetailScroll();
|
|
274
|
+
}
|
|
275
|
+
status = `Cluster ${nextIndex + 1}/${snapshot.clusters.length}`;
|
|
276
|
+
render();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (focusPane === 'members') {
|
|
280
|
+
if (memberRows.length === 0)
|
|
281
|
+
return;
|
|
282
|
+
memberIndex = moveSelectableIndex(memberRows, memberIndex < 0 ? 0 : memberIndex, delta);
|
|
283
|
+
selectedMemberThreadId = selectedThreadIdFromRow(memberRows, memberIndex);
|
|
284
|
+
loadSelectedThreadDetail(false);
|
|
285
|
+
resetDetailScroll();
|
|
286
|
+
status = selectedMemberThreadId !== null ? `Selected #${threadDetail?.thread.number ?? '?'}` : 'No selectable member';
|
|
287
|
+
render();
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const promptFilter = () => {
|
|
291
|
+
modalOpen = true;
|
|
292
|
+
const prompt = blessed.prompt({
|
|
293
|
+
parent: widgets.screen,
|
|
294
|
+
border: 'line',
|
|
295
|
+
height: 7,
|
|
296
|
+
width: '60%',
|
|
297
|
+
top: 'center',
|
|
298
|
+
left: 'center',
|
|
299
|
+
label: ' Cluster Filter ',
|
|
300
|
+
tags: true,
|
|
301
|
+
keys: true,
|
|
302
|
+
vi: true,
|
|
303
|
+
style: {
|
|
304
|
+
border: { fg: 'cyan' },
|
|
305
|
+
bg: '#101522',
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
prompt.input('Filter clusters', search, (_error, value) => {
|
|
309
|
+
search = (value ?? '').trim();
|
|
310
|
+
status = search ? `Filter: ${search}` : 'Filter cleared';
|
|
311
|
+
refreshAll(false);
|
|
312
|
+
prompt.destroy();
|
|
313
|
+
modalOpen = false;
|
|
314
|
+
updateFocus('clusters');
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
const openSelectedThread = () => {
|
|
318
|
+
const url = threadDetail?.thread.htmlUrl;
|
|
319
|
+
if (!url) {
|
|
320
|
+
status = 'No thread selected to open';
|
|
321
|
+
render();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
openUrl(url);
|
|
325
|
+
status = `Opened ${url}`;
|
|
326
|
+
render();
|
|
327
|
+
};
|
|
328
|
+
const promptUpdatePipeline = () => {
|
|
329
|
+
if (modalOpen || hasActiveJobs()) {
|
|
330
|
+
if (hasActiveJobs()) {
|
|
331
|
+
pushActivity('[jobs] update pipeline is unavailable while another job is running');
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
void (async () => {
|
|
336
|
+
modalOpen = true;
|
|
337
|
+
try {
|
|
338
|
+
const selection = await promptUpdatePipelineSelection(widgets.screen, snapshot?.stats ?? null);
|
|
339
|
+
if (!selection) {
|
|
340
|
+
render();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const selectedTasks = UPDATE_TASK_ORDER.filter((task) => selection[task]).join(' -> ');
|
|
344
|
+
pushActivity(`[jobs] queued update pipeline: ${selectedTasks}`);
|
|
345
|
+
await runUpdatePipeline(selection);
|
|
346
|
+
updateFocus('clusters');
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
modalOpen = false;
|
|
350
|
+
}
|
|
351
|
+
})();
|
|
352
|
+
};
|
|
353
|
+
const hasActiveJobs = () => syncJobRunning || embedJobRunning || clusterJobRunning;
|
|
354
|
+
const runUpdatePipeline = async (selection) => {
|
|
355
|
+
if (hasActiveJobs()) {
|
|
356
|
+
pushActivity('[jobs] another update pipeline is already running');
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
if (selection.sync) {
|
|
361
|
+
await runSyncStep();
|
|
362
|
+
}
|
|
363
|
+
if (selection.embed) {
|
|
364
|
+
await runEmbedStep();
|
|
365
|
+
}
|
|
366
|
+
if (selection.cluster) {
|
|
367
|
+
await runClusterStep();
|
|
368
|
+
}
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
pushActivity(`[jobs] update pipeline failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const persistRepositoryPreference = () => {
|
|
377
|
+
writeTuiRepositoryPreference(params.service.config, {
|
|
378
|
+
owner: currentRepository.owner,
|
|
379
|
+
repo: currentRepository.repo,
|
|
380
|
+
minClusterSize: minSize,
|
|
381
|
+
sortMode,
|
|
382
|
+
});
|
|
383
|
+
};
|
|
384
|
+
const switchRepository = (target, overrides) => {
|
|
385
|
+
currentRepository = target;
|
|
386
|
+
const preference = getTuiRepositoryPreference(params.service.config, target.owner, target.repo);
|
|
387
|
+
minSize = overrides?.minClusterSize ?? preference.minClusterSize;
|
|
388
|
+
sortMode = overrides?.sortMode ?? preference.sortMode;
|
|
389
|
+
persistRepositoryPreference();
|
|
390
|
+
clearCaches();
|
|
391
|
+
search = '';
|
|
392
|
+
snapshot = null;
|
|
393
|
+
clusterDetail = null;
|
|
394
|
+
threadDetail = null;
|
|
395
|
+
selectedClusterId = null;
|
|
396
|
+
selectedMemberThreadId = null;
|
|
397
|
+
memberRows = [];
|
|
398
|
+
memberIndex = -1;
|
|
399
|
+
status = `Switched to ${target.owner}/${target.repo}`;
|
|
400
|
+
refreshAll(false);
|
|
401
|
+
};
|
|
402
|
+
const runRepositoryBootstrap = async (target) => {
|
|
403
|
+
if (hasActiveJobs()) {
|
|
404
|
+
pushActivity('[repo] repository setup is blocked while jobs are already running');
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
status = `Bootstrapping ${target.owner}/${target.repo}`;
|
|
408
|
+
render();
|
|
409
|
+
try {
|
|
410
|
+
pushActivity(`[repo] starting initial update pipeline for ${target.owner}/${target.repo}`);
|
|
411
|
+
const previousRepository = currentRepository;
|
|
412
|
+
let ok = false;
|
|
413
|
+
try {
|
|
414
|
+
currentRepository = target;
|
|
415
|
+
ok = await runUpdatePipeline({ sync: true, embed: true, cluster: true });
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
currentRepository = previousRepository;
|
|
419
|
+
}
|
|
420
|
+
if (!ok) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
pushActivity(`[repo] initial setup complete for ${target.owner}/${target.repo}`);
|
|
424
|
+
switchRepository(target, { minClusterSize: 1 });
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
pushActivity(`[repo] initial setup failed for ${target.owner}/${target.repo}: ${error instanceof Error ? error.message : String(error)}`);
|
|
429
|
+
status = 'Ready';
|
|
430
|
+
render();
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
const browseRepositories = () => {
|
|
435
|
+
if (modalOpen)
|
|
436
|
+
return;
|
|
437
|
+
if (hasActiveJobs()) {
|
|
438
|
+
pushActivity('[repo] repository switching is disabled while jobs are running');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
void (async () => {
|
|
442
|
+
modalOpen = true;
|
|
443
|
+
try {
|
|
444
|
+
const choice = await promptRepositoryChoice(widgets.screen, params.service);
|
|
445
|
+
if (!choice) {
|
|
446
|
+
render();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (choice.kind === 'existing') {
|
|
450
|
+
switchRepository(choice.target);
|
|
451
|
+
pushActivity(`[repo] switched to ${choice.target.owner}/${choice.target.repo}`);
|
|
452
|
+
updateFocus('clusters');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const target = await promptRepositoryInput(widgets.screen);
|
|
456
|
+
if (!target) {
|
|
457
|
+
render();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
await runRepositoryBootstrap(target);
|
|
461
|
+
updateFocus('clusters');
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
modalOpen = false;
|
|
465
|
+
}
|
|
466
|
+
})();
|
|
467
|
+
};
|
|
468
|
+
const initializeRepositorySelection = async () => {
|
|
469
|
+
if (selectedRepository) {
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
modalOpen = true;
|
|
473
|
+
try {
|
|
474
|
+
const choice = await promptRepositoryChoice(widgets.screen, params.service);
|
|
475
|
+
if (!choice) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
if (choice.kind === 'existing') {
|
|
479
|
+
switchRepository(choice.target);
|
|
480
|
+
pushActivity(`[repo] opened ${choice.target.owner}/${choice.target.repo}`);
|
|
481
|
+
updateFocus('clusters');
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
const target = await promptRepositoryInput(widgets.screen);
|
|
485
|
+
if (!target) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
const ready = await runRepositoryBootstrap(target);
|
|
489
|
+
if (!ready) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
updateFocus('clusters');
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
finally {
|
|
496
|
+
modalOpen = false;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
widgets.screen.key(['q', 'C-c'], () => {
|
|
500
|
+
widgets.screen.destroy();
|
|
501
|
+
});
|
|
502
|
+
widgets.screen.key(['tab'], () => {
|
|
503
|
+
if (modalOpen)
|
|
504
|
+
return;
|
|
505
|
+
updateFocus(cycleFocusPane(focusPane, 1));
|
|
506
|
+
});
|
|
507
|
+
widgets.screen.key(['S-tab'], () => {
|
|
508
|
+
if (modalOpen)
|
|
509
|
+
return;
|
|
510
|
+
updateFocus(cycleFocusPane(focusPane, -1));
|
|
511
|
+
});
|
|
512
|
+
widgets.screen.key(['j', 'down'], () => {
|
|
513
|
+
if (modalOpen)
|
|
514
|
+
return;
|
|
515
|
+
if (focusPane === 'detail') {
|
|
516
|
+
scrollDetail(3);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
moveSelection(1);
|
|
520
|
+
});
|
|
521
|
+
widgets.screen.key(['k', 'up'], () => {
|
|
522
|
+
if (modalOpen)
|
|
523
|
+
return;
|
|
524
|
+
if (focusPane === 'detail') {
|
|
525
|
+
scrollDetail(-3);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
moveSelection(-1);
|
|
529
|
+
});
|
|
530
|
+
widgets.screen.key(['pageup'], () => {
|
|
531
|
+
if (modalOpen)
|
|
532
|
+
return;
|
|
533
|
+
scrollDetail(-12);
|
|
534
|
+
});
|
|
535
|
+
widgets.screen.key(['pagedown'], () => {
|
|
536
|
+
if (modalOpen)
|
|
537
|
+
return;
|
|
538
|
+
scrollDetail(12);
|
|
539
|
+
});
|
|
540
|
+
widgets.screen.key(['home'], () => {
|
|
541
|
+
if (modalOpen)
|
|
542
|
+
return;
|
|
543
|
+
if (focusPane !== 'detail')
|
|
544
|
+
return;
|
|
545
|
+
widgets.detail.setScroll(0);
|
|
546
|
+
widgets.screen.render();
|
|
547
|
+
});
|
|
548
|
+
widgets.screen.key(['end'], () => {
|
|
549
|
+
if (modalOpen)
|
|
550
|
+
return;
|
|
551
|
+
if (focusPane !== 'detail')
|
|
552
|
+
return;
|
|
553
|
+
widgets.detail.setScrollPerc(100);
|
|
554
|
+
widgets.screen.render();
|
|
555
|
+
});
|
|
556
|
+
widgets.screen.key(['enter'], () => {
|
|
557
|
+
if (modalOpen)
|
|
558
|
+
return;
|
|
559
|
+
if (focusPane === 'clusters') {
|
|
560
|
+
updateFocus('members');
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (focusPane === 'members') {
|
|
564
|
+
loadSelectedThreadDetail(true);
|
|
565
|
+
status = selectedMemberThreadId !== null ? `Loaded neighbors for #${threadDetail?.thread.number ?? '?'}` : status;
|
|
566
|
+
updateFocus('detail');
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
widgets.screen.key(['s'], () => {
|
|
570
|
+
if (modalOpen)
|
|
571
|
+
return;
|
|
572
|
+
sortMode = cycleSortMode(sortMode);
|
|
573
|
+
persistRepositoryPreference();
|
|
574
|
+
status = `Sort: ${sortMode}`;
|
|
575
|
+
refreshAll(false);
|
|
576
|
+
});
|
|
577
|
+
widgets.screen.key(['f'], () => {
|
|
578
|
+
if (modalOpen)
|
|
579
|
+
return;
|
|
580
|
+
minSize = cycleMinSizeFilter(minSize);
|
|
581
|
+
persistRepositoryPreference();
|
|
582
|
+
status = `Min size: ${minSize === 0 ? 'all' : `${minSize}+`}`;
|
|
583
|
+
refreshAll(false);
|
|
584
|
+
});
|
|
585
|
+
widgets.screen.key(['/'], () => {
|
|
586
|
+
if (modalOpen)
|
|
587
|
+
return;
|
|
588
|
+
promptFilter();
|
|
589
|
+
});
|
|
590
|
+
widgets.screen.key(['p'], () => browseRepositories());
|
|
591
|
+
widgets.screen.key(['g'], () => {
|
|
592
|
+
if (modalOpen)
|
|
593
|
+
return;
|
|
594
|
+
promptUpdatePipeline();
|
|
595
|
+
});
|
|
596
|
+
widgets.screen.key(['r'], () => {
|
|
597
|
+
if (modalOpen)
|
|
598
|
+
return;
|
|
599
|
+
status = 'Refreshing';
|
|
600
|
+
refreshAll(true);
|
|
601
|
+
});
|
|
602
|
+
widgets.screen.key(['o'], () => {
|
|
603
|
+
if (modalOpen)
|
|
604
|
+
return;
|
|
605
|
+
openSelectedThread();
|
|
606
|
+
});
|
|
607
|
+
widgets.screen.on('resize', () => render());
|
|
608
|
+
widgets.screen.on('destroy', () => {
|
|
609
|
+
widgets.screen.program.showCursor();
|
|
610
|
+
});
|
|
611
|
+
widgets.screen.program.hideCursor();
|
|
612
|
+
if (selectedRepository) {
|
|
613
|
+
refreshAll(false);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
status = 'Pick a repository';
|
|
617
|
+
render();
|
|
618
|
+
const ready = await initializeRepositorySelection();
|
|
619
|
+
if (!ready) {
|
|
620
|
+
widgets.screen.destroy();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
pushActivity('[jobs] press g to run the staged update pipeline: GitHub sync, embeddings, then clusters');
|
|
625
|
+
updateFocus('clusters');
|
|
626
|
+
await new Promise((resolve) => widgets.screen.once('destroy', () => resolve()));
|
|
627
|
+
}
|
|
628
|
+
function createWidgets(owner, repo) {
|
|
629
|
+
const screen = createScreen({
|
|
630
|
+
smartCSR: true,
|
|
631
|
+
fullUnicode: true,
|
|
632
|
+
dockBorders: true,
|
|
633
|
+
autoPadding: false,
|
|
634
|
+
title: owner && repo ? `ghcrawl ${owner}/${repo}` : 'ghcrawl',
|
|
635
|
+
});
|
|
636
|
+
const header = blessed.box({
|
|
637
|
+
parent: screen,
|
|
638
|
+
tags: true,
|
|
639
|
+
style: { fg: 'white', bg: '#0d1321' },
|
|
640
|
+
});
|
|
641
|
+
const clusters = blessed.list({
|
|
642
|
+
parent: screen,
|
|
643
|
+
border: 'line',
|
|
644
|
+
label: ' Clusters ',
|
|
645
|
+
tags: false,
|
|
646
|
+
keys: false,
|
|
647
|
+
style: {
|
|
648
|
+
border: { fg: '#5bc0eb' },
|
|
649
|
+
item: { fg: 'white' },
|
|
650
|
+
selected: { bg: '#5bc0eb', fg: 'black', bold: true },
|
|
651
|
+
},
|
|
652
|
+
scrollbar: { ch: ' ' },
|
|
653
|
+
});
|
|
654
|
+
const members = blessed.list({
|
|
655
|
+
parent: screen,
|
|
656
|
+
border: 'line',
|
|
657
|
+
label: ' Members ',
|
|
658
|
+
tags: false,
|
|
659
|
+
keys: false,
|
|
660
|
+
style: {
|
|
661
|
+
border: { fg: '#9bc53d' },
|
|
662
|
+
item: { fg: 'white' },
|
|
663
|
+
selected: { bg: '#9bc53d', fg: 'black', bold: true },
|
|
664
|
+
},
|
|
665
|
+
scrollbar: { ch: ' ' },
|
|
666
|
+
});
|
|
667
|
+
const detail = blessed.box({
|
|
668
|
+
parent: screen,
|
|
669
|
+
border: 'line',
|
|
670
|
+
label: ' Detail ',
|
|
671
|
+
tags: true,
|
|
672
|
+
scrollable: true,
|
|
673
|
+
alwaysScroll: true,
|
|
674
|
+
keys: false,
|
|
675
|
+
scrollbar: { ch: ' ' },
|
|
676
|
+
style: {
|
|
677
|
+
border: { fg: '#fde74c' },
|
|
678
|
+
fg: 'white',
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
const footer = blessed.box({
|
|
682
|
+
parent: screen,
|
|
683
|
+
tags: false,
|
|
684
|
+
style: { fg: 'black', bg: '#5bc0eb' },
|
|
685
|
+
});
|
|
686
|
+
return { screen, header, clusters, members, detail, footer };
|
|
687
|
+
}
|
|
688
|
+
function updatePaneStyles(widgets, focus) {
|
|
689
|
+
widgets.clusters.style.border = { fg: focus === 'clusters' ? 'white' : '#5bc0eb' };
|
|
690
|
+
widgets.members.style.border = { fg: focus === 'members' ? 'white' : '#9bc53d' };
|
|
691
|
+
widgets.detail.style.border = { fg: focus === 'detail' ? 'white' : '#fde74c' };
|
|
692
|
+
}
|
|
693
|
+
export function renderDetailPane(threadDetail, clusterDetail, focusPane) {
|
|
694
|
+
if (!clusterDetail) {
|
|
695
|
+
return 'No cluster selected.\n\nRun `ghcrawl cluster owner/repo` if you have not clustered this repository yet.';
|
|
696
|
+
}
|
|
697
|
+
if (!threadDetail) {
|
|
698
|
+
return `{bold}${escapeBlessedText(clusterDetail.displayTitle)}{/bold}\n\nSelect a member to inspect thread details.`;
|
|
699
|
+
}
|
|
700
|
+
const thread = threadDetail.thread;
|
|
701
|
+
const labels = thread.labels.length > 0 ? escapeBlessedText(thread.labels.join(', ')) : 'none';
|
|
702
|
+
const summaries = Object.entries(threadDetail.summaries)
|
|
703
|
+
.map(([key, value]) => `{bold}${key}:{/bold}\n${escapeBlessedText(value)}`)
|
|
704
|
+
.join('\n\n');
|
|
705
|
+
const neighbors = threadDetail.neighbors.length > 0
|
|
706
|
+
? threadDetail.neighbors
|
|
707
|
+
.map((neighbor) => `#${neighbor.number} ${neighbor.kind} ${(neighbor.score * 100).toFixed(1)}% ${escapeBlessedText(neighbor.title)}`)
|
|
708
|
+
.join('\n')
|
|
709
|
+
: focusPane === 'detail'
|
|
710
|
+
? 'No neighbors available.'
|
|
711
|
+
: 'Neighbors load when the detail pane is focused.';
|
|
712
|
+
return [
|
|
713
|
+
`{bold}${thread.kind} #${thread.number}{/bold} ${escapeBlessedText(thread.title)}`,
|
|
714
|
+
'',
|
|
715
|
+
`{bold}Author:{/bold} ${escapeBlessedText(thread.authorLogin ?? 'unknown')}`,
|
|
716
|
+
`{bold}Updated:{/bold} ${thread.updatedAtGh ?? 'unknown'}`,
|
|
717
|
+
`{bold}Labels:{/bold} ${labels}`,
|
|
718
|
+
`{bold}URL:{/bold} ${escapeBlessedText(thread.htmlUrl)}`,
|
|
719
|
+
'',
|
|
720
|
+
`{bold}Body{/bold}`,
|
|
721
|
+
escapeBlessedText(thread.body ?? '(no body)'),
|
|
722
|
+
summaries ? `\n\n${summaries}` : '',
|
|
723
|
+
`\n\n{bold}Neighbors{/bold}\n${neighbors}`,
|
|
724
|
+
]
|
|
725
|
+
.filter(Boolean)
|
|
726
|
+
.join('\n');
|
|
727
|
+
}
|
|
728
|
+
export function escapeBlessedText(value) {
|
|
729
|
+
return value.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
|
|
730
|
+
}
|
|
731
|
+
function applyRect(element, rect) {
|
|
732
|
+
element.top = rect.top;
|
|
733
|
+
element.left = rect.left;
|
|
734
|
+
element.width = rect.width;
|
|
735
|
+
element.height = rect.height;
|
|
736
|
+
}
|
|
737
|
+
function openUrl(url) {
|
|
738
|
+
const launch = process.platform === 'darwin'
|
|
739
|
+
? { command: 'open', args: [url] }
|
|
740
|
+
: process.platform === 'win32'
|
|
741
|
+
? { command: 'cmd', args: ['/c', 'start', '', url] }
|
|
742
|
+
: { command: 'xdg-open', args: [url] };
|
|
743
|
+
const child = spawn(launch.command, launch.args, {
|
|
744
|
+
detached: true,
|
|
745
|
+
stdio: 'ignore',
|
|
746
|
+
windowsVerbatimArguments: process.platform === 'win32',
|
|
747
|
+
});
|
|
748
|
+
child.unref();
|
|
749
|
+
}
|
|
750
|
+
export function describeUpdateTask(task, stats, now = new Date()) {
|
|
751
|
+
if (!stats) {
|
|
752
|
+
if (task === 'sync')
|
|
753
|
+
return 'recommended';
|
|
754
|
+
if (task === 'embed')
|
|
755
|
+
return 'recommended after sync';
|
|
756
|
+
return 'recommended after embeddings';
|
|
757
|
+
}
|
|
758
|
+
if (task === 'sync') {
|
|
759
|
+
return stats.lastGithubReconciliationAt
|
|
760
|
+
? `up to date, last ${formatRelativeTime(stats.lastGithubReconciliationAt, now)}`
|
|
761
|
+
: 'never run';
|
|
762
|
+
}
|
|
763
|
+
if (task === 'embed') {
|
|
764
|
+
if (!stats.lastEmbedRefreshAt)
|
|
765
|
+
return 'never run';
|
|
766
|
+
if (stats.staleEmbedThreadCount > 0) {
|
|
767
|
+
return `outdated: ${stats.staleEmbedThreadCount} stale, last ${formatRelativeTime(stats.lastEmbedRefreshAt, now)}`;
|
|
768
|
+
}
|
|
769
|
+
const syncMs = parseDateOrNull(stats.lastGithubReconciliationAt);
|
|
770
|
+
const embedMs = parseDateOrNull(stats.lastEmbedRefreshAt);
|
|
771
|
+
if (syncMs !== null && embedMs !== null && embedMs < syncMs) {
|
|
772
|
+
return `outdated: GitHub is newer by ${formatAge(syncMs - embedMs)}`;
|
|
773
|
+
}
|
|
774
|
+
return `up to date, last ${formatRelativeTime(stats.lastEmbedRefreshAt, now)}`;
|
|
775
|
+
}
|
|
776
|
+
if (!stats.latestClusterRunFinishedAt)
|
|
777
|
+
return 'never run';
|
|
778
|
+
const embedMs = parseDateOrNull(stats.lastEmbedRefreshAt);
|
|
779
|
+
const clusterMs = parseDateOrNull(stats.latestClusterRunFinishedAt);
|
|
780
|
+
if (embedMs !== null && clusterMs !== null && clusterMs < embedMs) {
|
|
781
|
+
return `outdated: embeddings are newer by ${formatAge(embedMs - clusterMs)}`;
|
|
782
|
+
}
|
|
783
|
+
return `up to date, last ${formatRelativeTime(stats.latestClusterRunFinishedAt, now)}`;
|
|
784
|
+
}
|
|
785
|
+
export function buildUpdatePipelineLabels(stats, selection, now = new Date()) {
|
|
786
|
+
return UPDATE_TASK_ORDER.map((task) => {
|
|
787
|
+
const mark = selection[task] ? '[x]' : '[ ]';
|
|
788
|
+
const title = task === 'sync' ? 'GitHub sync/reconcile' : task === 'embed' ? 'Embed refresh' : 'Cluster rebuild';
|
|
789
|
+
return `${mark} ${title} ${describeUpdateTask(task, stats, now)}`;
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
async function promptUpdatePipelineSelection(screen, stats) {
|
|
793
|
+
const selection = { sync: true, embed: true, cluster: true };
|
|
794
|
+
const modalWidth = '76%';
|
|
795
|
+
const box = blessed.list({
|
|
796
|
+
parent: screen,
|
|
797
|
+
border: 'line',
|
|
798
|
+
label: ' Update Pipeline ',
|
|
799
|
+
keys: true,
|
|
800
|
+
vi: true,
|
|
801
|
+
mouse: false,
|
|
802
|
+
top: 'center',
|
|
803
|
+
left: 'center',
|
|
804
|
+
width: modalWidth,
|
|
805
|
+
height: 11,
|
|
806
|
+
style: {
|
|
807
|
+
border: { fg: '#5bc0eb' },
|
|
808
|
+
item: { fg: 'white' },
|
|
809
|
+
selected: { bg: '#5bc0eb', fg: 'black', bold: true },
|
|
810
|
+
},
|
|
811
|
+
items: buildUpdatePipelineLabels(stats, selection),
|
|
812
|
+
});
|
|
813
|
+
const help = blessed.box({
|
|
814
|
+
parent: screen,
|
|
815
|
+
top: 'center-4',
|
|
816
|
+
left: 'center',
|
|
817
|
+
width: modalWidth,
|
|
818
|
+
height: 4,
|
|
819
|
+
style: { fg: 'white', bg: '#101522' },
|
|
820
|
+
content: 'Usually you want all three. Run order is fixed: GitHub sync/reconcile -> embeddings -> clusters.\n' +
|
|
821
|
+
'Toggle with space, move with j/k or arrows, Enter to start, Esc to cancel.',
|
|
822
|
+
});
|
|
823
|
+
box.focus();
|
|
824
|
+
box.select(0);
|
|
825
|
+
screen.render();
|
|
826
|
+
return await new Promise((resolve) => {
|
|
827
|
+
const getSelectedIndex = () => {
|
|
828
|
+
const selectedIndex = box.selected;
|
|
829
|
+
return typeof selectedIndex === 'number' && selectedIndex >= 0 ? selectedIndex : 0;
|
|
830
|
+
};
|
|
831
|
+
const refreshItems = () => {
|
|
832
|
+
const selectedIndex = getSelectedIndex();
|
|
833
|
+
box.setItems(buildUpdatePipelineLabels(stats, selection));
|
|
834
|
+
box.select(selectedIndex);
|
|
835
|
+
screen.render();
|
|
836
|
+
};
|
|
837
|
+
const finish = (value) => {
|
|
838
|
+
screen.off('keypress', handleKeypress);
|
|
839
|
+
box.destroy();
|
|
840
|
+
help.destroy();
|
|
841
|
+
screen.render();
|
|
842
|
+
resolve(value);
|
|
843
|
+
};
|
|
844
|
+
const handleKeypress = (_char, key) => {
|
|
845
|
+
if (key.name === 'escape') {
|
|
846
|
+
finish(null);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (key.name === 'space') {
|
|
850
|
+
const index = getSelectedIndex();
|
|
851
|
+
const task = UPDATE_TASK_ORDER[index];
|
|
852
|
+
if (!task)
|
|
853
|
+
return;
|
|
854
|
+
selection[task] = !selection[task];
|
|
855
|
+
if (!selection.sync && !selection.embed && !selection.cluster) {
|
|
856
|
+
selection[task] = true;
|
|
857
|
+
}
|
|
858
|
+
refreshItems();
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
screen.on('keypress', handleKeypress);
|
|
862
|
+
box.on('select', () => finish({ ...selection }));
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
export function getRepositoryChoices(service, now = new Date()) {
|
|
866
|
+
const repositories = service.listRepositories().repositories
|
|
867
|
+
.slice()
|
|
868
|
+
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || left.fullName.localeCompare(right.fullName));
|
|
869
|
+
return [
|
|
870
|
+
...repositories.map((repository) => ({
|
|
871
|
+
kind: 'existing',
|
|
872
|
+
target: { owner: repository.owner, repo: repository.name },
|
|
873
|
+
label: `${repository.fullName} ${formatRelativeTime(repository.updatedAt, now)}`,
|
|
874
|
+
})),
|
|
875
|
+
{ kind: 'new', label: '+ Sync a new repository' },
|
|
876
|
+
];
|
|
877
|
+
}
|
|
878
|
+
async function promptRepositoryChoice(screen, service) {
|
|
879
|
+
const choices = getRepositoryChoices(service);
|
|
880
|
+
const box = blessed.list({
|
|
881
|
+
parent: screen,
|
|
882
|
+
border: 'line',
|
|
883
|
+
label: ' Repositories ',
|
|
884
|
+
keys: true,
|
|
885
|
+
vi: true,
|
|
886
|
+
mouse: false,
|
|
887
|
+
top: 'center',
|
|
888
|
+
left: 'center',
|
|
889
|
+
width: '70%',
|
|
890
|
+
height: '70%',
|
|
891
|
+
style: {
|
|
892
|
+
border: { fg: '#5bc0eb' },
|
|
893
|
+
item: { fg: 'white' },
|
|
894
|
+
selected: { bg: '#5bc0eb', fg: 'black', bold: true },
|
|
895
|
+
},
|
|
896
|
+
items: choices.map((choice) => choice.label),
|
|
897
|
+
});
|
|
898
|
+
const help = blessed.box({
|
|
899
|
+
parent: screen,
|
|
900
|
+
bottom: 0,
|
|
901
|
+
left: 0,
|
|
902
|
+
width: '100%',
|
|
903
|
+
height: 1,
|
|
904
|
+
content: 'Select a repository with Enter. Press n for a new repo. Esc cancels.',
|
|
905
|
+
style: { fg: 'black', bg: '#5bc0eb' },
|
|
906
|
+
});
|
|
907
|
+
box.focus();
|
|
908
|
+
box.select(0);
|
|
909
|
+
screen.render();
|
|
910
|
+
return await new Promise((resolve) => {
|
|
911
|
+
const teardown = () => {
|
|
912
|
+
screen.off('keypress', handleKeypress);
|
|
913
|
+
box.destroy();
|
|
914
|
+
help.destroy();
|
|
915
|
+
screen.render();
|
|
916
|
+
};
|
|
917
|
+
const finish = (value) => {
|
|
918
|
+
teardown();
|
|
919
|
+
resolve(value);
|
|
920
|
+
};
|
|
921
|
+
const handleKeypress = (_char, key) => {
|
|
922
|
+
if (key.name === 'escape') {
|
|
923
|
+
finish(null);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (key.name === 'n') {
|
|
927
|
+
const newIndex = choices.findIndex((choice) => choice.kind === 'new');
|
|
928
|
+
if (newIndex >= 0) {
|
|
929
|
+
box.select(newIndex);
|
|
930
|
+
screen.render();
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
screen.on('keypress', handleKeypress);
|
|
935
|
+
box.on('select', (_item, index) => finish(choices[index] ?? null));
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
async function promptRepositoryInput(screen) {
|
|
939
|
+
const prompt = blessed.prompt({
|
|
940
|
+
parent: screen,
|
|
941
|
+
border: 'line',
|
|
942
|
+
height: 7,
|
|
943
|
+
width: '60%',
|
|
944
|
+
top: 'center',
|
|
945
|
+
left: 'center',
|
|
946
|
+
label: ' Repository ',
|
|
947
|
+
tags: true,
|
|
948
|
+
keys: true,
|
|
949
|
+
vi: true,
|
|
950
|
+
style: {
|
|
951
|
+
border: { fg: 'cyan' },
|
|
952
|
+
bg: '#101522',
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
return await new Promise((resolve) => {
|
|
956
|
+
prompt.input('Repository to sync (owner/repo)', '', (_error, value) => {
|
|
957
|
+
prompt.destroy();
|
|
958
|
+
const parsed = parseOwnerRepoValue((value ?? '').trim());
|
|
959
|
+
resolve(parsed);
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
async function runColdStartSetup(service, screen, target, log, footer) {
|
|
964
|
+
log?.log(`[setup] starting initial setup for ${target.owner}/${target.repo}`);
|
|
965
|
+
footer?.setContent('Running initial sync, embed, and cluster. This can take a while.');
|
|
966
|
+
screen.render();
|
|
967
|
+
try {
|
|
968
|
+
const reporter = (message) => {
|
|
969
|
+
log?.log(message);
|
|
970
|
+
screen.render();
|
|
971
|
+
};
|
|
972
|
+
await service.syncRepository({
|
|
973
|
+
owner: target.owner,
|
|
974
|
+
repo: target.repo,
|
|
975
|
+
onProgress: reporter,
|
|
976
|
+
});
|
|
977
|
+
await service.embedRepository({
|
|
978
|
+
owner: target.owner,
|
|
979
|
+
repo: target.repo,
|
|
980
|
+
onProgress: reporter,
|
|
981
|
+
});
|
|
982
|
+
service.clusterRepository({
|
|
983
|
+
owner: target.owner,
|
|
984
|
+
repo: target.repo,
|
|
985
|
+
onProgress: reporter,
|
|
986
|
+
});
|
|
987
|
+
writeTuiRepositoryPreference(service.config, {
|
|
988
|
+
owner: target.owner,
|
|
989
|
+
repo: target.repo,
|
|
990
|
+
minClusterSize: 1,
|
|
991
|
+
sortMode: 'recent',
|
|
992
|
+
});
|
|
993
|
+
log?.log('[setup] initial setup complete');
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
catch (error) {
|
|
997
|
+
log?.log(`[setup] failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
export function parseOwnerRepoValue(value) {
|
|
1002
|
+
const parts = value.trim().split('/');
|
|
1003
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
return { owner: parts[0], repo: parts[1] };
|
|
1007
|
+
}
|
|
1008
|
+
function formatActivityTimestamp(now = new Date()) {
|
|
1009
|
+
return now.toISOString().slice(11, 19);
|
|
1010
|
+
}
|
|
1011
|
+
function parseDateOrNull(value) {
|
|
1012
|
+
if (!value)
|
|
1013
|
+
return null;
|
|
1014
|
+
const parsed = Date.parse(value);
|
|
1015
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
1016
|
+
}
|
|
1017
|
+
function formatAge(diffMs) {
|
|
1018
|
+
const safeDiffMs = Math.max(0, diffMs);
|
|
1019
|
+
const minuteMs = 60_000;
|
|
1020
|
+
const hourMs = 60 * minuteMs;
|
|
1021
|
+
const dayMs = 24 * hourMs;
|
|
1022
|
+
if (safeDiffMs < hourMs) {
|
|
1023
|
+
return `${Math.max(1, Math.floor(safeDiffMs / minuteMs))}m`;
|
|
1024
|
+
}
|
|
1025
|
+
if (safeDiffMs < dayMs) {
|
|
1026
|
+
return `${Math.floor(safeDiffMs / hourMs)}h`;
|
|
1027
|
+
}
|
|
1028
|
+
if (safeDiffMs < 14 * dayMs) {
|
|
1029
|
+
return `${Math.floor(safeDiffMs / dayMs)}d`;
|
|
1030
|
+
}
|
|
1031
|
+
return `${Math.floor(safeDiffMs / dayMs)}d`;
|
|
1032
|
+
}
|
|
1033
|
+
function formatRelativeTime(value, now = new Date()) {
|
|
1034
|
+
if (!value)
|
|
1035
|
+
return 'never';
|
|
1036
|
+
const parsed = new Date(value);
|
|
1037
|
+
if (Number.isNaN(parsed.getTime()))
|
|
1038
|
+
return value;
|
|
1039
|
+
const diffMs = Math.max(0, now.getTime() - parsed.getTime());
|
|
1040
|
+
const minuteMs = 60_000;
|
|
1041
|
+
const hourMs = 60 * minuteMs;
|
|
1042
|
+
const dayMs = 24 * hourMs;
|
|
1043
|
+
if (diffMs < hourMs) {
|
|
1044
|
+
const minutes = Math.max(1, Math.floor(diffMs / minuteMs));
|
|
1045
|
+
return `${minutes}m ago`;
|
|
1046
|
+
}
|
|
1047
|
+
if (diffMs < dayMs) {
|
|
1048
|
+
return `${Math.floor(diffMs / hourMs)}h ago`;
|
|
1049
|
+
}
|
|
1050
|
+
if (diffMs < 14 * dayMs) {
|
|
1051
|
+
return `${Math.floor(diffMs / dayMs)}d ago`;
|
|
1052
|
+
}
|
|
1053
|
+
return parsed.toISOString().slice(0, 10);
|
|
1054
|
+
}
|
|
1055
|
+
//# sourceMappingURL=app.js.map
|