git-watchtower 1.6.1 → 1.7.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/bin/git-watchtower.js +49 -2
- package/package.json +6 -1
- package/sounds/README.md +34 -0
- package/src/casino/index.js +721 -0
- package/src/casino/sounds.js +245 -0
- package/src/cli/args.js +239 -0
- package/src/config/loader.js +329 -0
- package/src/config/schema.js +305 -0
- package/src/git/branch.js +428 -0
- package/src/git/commands.js +416 -0
- package/src/git/pr.js +111 -0
- package/src/git/remote.js +127 -0
- package/src/index.js +179 -0
- package/src/polling/engine.js +157 -0
- package/src/server/process.js +329 -0
- package/src/server/static.js +95 -0
- package/src/state/store.js +527 -0
- package/src/telemetry/analytics.js +142 -0
- package/src/telemetry/config.js +123 -0
- package/src/telemetry/index.js +93 -0
- package/src/ui/actions.js +425 -0
- package/src/ui/ansi.js +498 -0
- package/src/ui/keybindings.js +198 -0
- package/src/ui/renderer.js +1326 -0
- package/src/utils/async.js +219 -0
- package/src/utils/browser.js +40 -0
- package/src/utils/errors.js +490 -0
- package/src/utils/gitignore.js +174 -0
- package/src/utils/sound.js +33 -0
- package/src/utils/time.js +27 -0
|
@@ -0,0 +1,1326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracted rendering functions for Git Watchtower terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* Each function takes a `state` object (plain data, no globals) and a `write`
|
|
5
|
+
* function (e.g. process.stdout.write bound to stdout). This makes the
|
|
6
|
+
* renderers pure-ish: they only read from `state` and only produce output
|
|
7
|
+
* through `write`, which simplifies testing and decouples them from the
|
|
8
|
+
* main process module.
|
|
9
|
+
*
|
|
10
|
+
* @module ui/renderer
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
ansi,
|
|
15
|
+
box,
|
|
16
|
+
truncate,
|
|
17
|
+
padRight,
|
|
18
|
+
padLeft,
|
|
19
|
+
drawBox,
|
|
20
|
+
clearArea,
|
|
21
|
+
visibleLength,
|
|
22
|
+
stripAnsi,
|
|
23
|
+
} = require('../ui/ansi');
|
|
24
|
+
const { formatTimeAgo } = require('../utils/time');
|
|
25
|
+
const { isBaseBranch } = require('../git/pr');
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// renderHeader
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render the top header bar.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} state
|
|
35
|
+
* @param {function} write
|
|
36
|
+
*/
|
|
37
|
+
function renderHeader(state, write) {
|
|
38
|
+
const width = state.terminalWidth;
|
|
39
|
+
const headerRow = state.casinoModeEnabled ? 2 : 1;
|
|
40
|
+
|
|
41
|
+
let statusIcon = { idle: ansi.green + '\u25CF', fetching: ansi.yellow + '\u27F3', error: ansi.red + '\u25CF' }[state.pollingStatus];
|
|
42
|
+
if (state.isOffline) statusIcon = ansi.red + '\u2298';
|
|
43
|
+
|
|
44
|
+
const soundIcon = state.soundEnabled ? ansi.green + '\uD83D\uDD14' : ansi.gray + '\uD83D\uDD15';
|
|
45
|
+
|
|
46
|
+
write(ansi.moveTo(headerRow, 1));
|
|
47
|
+
write(ansi.bgBlue + ansi.white + ansi.bold);
|
|
48
|
+
|
|
49
|
+
const leftContent = ` \uD83C\uDFF0 Git Watchtower ${ansi.dim}\u2502${ansi.bold} ${state.projectName}`;
|
|
50
|
+
const leftVisibleLen = 21 + state.projectName.length;
|
|
51
|
+
write(leftContent);
|
|
52
|
+
|
|
53
|
+
let badges = '';
|
|
54
|
+
let badgesVisibleLen = 0;
|
|
55
|
+
|
|
56
|
+
if (state.serverMode === 'command' && state.serverCrashed) {
|
|
57
|
+
const label = ' CRASHED ';
|
|
58
|
+
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
59
|
+
badgesVisibleLen += 1 + label.length;
|
|
60
|
+
}
|
|
61
|
+
if (state.isOffline) {
|
|
62
|
+
const label = ' OFFLINE ';
|
|
63
|
+
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
64
|
+
badgesVisibleLen += 1 + label.length;
|
|
65
|
+
}
|
|
66
|
+
if (state.isDetachedHead) {
|
|
67
|
+
const label = ' DETACHED HEAD ';
|
|
68
|
+
badges += ' ' + ansi.bgYellow + ansi.black + label + ansi.bgBlue + ansi.white;
|
|
69
|
+
badgesVisibleLen += 1 + label.length;
|
|
70
|
+
}
|
|
71
|
+
if (state.hasMergeConflict) {
|
|
72
|
+
const label = ' MERGE CONFLICT ';
|
|
73
|
+
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
74
|
+
badgesVisibleLen += 1 + label.length;
|
|
75
|
+
}
|
|
76
|
+
write(badges);
|
|
77
|
+
|
|
78
|
+
let modeLabel = '';
|
|
79
|
+
let modeBadge = '';
|
|
80
|
+
if (state.serverMode === 'static') {
|
|
81
|
+
modeLabel = ' STATIC ';
|
|
82
|
+
modeBadge = ansi.bgCyan + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
|
|
83
|
+
} else if (state.serverMode === 'command') {
|
|
84
|
+
modeLabel = ' COMMAND ';
|
|
85
|
+
modeBadge = ansi.bgGreen + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
|
|
86
|
+
} else {
|
|
87
|
+
modeLabel = ' MONITOR ';
|
|
88
|
+
modeBadge = ansi.bgMagenta + ansi.white + modeLabel + ansi.bgBlue + ansi.white;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let serverInfo = '';
|
|
92
|
+
let serverInfoVisible = '';
|
|
93
|
+
if (state.serverMode === 'none') {
|
|
94
|
+
serverInfoVisible = '';
|
|
95
|
+
} else {
|
|
96
|
+
const statusDot = state.serverRunning
|
|
97
|
+
? ansi.green + '\u25CF'
|
|
98
|
+
: (state.serverCrashed ? ansi.red + '\u25CF' : ansi.gray + '\u25CB');
|
|
99
|
+
serverInfoVisible = `localhost:${state.port} `;
|
|
100
|
+
serverInfo = statusDot + ansi.white + ` localhost:${state.port} `;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rightContent = `${modeBadge} ${serverInfo}${statusIcon}${ansi.bgBlue} ${soundIcon}${ansi.bgBlue} `;
|
|
104
|
+
const rightVisibleLen = modeLabel.length + 1 + serverInfoVisible.length + 5;
|
|
105
|
+
|
|
106
|
+
const usedSpace = leftVisibleLen + badgesVisibleLen + rightVisibleLen;
|
|
107
|
+
const padding = Math.max(1, width - usedSpace);
|
|
108
|
+
write(' '.repeat(padding));
|
|
109
|
+
write(rightContent);
|
|
110
|
+
write(ansi.reset);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// renderBranchList
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render the branch list box. Returns the row number at the bottom of the
|
|
119
|
+
* box so that subsequent sections know where to start.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} state
|
|
122
|
+
* @param {function} write
|
|
123
|
+
* @returns {number} The row immediately after the branch list box.
|
|
124
|
+
*/
|
|
125
|
+
function renderBranchList(state, write) {
|
|
126
|
+
const startRow = state.casinoModeEnabled ? 4 : 3;
|
|
127
|
+
const boxWidth = state.terminalWidth;
|
|
128
|
+
const contentWidth = boxWidth - 4;
|
|
129
|
+
const height = Math.min(state.visibleBranchCount * 2 + 4, Math.floor(state.terminalHeight * 0.5));
|
|
130
|
+
|
|
131
|
+
const displayBranches = state.filteredBranches !== null ? state.filteredBranches : state.branches;
|
|
132
|
+
const boxTitle = state.searchMode
|
|
133
|
+
? `BRANCHES (/${state.searchQuery}_)`
|
|
134
|
+
: 'ACTIVE BRANCHES';
|
|
135
|
+
|
|
136
|
+
write(drawBox(startRow, 1, boxWidth, height, boxTitle, ansi.cyan));
|
|
137
|
+
|
|
138
|
+
// Clear content area
|
|
139
|
+
for (let i = 1; i < height - 1; i++) {
|
|
140
|
+
write(ansi.moveTo(startRow + i, 2));
|
|
141
|
+
write(' '.repeat(contentWidth + 2));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Header line
|
|
145
|
+
write(ansi.moveTo(startRow + 1, 2));
|
|
146
|
+
write(ansi.gray + '\u2500'.repeat(contentWidth + 2) + ansi.reset);
|
|
147
|
+
|
|
148
|
+
if (displayBranches.length === 0) {
|
|
149
|
+
write(ansi.moveTo(startRow + 3, 4));
|
|
150
|
+
if (state.searchMode && state.searchQuery) {
|
|
151
|
+
write(ansi.gray + `No branches matching "${state.searchQuery}"` + ansi.reset);
|
|
152
|
+
} else {
|
|
153
|
+
write(ansi.gray + "No branches found. Press 'f' to fetch." + ansi.reset);
|
|
154
|
+
}
|
|
155
|
+
return startRow + height;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let row = startRow + 2;
|
|
159
|
+
for (let i = 0; i < displayBranches.length && i < state.visibleBranchCount; i++) {
|
|
160
|
+
const branch = displayBranches[i];
|
|
161
|
+
const isSelected = i === state.selectedIndex;
|
|
162
|
+
const isCurrent = branch.name === state.currentBranch;
|
|
163
|
+
const timeAgo = formatTimeAgo(branch.date);
|
|
164
|
+
const sparkline = state.sparklineCache.get(branch.name) || ' ';
|
|
165
|
+
const prStatus = state.branchPrStatusMap.get(branch.name);
|
|
166
|
+
const isBranchBase = isBaseBranch(branch.name);
|
|
167
|
+
const isMerged = !isBranchBase && prStatus && prStatus.state === 'MERGED';
|
|
168
|
+
const hasOpenPr = prStatus && prStatus.state === 'OPEN';
|
|
169
|
+
|
|
170
|
+
write(ansi.moveTo(row, 2));
|
|
171
|
+
const cursor = isSelected ? ' \u25B6 ' : ' ';
|
|
172
|
+
const maxNameLen = contentWidth - 38;
|
|
173
|
+
const displayName = truncate(branch.name, maxNameLen);
|
|
174
|
+
const namePadding = Math.max(1, maxNameLen - displayName.length + 2);
|
|
175
|
+
|
|
176
|
+
if (isSelected) write(ansi.inverse);
|
|
177
|
+
write(cursor);
|
|
178
|
+
|
|
179
|
+
if (branch.isDeleted) {
|
|
180
|
+
write(ansi.gray + ansi.dim + displayName + ansi.reset);
|
|
181
|
+
if (isSelected) write(ansi.inverse);
|
|
182
|
+
} else if (isMerged && !isCurrent) {
|
|
183
|
+
write(ansi.dim + ansi.fg256(103) + displayName + ansi.reset);
|
|
184
|
+
if (isSelected) write(ansi.inverse);
|
|
185
|
+
} else if (isCurrent) {
|
|
186
|
+
write(ansi.green + ansi.bold + displayName + ansi.reset);
|
|
187
|
+
if (isSelected) write(ansi.inverse);
|
|
188
|
+
} else if (branch.justUpdated) {
|
|
189
|
+
write(ansi.yellow + displayName + ansi.reset);
|
|
190
|
+
if (isSelected) write(ansi.inverse);
|
|
191
|
+
} else {
|
|
192
|
+
write(displayName);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
write(' '.repeat(namePadding));
|
|
196
|
+
|
|
197
|
+
// Sparkline
|
|
198
|
+
if (isSelected) write(ansi.reset);
|
|
199
|
+
if (isMerged && !isCurrent) {
|
|
200
|
+
write(ansi.dim + ansi.fg256(60) + sparkline + ansi.reset);
|
|
201
|
+
} else {
|
|
202
|
+
write(ansi.fg256(39) + sparkline + ansi.reset);
|
|
203
|
+
}
|
|
204
|
+
if (isSelected) write(ansi.inverse);
|
|
205
|
+
|
|
206
|
+
// PR status dot
|
|
207
|
+
if (isSelected) write(ansi.reset);
|
|
208
|
+
if (isMerged) {
|
|
209
|
+
write(ansi.dim + ansi.magenta + '\u25CF' + ansi.reset);
|
|
210
|
+
} else if (hasOpenPr) {
|
|
211
|
+
write(ansi.brightGreen + '\u25CF' + ansi.reset);
|
|
212
|
+
} else {
|
|
213
|
+
write(' ');
|
|
214
|
+
}
|
|
215
|
+
if (isSelected) write(ansi.inverse);
|
|
216
|
+
|
|
217
|
+
// Status badge
|
|
218
|
+
if (branch.isDeleted) {
|
|
219
|
+
if (isSelected) write(ansi.reset);
|
|
220
|
+
write(ansi.red + ansi.dim + '\u2717 DELETED' + ansi.reset);
|
|
221
|
+
if (isSelected) write(ansi.inverse);
|
|
222
|
+
} else if (isMerged && !isCurrent && !branch.isNew && !branch.hasUpdates) {
|
|
223
|
+
if (isSelected) write(ansi.reset);
|
|
224
|
+
write(ansi.dim + ansi.magenta + '\u2713 MERGED ' + ansi.reset);
|
|
225
|
+
if (isSelected) write(ansi.inverse);
|
|
226
|
+
} else if (isCurrent) {
|
|
227
|
+
if (isSelected) write(ansi.reset);
|
|
228
|
+
write(ansi.green + '\u2605 CURRENT' + ansi.reset);
|
|
229
|
+
if (isSelected) write(ansi.inverse);
|
|
230
|
+
} else if (branch.isNew) {
|
|
231
|
+
if (isSelected) write(ansi.reset);
|
|
232
|
+
write(ansi.magenta + '\u2726 NEW ' + ansi.reset);
|
|
233
|
+
if (isSelected) write(ansi.inverse);
|
|
234
|
+
} else if (branch.hasUpdates) {
|
|
235
|
+
if (isSelected) write(ansi.reset);
|
|
236
|
+
write(ansi.yellow + '\u2193 UPDATES' + ansi.reset);
|
|
237
|
+
if (isSelected) write(ansi.inverse);
|
|
238
|
+
} else {
|
|
239
|
+
write(' ');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Time ago
|
|
243
|
+
write(' ');
|
|
244
|
+
if (isSelected) write(ansi.reset);
|
|
245
|
+
write(ansi.gray + padLeft(timeAgo, 10) + ansi.reset);
|
|
246
|
+
if (isSelected) write(ansi.reset);
|
|
247
|
+
|
|
248
|
+
row++;
|
|
249
|
+
|
|
250
|
+
// Commit info line
|
|
251
|
+
write(ansi.moveTo(row, 2));
|
|
252
|
+
if (isMerged && !isCurrent) {
|
|
253
|
+
write(ansi.dim + ' \u2514\u2500 ' + ansi.reset);
|
|
254
|
+
write(ansi.dim + ansi.cyan + (branch.commit || '???????') + ansi.reset);
|
|
255
|
+
write(ansi.dim + ' \u2022 ' + ansi.reset);
|
|
256
|
+
const prTag = ansi.dim + ansi.magenta + '#' + prStatus.number + ansi.reset + ansi.dim + ' ';
|
|
257
|
+
write(prTag + ansi.gray + ansi.dim + truncate(branch.subject || 'No commit message', contentWidth - 28) + ansi.reset);
|
|
258
|
+
} else {
|
|
259
|
+
write(' \u2514\u2500 ');
|
|
260
|
+
write(ansi.cyan + (branch.commit || '???????') + ansi.reset);
|
|
261
|
+
write(' \u2022 ');
|
|
262
|
+
if (hasOpenPr) {
|
|
263
|
+
const prTag = ansi.brightGreen + '#' + prStatus.number + ansi.reset + ' ';
|
|
264
|
+
write(prTag + ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 28) + ansi.reset);
|
|
265
|
+
} else {
|
|
266
|
+
write(ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 22) + ansi.reset);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
row++;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return startRow + height;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// renderActivityLog
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Render the activity log box below the branch list.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} state
|
|
284
|
+
* @param {function} write
|
|
285
|
+
* @param {number} startRow - Row where the box should begin.
|
|
286
|
+
* @returns {number} The row immediately after the activity log box.
|
|
287
|
+
*/
|
|
288
|
+
function renderActivityLog(state, write, startRow) {
|
|
289
|
+
const boxWidth = state.terminalWidth;
|
|
290
|
+
const contentWidth = boxWidth - 4;
|
|
291
|
+
const height = Math.min(state.maxLogEntries + 3, state.terminalHeight - startRow - 4);
|
|
292
|
+
|
|
293
|
+
write(drawBox(startRow, 1, boxWidth, height, 'ACTIVITY LOG', ansi.gray));
|
|
294
|
+
|
|
295
|
+
for (let i = 1; i < height - 1; i++) {
|
|
296
|
+
write(ansi.moveTo(startRow + i, 2));
|
|
297
|
+
write(' '.repeat(contentWidth + 2));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let row = startRow + 1;
|
|
301
|
+
for (let i = 0; i < state.activityLog.length && i < height - 2; i++) {
|
|
302
|
+
const entry = state.activityLog[i];
|
|
303
|
+
write(ansi.moveTo(row, 3));
|
|
304
|
+
write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
|
|
305
|
+
write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
|
|
306
|
+
write(truncate(entry.message, contentWidth - 16));
|
|
307
|
+
row++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (state.activityLog.length === 0) {
|
|
311
|
+
write(ansi.moveTo(startRow + 1, 3));
|
|
312
|
+
write(ansi.gray + 'No activity yet...' + ansi.reset);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return startRow + height;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// renderCasinoStats (stub)
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Placeholder for casino stats rendering. The actual casino rendering
|
|
324
|
+
* depends on the casino module and stays in bin/ for now.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} state
|
|
327
|
+
* @param {function} write
|
|
328
|
+
* @param {number} startRow
|
|
329
|
+
* @returns {number} The unchanged startRow (no-op).
|
|
330
|
+
*/
|
|
331
|
+
function renderCasinoStats(state, write, startRow) {
|
|
332
|
+
if (!state.casinoModeEnabled) return startRow;
|
|
333
|
+
// Delegates to casino module; actual rendering stays in bin/
|
|
334
|
+
return startRow;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// renderFooter
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Render the bottom footer/key-binding bar.
|
|
343
|
+
*
|
|
344
|
+
* @param {object} state
|
|
345
|
+
* @param {function} write
|
|
346
|
+
*/
|
|
347
|
+
function renderFooter(state, write) {
|
|
348
|
+
const row = state.terminalHeight - 1;
|
|
349
|
+
write(ansi.moveTo(row, 1));
|
|
350
|
+
write(ansi.bgBlack + ansi.white);
|
|
351
|
+
write(' ');
|
|
352
|
+
write(ansi.gray + '[\u2191\u2193]' + ansi.reset + ansi.bgBlack + ' Nav ');
|
|
353
|
+
write(ansi.gray + '[/]' + ansi.reset + ansi.bgBlack + ' Search ');
|
|
354
|
+
write(ansi.gray + '[v]' + ansi.reset + ansi.bgBlack + ' Preview ');
|
|
355
|
+
write(ansi.gray + '[Enter]' + ansi.reset + ansi.bgBlack + ' Switch ');
|
|
356
|
+
write(ansi.gray + '[h]' + ansi.reset + ansi.bgBlack + ' History ');
|
|
357
|
+
write(ansi.gray + '[i]' + ansi.reset + ansi.bgBlack + ' Info ');
|
|
358
|
+
write(ansi.gray + '[b]' + ansi.reset + ansi.bgBlack + ' Actions ');
|
|
359
|
+
|
|
360
|
+
if (!state.noServer) {
|
|
361
|
+
write(ansi.gray + '[l]' + ansi.reset + ansi.bgBlack + ' Logs ');
|
|
362
|
+
write(ansi.gray + '[o]' + ansi.reset + ansi.bgBlack + ' Open ');
|
|
363
|
+
}
|
|
364
|
+
if (state.serverMode === 'static') {
|
|
365
|
+
write(ansi.gray + '[r]' + ansi.reset + ansi.bgBlack + ' Reload ');
|
|
366
|
+
} else if (state.serverMode === 'command') {
|
|
367
|
+
write(ansi.gray + '[R]' + ansi.reset + ansi.bgBlack + ' Restart ');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
write(ansi.gray + '[\u00B1]' + ansi.reset + ansi.bgBlack + ' List:' + ansi.cyan + state.visibleBranchCount + ansi.reset + ansi.bgBlack + ' ');
|
|
371
|
+
|
|
372
|
+
if (state.casinoModeEnabled) {
|
|
373
|
+
write(ansi.brightMagenta + '[c]' + ansi.reset + ansi.bgBlack + ' \uD83C\uDFB0 ');
|
|
374
|
+
} else {
|
|
375
|
+
write(ansi.gray + '[c]' + ansi.reset + ansi.bgBlack + ' Casino ');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
write(ansi.gray + '[d]' + ansi.reset + ansi.bgBlack + ' Cleanup ');
|
|
379
|
+
write(ansi.gray + '[q]' + ansi.reset + ansi.bgBlack + ' Quit ');
|
|
380
|
+
write(ansi.reset);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// renderFlash
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Render a centered flash notification overlay (e.g. "NEW UPDATE").
|
|
389
|
+
*
|
|
390
|
+
* @param {object} state
|
|
391
|
+
* @param {function} write
|
|
392
|
+
*/
|
|
393
|
+
function renderFlash(state, write) {
|
|
394
|
+
if (!state.flashMessage) return;
|
|
395
|
+
|
|
396
|
+
const width = 50;
|
|
397
|
+
const height = 5;
|
|
398
|
+
const col = Math.floor((state.terminalWidth - width) / 2);
|
|
399
|
+
const row = Math.floor((state.terminalHeight - height) / 2);
|
|
400
|
+
|
|
401
|
+
// Draw double-line box
|
|
402
|
+
write(ansi.moveTo(row, col));
|
|
403
|
+
write(ansi.yellow + ansi.bold);
|
|
404
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
405
|
+
|
|
406
|
+
for (let i = 1; i < height - 1; i++) {
|
|
407
|
+
write(ansi.moveTo(row + i, col));
|
|
408
|
+
write(box.dVertical + ' '.repeat(width - 2) + box.dVertical);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
412
|
+
write(box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
413
|
+
write(ansi.reset);
|
|
414
|
+
|
|
415
|
+
// Content
|
|
416
|
+
write(ansi.moveTo(row + 1, col + Math.floor((width - 16) / 2)));
|
|
417
|
+
write(ansi.yellow + ansi.bold + '\u26A1 NEW UPDATE \u26A1' + ansi.reset);
|
|
418
|
+
|
|
419
|
+
write(ansi.moveTo(row + 2, col + 2));
|
|
420
|
+
const truncMsg = truncate(state.flashMessage, width - 4);
|
|
421
|
+
write(ansi.white + truncMsg + ansi.reset);
|
|
422
|
+
|
|
423
|
+
write(ansi.moveTo(row + 3, col + Math.floor((width - 22) / 2)));
|
|
424
|
+
write(ansi.gray + 'Press any key to dismiss' + ansi.reset);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// renderErrorToast
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Render a centered error toast overlay.
|
|
433
|
+
*
|
|
434
|
+
* @param {object} state
|
|
435
|
+
* @param {function} write
|
|
436
|
+
*/
|
|
437
|
+
function renderErrorToast(state, write) {
|
|
438
|
+
if (!state.errorToast) return;
|
|
439
|
+
|
|
440
|
+
const width = Math.min(60, state.terminalWidth - 4);
|
|
441
|
+
const col = Math.floor((state.terminalWidth - width) / 2);
|
|
442
|
+
const row = 2; // Near the top, below header
|
|
443
|
+
|
|
444
|
+
// Calculate height based on content
|
|
445
|
+
const lines = [];
|
|
446
|
+
lines.push(state.errorToast.title || 'Git Error');
|
|
447
|
+
lines.push('');
|
|
448
|
+
|
|
449
|
+
// Word wrap the message
|
|
450
|
+
const msgWords = state.errorToast.message.split(' ');
|
|
451
|
+
let currentLine = '';
|
|
452
|
+
for (const word of msgWords) {
|
|
453
|
+
if ((currentLine + ' ' + word).length > width - 6) {
|
|
454
|
+
lines.push(currentLine.trim());
|
|
455
|
+
currentLine = word;
|
|
456
|
+
} else {
|
|
457
|
+
currentLine += (currentLine ? ' ' : '') + word;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (currentLine) lines.push(currentLine.trim());
|
|
461
|
+
|
|
462
|
+
if (state.errorToast.hint) {
|
|
463
|
+
lines.push('');
|
|
464
|
+
lines.push(state.errorToast.hint);
|
|
465
|
+
}
|
|
466
|
+
lines.push('');
|
|
467
|
+
lines.push('Press any key to dismiss');
|
|
468
|
+
|
|
469
|
+
const height = lines.length + 2;
|
|
470
|
+
|
|
471
|
+
// Draw red error box
|
|
472
|
+
write(ansi.moveTo(row, col));
|
|
473
|
+
write(ansi.red + ansi.bold);
|
|
474
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
475
|
+
|
|
476
|
+
for (let i = 1; i < height - 1; i++) {
|
|
477
|
+
write(ansi.moveTo(row + i, col));
|
|
478
|
+
write(ansi.red + box.dVertical + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 2) + ansi.reset + ansi.red + box.dVertical + ansi.reset);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
482
|
+
write(ansi.red + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
483
|
+
write(ansi.reset);
|
|
484
|
+
|
|
485
|
+
// Render content
|
|
486
|
+
let contentRow = row + 1;
|
|
487
|
+
for (let i = 0; i < lines.length; i++) {
|
|
488
|
+
const line = lines[i];
|
|
489
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
490
|
+
write(ansi.bgRed + ansi.white);
|
|
491
|
+
|
|
492
|
+
if (i === 0) {
|
|
493
|
+
// Title line - centered and bold
|
|
494
|
+
const titlePadding = Math.floor((width - 4 - line.length) / 2);
|
|
495
|
+
write(' '.repeat(titlePadding) + ansi.bold + line + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 4 - titlePadding - line.length));
|
|
496
|
+
} else if (line === 'Press any key to dismiss') {
|
|
497
|
+
// Instruction line - centered and dimmer
|
|
498
|
+
const lPadding = Math.floor((width - 4 - line.length) / 2);
|
|
499
|
+
write(ansi.reset + ansi.bgRed + ansi.gray + ' '.repeat(lPadding) + line + ' '.repeat(width - 4 - lPadding - line.length));
|
|
500
|
+
} else if (state.errorToast.hint && line === state.errorToast.hint) {
|
|
501
|
+
// Hint line - yellow on red
|
|
502
|
+
const lPadding = Math.floor((width - 4 - line.length) / 2);
|
|
503
|
+
write(ansi.reset + ansi.bgRed + ansi.yellow + ' '.repeat(lPadding) + line + ' '.repeat(width - 4 - lPadding - line.length));
|
|
504
|
+
} else {
|
|
505
|
+
// Regular content
|
|
506
|
+
write(padRight(line, width - 4));
|
|
507
|
+
}
|
|
508
|
+
write(ansi.reset);
|
|
509
|
+
contentRow++;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// renderPreview
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Render the branch preview overlay showing recent commits and changed files.
|
|
519
|
+
*
|
|
520
|
+
* @param {object} state
|
|
521
|
+
* @param {function} write
|
|
522
|
+
*/
|
|
523
|
+
function renderPreview(state, write) {
|
|
524
|
+
if (!state.previewMode || !state.previewData) return;
|
|
525
|
+
|
|
526
|
+
const width = Math.min(60, state.terminalWidth - 4);
|
|
527
|
+
const height = 16;
|
|
528
|
+
const col = Math.floor((state.terminalWidth - width) / 2);
|
|
529
|
+
const row = Math.floor((state.terminalHeight - height) / 2);
|
|
530
|
+
|
|
531
|
+
const displayBranches = state.filteredBranches !== null ? state.filteredBranches : state.branches;
|
|
532
|
+
const branch = displayBranches[state.selectedIndex];
|
|
533
|
+
if (!branch) return;
|
|
534
|
+
|
|
535
|
+
// Draw box
|
|
536
|
+
write(ansi.moveTo(row, col));
|
|
537
|
+
write(ansi.cyan + ansi.bold);
|
|
538
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
539
|
+
|
|
540
|
+
for (let i = 1; i < height - 1; i++) {
|
|
541
|
+
write(ansi.moveTo(row + i, col));
|
|
542
|
+
write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
546
|
+
write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
547
|
+
write(ansi.reset);
|
|
548
|
+
|
|
549
|
+
// Title
|
|
550
|
+
const title = ` Preview: ${truncate(branch.name, width - 14)} `;
|
|
551
|
+
write(ansi.moveTo(row, col + 2));
|
|
552
|
+
write(ansi.cyan + ansi.bold + title + ansi.reset);
|
|
553
|
+
|
|
554
|
+
// Commits section
|
|
555
|
+
write(ansi.moveTo(row + 2, col + 2));
|
|
556
|
+
write(ansi.white + ansi.bold + 'Recent Commits:' + ansi.reset);
|
|
557
|
+
|
|
558
|
+
let contentRow = row + 3;
|
|
559
|
+
if (state.previewData.commits.length === 0) {
|
|
560
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
561
|
+
write(ansi.gray + '(no commits)' + ansi.reset);
|
|
562
|
+
contentRow++;
|
|
563
|
+
} else {
|
|
564
|
+
for (const commit of state.previewData.commits.slice(0, 5)) {
|
|
565
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
566
|
+
write(ansi.yellow + commit.hash + ansi.reset + ' ');
|
|
567
|
+
write(ansi.gray + truncate(commit.message, width - 14) + ansi.reset);
|
|
568
|
+
contentRow++;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Files section
|
|
573
|
+
contentRow++;
|
|
574
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
575
|
+
write(ansi.white + ansi.bold + 'Files Changed vs HEAD:' + ansi.reset);
|
|
576
|
+
contentRow++;
|
|
577
|
+
|
|
578
|
+
if (state.previewData.filesChanged.length === 0) {
|
|
579
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
580
|
+
write(ansi.gray + '(no changes or same as current)' + ansi.reset);
|
|
581
|
+
} else {
|
|
582
|
+
for (const file of state.previewData.filesChanged.slice(0, 5)) {
|
|
583
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
584
|
+
write(ansi.green + '\u2022 ' + ansi.reset + truncate(file, width - 8));
|
|
585
|
+
contentRow++;
|
|
586
|
+
}
|
|
587
|
+
if (state.previewData.filesChanged.length > 5) {
|
|
588
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
589
|
+
write(ansi.gray + `... and ${state.previewData.filesChanged.length - 5} more` + ansi.reset);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Instructions
|
|
594
|
+
write(ansi.moveTo(row + height - 2, col + Math.floor((width - 26) / 2)));
|
|
595
|
+
write(ansi.gray + 'Press [v] or [Esc] to close' + ansi.reset);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// renderHistory
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Render the branch-switch history overlay.
|
|
604
|
+
*
|
|
605
|
+
* @param {object} state
|
|
606
|
+
* @param {function} write
|
|
607
|
+
*/
|
|
608
|
+
function renderHistory(state, write) {
|
|
609
|
+
const width = Math.min(50, state.terminalWidth - 4);
|
|
610
|
+
const height = Math.min(state.switchHistory.length + 5, 15);
|
|
611
|
+
const col = Math.floor((state.terminalWidth - width) / 2);
|
|
612
|
+
const row = Math.floor((state.terminalHeight - height) / 2);
|
|
613
|
+
|
|
614
|
+
// Draw box
|
|
615
|
+
write(ansi.moveTo(row, col));
|
|
616
|
+
write(ansi.magenta + ansi.bold);
|
|
617
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
618
|
+
|
|
619
|
+
for (let i = 1; i < height - 1; i++) {
|
|
620
|
+
write(ansi.moveTo(row + i, col));
|
|
621
|
+
write(ansi.magenta + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.magenta + box.dVertical + ansi.reset);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
625
|
+
write(ansi.magenta + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
626
|
+
write(ansi.reset);
|
|
627
|
+
|
|
628
|
+
// Title
|
|
629
|
+
write(ansi.moveTo(row, col + 2));
|
|
630
|
+
write(ansi.magenta + ansi.bold + ' Switch History ' + ansi.reset);
|
|
631
|
+
|
|
632
|
+
// Content
|
|
633
|
+
if (state.switchHistory.length === 0) {
|
|
634
|
+
write(ansi.moveTo(row + 2, col + 3));
|
|
635
|
+
write(ansi.gray + 'No branch switches yet' + ansi.reset);
|
|
636
|
+
} else {
|
|
637
|
+
let contentRow = row + 2;
|
|
638
|
+
for (let i = 0; i < Math.min(state.switchHistory.length, height - 4); i++) {
|
|
639
|
+
const entry = state.switchHistory[i];
|
|
640
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
641
|
+
if (i === 0) {
|
|
642
|
+
write(ansi.yellow + '\u2192 ' + ansi.reset); // Most recent
|
|
643
|
+
} else {
|
|
644
|
+
write(ansi.gray + ' ' + ansi.reset);
|
|
645
|
+
}
|
|
646
|
+
write(truncate(entry.from, 15) + ansi.gray + ' \u2192 ' + ansi.reset);
|
|
647
|
+
write(ansi.cyan + truncate(entry.to, 15) + ansi.reset);
|
|
648
|
+
contentRow++;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Instructions
|
|
653
|
+
write(ansi.moveTo(row + height - 2, col + 2));
|
|
654
|
+
write(ansi.gray + '[u] Undo last [h]/[Esc] Close' + ansi.reset);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// renderLogView
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Render the full-screen log viewer overlay with activity/server tabs.
|
|
663
|
+
*
|
|
664
|
+
* NOTE: The original bin/ version mutates `logScrollOffset` in place to
|
|
665
|
+
* clamp it. Since we receive state as a plain object the caller is
|
|
666
|
+
* responsible for clamping before calling this function, or the caller
|
|
667
|
+
* can read back the clamped value from the returned object if we decide
|
|
668
|
+
* to return one in the future. For now we treat `logScrollOffset` as
|
|
669
|
+
* already clamped.
|
|
670
|
+
*
|
|
671
|
+
* @param {object} state
|
|
672
|
+
* @param {function} write
|
|
673
|
+
*/
|
|
674
|
+
function renderLogView(state, write) {
|
|
675
|
+
if (!state.logViewMode) return;
|
|
676
|
+
|
|
677
|
+
const width = Math.min(state.terminalWidth - 4, 100);
|
|
678
|
+
const height = Math.min(state.terminalHeight - 4, 30);
|
|
679
|
+
const col = Math.floor((state.terminalWidth - width) / 2);
|
|
680
|
+
const row = Math.floor((state.terminalHeight - height) / 2);
|
|
681
|
+
|
|
682
|
+
// Determine which log to display
|
|
683
|
+
const isServerTab = state.logViewTab === 'server';
|
|
684
|
+
const logData = isServerTab ? state.serverLogBuffer : state.activityLog;
|
|
685
|
+
|
|
686
|
+
// Draw box
|
|
687
|
+
write(ansi.moveTo(row, col));
|
|
688
|
+
write(ansi.yellow + ansi.bold);
|
|
689
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
690
|
+
|
|
691
|
+
for (let i = 1; i < height - 1; i++) {
|
|
692
|
+
write(ansi.moveTo(row + i, col));
|
|
693
|
+
write(ansi.yellow + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.yellow + box.dVertical + ansi.reset);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
697
|
+
write(ansi.yellow + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
698
|
+
write(ansi.reset);
|
|
699
|
+
|
|
700
|
+
// Title with tabs
|
|
701
|
+
const activityTab = state.logViewTab === 'activity'
|
|
702
|
+
? ansi.bgWhite + ansi.black + ' 1:Activity ' + ansi.reset + ansi.yellow
|
|
703
|
+
: ansi.gray + ' 1:Activity ' + ansi.yellow;
|
|
704
|
+
const serverTab = state.logViewTab === 'server'
|
|
705
|
+
? ansi.bgWhite + ansi.black + ' 2:Server ' + ansi.reset + ansi.yellow
|
|
706
|
+
: ansi.gray + ' 2:Server ' + ansi.yellow;
|
|
707
|
+
|
|
708
|
+
// Server status (only show on server tab)
|
|
709
|
+
let statusIndicator = '';
|
|
710
|
+
if (isServerTab && state.serverMode === 'command') {
|
|
711
|
+
const statusText = state.serverRunning ? ansi.green + 'RUNNING' : (state.serverCrashed ? ansi.red + 'CRASHED' : ansi.gray + 'STOPPED');
|
|
712
|
+
statusIndicator = ` [${statusText}${ansi.yellow}]`;
|
|
713
|
+
} else if (isServerTab && state.serverMode === 'static') {
|
|
714
|
+
statusIndicator = ansi.green + ' [STATIC]' + ansi.yellow;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
write(ansi.moveTo(row, col + 2));
|
|
718
|
+
write(ansi.yellow + ansi.bold + ' ' + activityTab + ' ' + serverTab + statusIndicator + ' ' + ansi.reset);
|
|
719
|
+
|
|
720
|
+
// Content
|
|
721
|
+
const contentHeight = height - 4;
|
|
722
|
+
const maxScroll = Math.max(0, logData.length - contentHeight);
|
|
723
|
+
const logScrollOffset = Math.min(Math.max(0, state.logScrollOffset), maxScroll);
|
|
724
|
+
|
|
725
|
+
let contentRow = row + 2;
|
|
726
|
+
|
|
727
|
+
if (logData.length === 0) {
|
|
728
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
729
|
+
write(ansi.gray + (isServerTab ? 'No server output yet...' : 'No activity yet...') + ansi.reset);
|
|
730
|
+
} else if (isServerTab) {
|
|
731
|
+
// Server log: newest at bottom, scroll from bottom
|
|
732
|
+
const startIndex = Math.max(0, state.serverLogBuffer.length - contentHeight - logScrollOffset);
|
|
733
|
+
const endIndex = Math.min(state.serverLogBuffer.length, startIndex + contentHeight);
|
|
734
|
+
|
|
735
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
736
|
+
const entry = state.serverLogBuffer[i];
|
|
737
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
738
|
+
const lineText = truncate(entry.line, width - 4);
|
|
739
|
+
if (entry.isError) {
|
|
740
|
+
write(ansi.red + lineText + ansi.reset);
|
|
741
|
+
} else {
|
|
742
|
+
write(lineText);
|
|
743
|
+
}
|
|
744
|
+
contentRow++;
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
// Activity log: newest first, scroll from top
|
|
748
|
+
const startIndex = logScrollOffset;
|
|
749
|
+
const endIndex = Math.min(state.activityLog.length, startIndex + contentHeight);
|
|
750
|
+
|
|
751
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
752
|
+
const entry = state.activityLog[i];
|
|
753
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
754
|
+
write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
|
|
755
|
+
write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
|
|
756
|
+
write(truncate(entry.message, width - 18));
|
|
757
|
+
contentRow++;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Scroll indicator
|
|
762
|
+
if (logData.length > contentHeight) {
|
|
763
|
+
const scrollPercent = isServerTab
|
|
764
|
+
? Math.round((1 - logScrollOffset / maxScroll) * 100)
|
|
765
|
+
: Math.round((logScrollOffset / maxScroll) * 100);
|
|
766
|
+
write(ansi.moveTo(row, col + width - 10));
|
|
767
|
+
write(ansi.gray + ` ${scrollPercent}% ` + ansi.reset);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Instructions
|
|
771
|
+
write(ansi.moveTo(row + height - 2, col + 2));
|
|
772
|
+
const restartHint = state.serverMode === 'command' ? '[R] Restart ' : '';
|
|
773
|
+
write(ansi.gray + '[1/2] Switch Tab [\u2191\u2193] Scroll ' + restartHint + '[l]/[Esc] Close' + ansi.reset);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---------------------------------------------------------------------------
|
|
777
|
+
// renderInfo
|
|
778
|
+
// ---------------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Render the server/status info overlay.
|
|
782
|
+
*
|
|
783
|
+
* @param {object} state
|
|
784
|
+
* @param {function} write
|
|
785
|
+
*/
|
|
786
|
+
function renderInfo(state, write) {
|
|
787
|
+
const width = Math.min(50, state.terminalWidth - 4);
|
|
788
|
+
const height = state.noServer ? 9 : 12;
|
|
789
|
+
const col = Math.floor((state.terminalWidth - width) / 2);
|
|
790
|
+
const row = Math.floor((state.terminalHeight - height) / 2);
|
|
791
|
+
|
|
792
|
+
// Draw box
|
|
793
|
+
write(ansi.moveTo(row, col));
|
|
794
|
+
write(ansi.cyan + ansi.bold);
|
|
795
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
796
|
+
|
|
797
|
+
for (let i = 1; i < height - 1; i++) {
|
|
798
|
+
write(ansi.moveTo(row + i, col));
|
|
799
|
+
write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
803
|
+
write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
804
|
+
write(ansi.reset);
|
|
805
|
+
|
|
806
|
+
// Title
|
|
807
|
+
write(ansi.moveTo(row, col + 2));
|
|
808
|
+
write(ansi.cyan + ansi.bold + (state.noServer ? ' Status Info ' : ' Server Info ') + ansi.reset);
|
|
809
|
+
|
|
810
|
+
// Content
|
|
811
|
+
let contentRow = row + 2;
|
|
812
|
+
|
|
813
|
+
if (!state.noServer) {
|
|
814
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
815
|
+
write(ansi.white + ansi.bold + 'Dev Server' + ansi.reset);
|
|
816
|
+
contentRow++;
|
|
817
|
+
|
|
818
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
819
|
+
write(ansi.gray + 'URL: ' + ansi.reset + ansi.green + `http://localhost:${state.port}` + ansi.reset);
|
|
820
|
+
contentRow++;
|
|
821
|
+
|
|
822
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
823
|
+
write(ansi.gray + 'Port: ' + ansi.reset + ansi.yellow + state.port + ansi.reset);
|
|
824
|
+
contentRow++;
|
|
825
|
+
|
|
826
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
827
|
+
write(ansi.gray + 'Connected browsers: ' + ansi.reset + ansi.cyan + state.clientCount + ansi.reset);
|
|
828
|
+
contentRow++;
|
|
829
|
+
|
|
830
|
+
contentRow++;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
834
|
+
write(ansi.white + ansi.bold + 'Git Polling' + ansi.reset);
|
|
835
|
+
contentRow++;
|
|
836
|
+
|
|
837
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
838
|
+
write(ansi.gray + 'Interval: ' + ansi.reset + `${state.adaptivePollInterval / 1000}s`);
|
|
839
|
+
contentRow++;
|
|
840
|
+
|
|
841
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
842
|
+
write(ansi.gray + 'Status: ' + ansi.reset + (state.isOffline ? ansi.red + 'Offline' : ansi.green + 'Online') + ansi.reset);
|
|
843
|
+
contentRow++;
|
|
844
|
+
|
|
845
|
+
if (state.noServer) {
|
|
846
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
847
|
+
write(ansi.gray + 'Mode: ' + ansi.reset + ansi.magenta + 'No-Server (branch monitor only)' + ansi.reset);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Instructions
|
|
851
|
+
write(ansi.moveTo(row + height - 2, col + Math.floor((width - 20) / 2)));
|
|
852
|
+
write(ansi.gray + 'Press [i] or [Esc] to close' + ansi.reset);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
// renderActionModal
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Render the branch-actions modal with PR/CI/Claude integration.
|
|
861
|
+
*
|
|
862
|
+
* @param {object} state
|
|
863
|
+
* @param {function} write
|
|
864
|
+
*/
|
|
865
|
+
function renderActionModal(state, write) {
|
|
866
|
+
if (!state.actionMode || !state.actionData) return;
|
|
867
|
+
|
|
868
|
+
const { branch, sessionUrl, prInfo, hasGh, hasGlab, ghAuthed, glabAuthed, webUrl, isClaudeBranch, platform, prLoaded } = state.actionData;
|
|
869
|
+
|
|
870
|
+
const width = Math.min(64, state.terminalWidth - 4);
|
|
871
|
+
const innerW = width - 6;
|
|
872
|
+
|
|
873
|
+
const platformLabel = platform === 'gitlab' ? 'GitLab' : platform === 'bitbucket' ? 'Bitbucket' : platform === 'azure' ? 'Azure DevOps' : 'GitHub';
|
|
874
|
+
const prLabel = platform === 'gitlab' ? 'MR' : 'PR';
|
|
875
|
+
const cliTool = platform === 'gitlab' ? 'glab' : 'gh';
|
|
876
|
+
const hasCli = platform === 'gitlab' ? hasGlab : hasGh;
|
|
877
|
+
const cliAuthed = platform === 'gitlab' ? glabAuthed : ghAuthed;
|
|
878
|
+
const cliReady = hasCli && cliAuthed;
|
|
879
|
+
const loading = state.actionLoading;
|
|
880
|
+
|
|
881
|
+
// Build actions list - ALL actions always shown, grayed out with reasons when unavailable
|
|
882
|
+
const actions = [];
|
|
883
|
+
|
|
884
|
+
// Open on web
|
|
885
|
+
actions.push({
|
|
886
|
+
key: 'b', label: `Open branch on ${platformLabel}`,
|
|
887
|
+
available: !!webUrl, reason: !webUrl ? 'Could not parse remote URL' : null,
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// Claude session - always shown so users know it exists
|
|
891
|
+
actions.push({
|
|
892
|
+
key: 'c', label: 'Open Claude Code session',
|
|
893
|
+
available: !!sessionUrl,
|
|
894
|
+
reason: !isClaudeBranch ? 'Not a Claude branch' : !sessionUrl && !loading ? 'No session URL in commits' : null,
|
|
895
|
+
loading: isClaudeBranch && !sessionUrl && loading,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// PR: create or view depending on state
|
|
899
|
+
const prIsMerged = prInfo && (prInfo.state === 'MERGED' || prInfo.state === 'merged');
|
|
900
|
+
const prIsOpen = prInfo && (prInfo.state === 'OPEN' || prInfo.state === 'open');
|
|
901
|
+
if (prInfo) {
|
|
902
|
+
actions.push({ key: 'p', label: `View ${prLabel} #${prInfo.number}`, available: !!webUrl, reason: null });
|
|
903
|
+
} else {
|
|
904
|
+
actions.push({
|
|
905
|
+
key: 'p', label: `Create ${prLabel}`,
|
|
906
|
+
available: cliReady && prLoaded,
|
|
907
|
+
reason: !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : null,
|
|
908
|
+
loading: cliReady && !prLoaded,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Diff - opens on web, just needs a PR and webUrl
|
|
913
|
+
actions.push({
|
|
914
|
+
key: 'd', label: `View ${prLabel} diff on ${platformLabel}`,
|
|
915
|
+
available: !!prInfo && !!webUrl,
|
|
916
|
+
reason: !prInfo && prLoaded ? `No ${prLabel}` : !webUrl ? 'Could not parse remote URL' : null,
|
|
917
|
+
loading: !prLoaded && (cliReady || !!webUrl),
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// Approve - disabled for merged PRs
|
|
921
|
+
actions.push({
|
|
922
|
+
key: 'a', label: `Approve ${prLabel}`,
|
|
923
|
+
available: !!prInfo && prIsOpen && cliReady,
|
|
924
|
+
reason: prIsMerged ? `${prLabel} already merged` : !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded ? `No open ${prLabel}` : null,
|
|
925
|
+
loading: cliReady && !prLoaded,
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// Merge - disabled for already-merged PRs
|
|
929
|
+
actions.push({
|
|
930
|
+
key: 'm', label: `Merge ${prLabel} (squash)`,
|
|
931
|
+
available: !!prInfo && prIsOpen && cliReady,
|
|
932
|
+
reason: prIsMerged ? `${prLabel} already merged` : !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded ? `No open ${prLabel}` : null,
|
|
933
|
+
loading: cliReady && !prLoaded,
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// CI
|
|
937
|
+
actions.push({
|
|
938
|
+
key: 'i', label: 'Check CI status',
|
|
939
|
+
available: cliReady && (!!prInfo || platform === 'gitlab'),
|
|
940
|
+
reason: !hasCli ? `Requires ${cliTool} CLI` : !cliAuthed ? `Run: ${cliTool} auth login` : !prInfo && prLoaded && platform !== 'gitlab' ? `No open ${prLabel}` : null,
|
|
941
|
+
loading: cliReady && !prLoaded && platform !== 'gitlab',
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// Calculate height
|
|
945
|
+
let contentLines = 0;
|
|
946
|
+
contentLines += 2; // spacing + branch name
|
|
947
|
+
contentLines += 1; // separator
|
|
948
|
+
contentLines += actions.length;
|
|
949
|
+
contentLines += 1; // separator
|
|
950
|
+
|
|
951
|
+
// Status info
|
|
952
|
+
const statusInfoLines = [];
|
|
953
|
+
if (prInfo) {
|
|
954
|
+
let prStatus = `${prLabel} #${prInfo.number}: ${truncate(prInfo.title, innerW - 20)}`;
|
|
955
|
+
const badges = [];
|
|
956
|
+
if (prIsMerged) badges.push('merged');
|
|
957
|
+
if (prInfo.approved) badges.push('approved');
|
|
958
|
+
if (prInfo.checksPass) badges.push('checks pass');
|
|
959
|
+
if (prInfo.checksFail) badges.push('checks fail');
|
|
960
|
+
if (badges.length) prStatus += ` [${badges.join(', ')}]`;
|
|
961
|
+
statusInfoLines.push({ color: prIsMerged ? 'magenta' : 'green', text: prStatus });
|
|
962
|
+
} else if (loading) {
|
|
963
|
+
statusInfoLines.push({ color: 'gray', text: `Loading ${prLabel} info...` });
|
|
964
|
+
} else if (cliReady) {
|
|
965
|
+
statusInfoLines.push({ color: 'gray', text: `No ${prLabel} for this branch` });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (isClaudeBranch) {
|
|
969
|
+
if (sessionUrl) {
|
|
970
|
+
const shortSession = sessionUrl.replace('https://claude.ai/code/', '');
|
|
971
|
+
statusInfoLines.push({ color: 'magenta', text: `Session: ${truncate(shortSession, innerW - 10)}` });
|
|
972
|
+
} else if (!loading) {
|
|
973
|
+
statusInfoLines.push({ color: 'gray', text: 'Claude branch (no session URL in commits)' });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
contentLines += statusInfoLines.length;
|
|
978
|
+
|
|
979
|
+
// Setup hints
|
|
980
|
+
const hints = [];
|
|
981
|
+
if (!hasCli) {
|
|
982
|
+
if (platform === 'gitlab') {
|
|
983
|
+
hints.push('Install glab: https://gitlab.com/gitlab-org/cli');
|
|
984
|
+
hints.push('Then run: glab auth login');
|
|
985
|
+
} else {
|
|
986
|
+
hints.push('Install gh: https://cli.github.com');
|
|
987
|
+
hints.push('Then run: gh auth login');
|
|
988
|
+
}
|
|
989
|
+
} else if (!cliAuthed) {
|
|
990
|
+
hints.push(`${cliTool} is installed but not authenticated`);
|
|
991
|
+
hints.push(`Run: ${cliTool} auth login`);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (hints.length > 0) {
|
|
995
|
+
contentLines += 1;
|
|
996
|
+
contentLines += hints.length;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
contentLines += 2; // blank + close instructions
|
|
1000
|
+
|
|
1001
|
+
const modalHeight = contentLines + 3;
|
|
1002
|
+
const modalCol = Math.floor((state.terminalWidth - width) / 2);
|
|
1003
|
+
const modalRow = Math.floor((state.terminalHeight - modalHeight) / 2);
|
|
1004
|
+
|
|
1005
|
+
// Draw box
|
|
1006
|
+
const borderColor = ansi.brightCyan;
|
|
1007
|
+
write(ansi.moveTo(modalRow, modalCol));
|
|
1008
|
+
write(borderColor + ansi.bold);
|
|
1009
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1010
|
+
|
|
1011
|
+
for (let i = 1; i < modalHeight - 1; i++) {
|
|
1012
|
+
write(ansi.moveTo(modalRow + i, modalCol));
|
|
1013
|
+
write(borderColor + box.dVertical + ansi.reset + ' '.repeat(width - 2) + borderColor + box.dVertical + ansi.reset);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
write(ansi.moveTo(modalRow + modalHeight - 1, modalCol));
|
|
1017
|
+
write(borderColor + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1018
|
+
write(ansi.reset);
|
|
1019
|
+
|
|
1020
|
+
// Title
|
|
1021
|
+
const title = ' Branch Actions ';
|
|
1022
|
+
write(ansi.moveTo(modalRow, modalCol + 2));
|
|
1023
|
+
write(borderColor + ansi.bold + title + ansi.reset);
|
|
1024
|
+
|
|
1025
|
+
let r = modalRow + 2;
|
|
1026
|
+
|
|
1027
|
+
// Branch name with type indicator
|
|
1028
|
+
write(ansi.moveTo(r, modalCol + 3));
|
|
1029
|
+
write(ansi.white + ansi.bold + truncate(branch.name, innerW - 10) + ansi.reset);
|
|
1030
|
+
if (isClaudeBranch) {
|
|
1031
|
+
write(ansi.magenta + ' [Claude]' + ansi.reset);
|
|
1032
|
+
}
|
|
1033
|
+
r++;
|
|
1034
|
+
|
|
1035
|
+
// Separator
|
|
1036
|
+
r++;
|
|
1037
|
+
|
|
1038
|
+
// Actions list - all always visible
|
|
1039
|
+
for (const action of actions) {
|
|
1040
|
+
write(ansi.moveTo(r, modalCol + 3));
|
|
1041
|
+
if (action.loading) {
|
|
1042
|
+
write(ansi.gray + '[' + action.key + '] ' + action.label + ' ' + ansi.dim + ansi.cyan + 'loading...' + ansi.reset);
|
|
1043
|
+
} else if (action.available) {
|
|
1044
|
+
write(ansi.brightCyan + '[' + action.key + ']' + ansi.reset + ' ' + action.label);
|
|
1045
|
+
} else {
|
|
1046
|
+
write(ansi.gray + '[' + action.key + '] ' + action.label);
|
|
1047
|
+
if (action.reason) {
|
|
1048
|
+
write(' ' + ansi.dim + ansi.yellow + action.reason + ansi.reset);
|
|
1049
|
+
}
|
|
1050
|
+
write(ansi.reset);
|
|
1051
|
+
}
|
|
1052
|
+
r++;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Separator
|
|
1056
|
+
r++;
|
|
1057
|
+
|
|
1058
|
+
// Status info
|
|
1059
|
+
for (const info of statusInfoLines) {
|
|
1060
|
+
write(ansi.moveTo(r, modalCol + 3));
|
|
1061
|
+
write(ansi[info.color] + truncate(info.text, innerW) + ansi.reset);
|
|
1062
|
+
r++;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Setup hints
|
|
1066
|
+
if (hints.length > 0) {
|
|
1067
|
+
r++;
|
|
1068
|
+
for (const hint of hints) {
|
|
1069
|
+
write(ansi.moveTo(r, modalCol + 3));
|
|
1070
|
+
write(ansi.yellow + truncate(hint, innerW) + ansi.reset);
|
|
1071
|
+
r++;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Close instructions
|
|
1076
|
+
write(ansi.moveTo(modalRow + modalHeight - 2, modalCol + Math.floor((width - 18) / 2)));
|
|
1077
|
+
write(ansi.gray + 'Press [Esc] to close' + ansi.reset);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// ---------------------------------------------------------------------------
|
|
1081
|
+
// renderStashConfirm
|
|
1082
|
+
// ---------------------------------------------------------------------------
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Render a stash confirmation dialog with selectable options.
|
|
1086
|
+
*
|
|
1087
|
+
* The dialog appears when the user tries to switch branches or pull with
|
|
1088
|
+
* uncommitted changes. It offers two options navigable with up/down arrows.
|
|
1089
|
+
*
|
|
1090
|
+
* State shape expected:
|
|
1091
|
+
* stashConfirmMode: boolean
|
|
1092
|
+
* stashConfirmSelectedIndex: number (0 or 1)
|
|
1093
|
+
* pendingDirtyOperationLabel: string (e.g. "switch to main", "pull")
|
|
1094
|
+
*
|
|
1095
|
+
* @param {object} state
|
|
1096
|
+
* @param {function} write
|
|
1097
|
+
*/
|
|
1098
|
+
function renderStashConfirm(state, write) {
|
|
1099
|
+
if (!state.stashConfirmMode) return;
|
|
1100
|
+
|
|
1101
|
+
const options = [
|
|
1102
|
+
'Stash changes and continue',
|
|
1103
|
+
"No, I'll handle it myself",
|
|
1104
|
+
];
|
|
1105
|
+
const selectedIdx = state.stashConfirmSelectedIndex || 0;
|
|
1106
|
+
|
|
1107
|
+
const title = 'Uncommitted Changes';
|
|
1108
|
+
const message = state.pendingDirtyOperationLabel
|
|
1109
|
+
? `You have uncommitted changes that conflict with: ${state.pendingDirtyOperationLabel}`
|
|
1110
|
+
: 'You have uncommitted changes in your working directory.';
|
|
1111
|
+
|
|
1112
|
+
const width = Math.min(60, state.terminalWidth - 4);
|
|
1113
|
+
const col = Math.floor((state.terminalWidth - width) / 2);
|
|
1114
|
+
const row = 2;
|
|
1115
|
+
|
|
1116
|
+
// Build content lines
|
|
1117
|
+
const lines = [];
|
|
1118
|
+
lines.push(title);
|
|
1119
|
+
lines.push('');
|
|
1120
|
+
|
|
1121
|
+
// Word wrap the message
|
|
1122
|
+
const msgWords = message.split(' ');
|
|
1123
|
+
let currentLine = '';
|
|
1124
|
+
for (const word of msgWords) {
|
|
1125
|
+
if ((currentLine + ' ' + word).length > width - 6) {
|
|
1126
|
+
lines.push(currentLine.trim());
|
|
1127
|
+
currentLine = word;
|
|
1128
|
+
} else {
|
|
1129
|
+
currentLine += (currentLine ? ' ' : '') + word;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (currentLine) lines.push(currentLine.trim());
|
|
1133
|
+
|
|
1134
|
+
lines.push('');
|
|
1135
|
+
|
|
1136
|
+
// Option lines (we'll render these specially)
|
|
1137
|
+
const optionStartIdx = lines.length;
|
|
1138
|
+
for (const opt of options) {
|
|
1139
|
+
lines.push(opt);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
lines.push('');
|
|
1143
|
+
lines.push('[S] Stash [Esc] Cancel');
|
|
1144
|
+
|
|
1145
|
+
const height = lines.length + 2;
|
|
1146
|
+
|
|
1147
|
+
// Draw yellow/amber box
|
|
1148
|
+
write(ansi.moveTo(row, col));
|
|
1149
|
+
write(ansi.yellow + ansi.bold);
|
|
1150
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1151
|
+
|
|
1152
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1153
|
+
write(ansi.moveTo(row + i, col));
|
|
1154
|
+
write(ansi.yellow + box.dVertical + ansi.reset + ' ' + ' '.repeat(width - 6) + ' ' + ansi.yellow + box.dVertical + ansi.reset);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1158
|
+
write(ansi.yellow + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1159
|
+
write(ansi.reset);
|
|
1160
|
+
|
|
1161
|
+
// Render content
|
|
1162
|
+
let contentRow = row + 1;
|
|
1163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1164
|
+
const line = lines[i];
|
|
1165
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1166
|
+
|
|
1167
|
+
if (i === 0) {
|
|
1168
|
+
// Title — centered, bold yellow
|
|
1169
|
+
const titlePadding = Math.floor((width - 6 - line.length) / 2);
|
|
1170
|
+
write(' '.repeat(titlePadding) + ansi.yellow + ansi.bold + line + ansi.reset + ' '.repeat(width - 6 - titlePadding - line.length));
|
|
1171
|
+
} else if (i >= optionStartIdx && i < optionStartIdx + options.length) {
|
|
1172
|
+
// Selectable option
|
|
1173
|
+
const optIdx = i - optionStartIdx;
|
|
1174
|
+
const isSelected = optIdx === selectedIdx;
|
|
1175
|
+
const prefix = isSelected ? '\u25b8 ' : ' ';
|
|
1176
|
+
const optText = prefix + line;
|
|
1177
|
+
if (isSelected) {
|
|
1178
|
+
write(ansi.bold + ansi.cyan + padRight(optText, width - 6) + ansi.reset);
|
|
1179
|
+
} else {
|
|
1180
|
+
write(ansi.gray + padRight(optText, width - 6) + ansi.reset);
|
|
1181
|
+
}
|
|
1182
|
+
} else if (line === '[S] Stash [Esc] Cancel') {
|
|
1183
|
+
// Keyboard hints — centered, dim
|
|
1184
|
+
const lPadding = Math.floor((width - 6 - line.length) / 2);
|
|
1185
|
+
write(ansi.dim + ' '.repeat(lPadding) + line + ' '.repeat(width - 6 - lPadding - line.length) + ansi.reset);
|
|
1186
|
+
} else {
|
|
1187
|
+
// Regular content
|
|
1188
|
+
write(padRight(line, width - 6));
|
|
1189
|
+
}
|
|
1190
|
+
contentRow++;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ---------------------------------------------------------------------------
|
|
1195
|
+
// renderCleanupConfirm
|
|
1196
|
+
// ---------------------------------------------------------------------------
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Render the cleanup confirmation modal.
|
|
1200
|
+
* Lists branches whose remote tracking branch is gone and asks for confirmation.
|
|
1201
|
+
*
|
|
1202
|
+
* @param {object} state
|
|
1203
|
+
* @param {function} write
|
|
1204
|
+
*/
|
|
1205
|
+
function renderCleanupConfirm(state, write) {
|
|
1206
|
+
if (!state.cleanupConfirmMode || !state.cleanupBranches) return;
|
|
1207
|
+
|
|
1208
|
+
const branches = state.cleanupBranches;
|
|
1209
|
+
const width = Math.min(60, state.terminalWidth - 4);
|
|
1210
|
+
const innerW = width - 6;
|
|
1211
|
+
|
|
1212
|
+
// Build content lines
|
|
1213
|
+
const lines = [];
|
|
1214
|
+
lines.push('Cleanup Stale Branches');
|
|
1215
|
+
lines.push('');
|
|
1216
|
+
|
|
1217
|
+
if (branches.length === 0) {
|
|
1218
|
+
lines.push('No stale branches found.');
|
|
1219
|
+
lines.push('All local branches have active remotes.');
|
|
1220
|
+
} else {
|
|
1221
|
+
lines.push(`${branches.length} branch${branches.length === 1 ? '' : 'es'} with deleted remote:`);
|
|
1222
|
+
lines.push('');
|
|
1223
|
+
|
|
1224
|
+
const maxShown = Math.min(branches.length, 10);
|
|
1225
|
+
for (let i = 0; i < maxShown; i++) {
|
|
1226
|
+
lines.push('\u2717 ' + truncate(branches[i], innerW - 4));
|
|
1227
|
+
}
|
|
1228
|
+
if (branches.length > maxShown) {
|
|
1229
|
+
lines.push(` ... and ${branches.length - maxShown} more`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
lines.push('');
|
|
1234
|
+
|
|
1235
|
+
// Options
|
|
1236
|
+
const optionStartIdx = lines.length;
|
|
1237
|
+
const options = branches.length > 0
|
|
1238
|
+
? ['Delete all (safe — merged only)', 'Force delete all (including unmerged)', 'Cancel']
|
|
1239
|
+
: ['Close'];
|
|
1240
|
+
for (const opt of options) {
|
|
1241
|
+
lines.push(opt);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
lines.push('');
|
|
1245
|
+
if (branches.length > 0) {
|
|
1246
|
+
lines.push('[Enter] Select [Esc] Cancel');
|
|
1247
|
+
} else {
|
|
1248
|
+
lines.push('[Esc] Close');
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const height = lines.length + 2;
|
|
1252
|
+
const col = Math.floor((state.terminalWidth - width) / 2);
|
|
1253
|
+
const row = Math.floor((state.terminalHeight - height) / 2);
|
|
1254
|
+
const selectedIdx = state.cleanupSelectedIndex || 0;
|
|
1255
|
+
|
|
1256
|
+
// Draw red/amber box
|
|
1257
|
+
const borderColor = ansi.red;
|
|
1258
|
+
write(ansi.moveTo(row, col));
|
|
1259
|
+
write(borderColor + ansi.bold);
|
|
1260
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1261
|
+
|
|
1262
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1263
|
+
write(ansi.moveTo(row + i, col));
|
|
1264
|
+
write(borderColor + box.dVertical + ansi.reset + ' ' + ' '.repeat(width - 6) + ' ' + borderColor + box.dVertical + ansi.reset);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1268
|
+
write(borderColor + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1269
|
+
write(ansi.reset);
|
|
1270
|
+
|
|
1271
|
+
// Render content
|
|
1272
|
+
let contentRow = row + 1;
|
|
1273
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1274
|
+
const line = lines[i];
|
|
1275
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1276
|
+
|
|
1277
|
+
if (i === 0) {
|
|
1278
|
+
// Title — centered, bold red
|
|
1279
|
+
const titlePadding = Math.floor((width - 6 - line.length) / 2);
|
|
1280
|
+
write(' '.repeat(titlePadding) + ansi.red + ansi.bold + line + ansi.reset + ' '.repeat(width - 6 - titlePadding - line.length));
|
|
1281
|
+
} else if (i >= optionStartIdx && i < optionStartIdx + options.length) {
|
|
1282
|
+
// Selectable option
|
|
1283
|
+
const optIdx = i - optionStartIdx;
|
|
1284
|
+
const isSelected = optIdx === selectedIdx;
|
|
1285
|
+
const prefix = isSelected ? '\u25b8 ' : ' ';
|
|
1286
|
+
const optText = prefix + line;
|
|
1287
|
+
if (isSelected) {
|
|
1288
|
+
write(ansi.bold + ansi.cyan + padRight(optText, width - 6) + ansi.reset);
|
|
1289
|
+
} else {
|
|
1290
|
+
write(ansi.gray + padRight(optText, width - 6) + ansi.reset);
|
|
1291
|
+
}
|
|
1292
|
+
} else if (line.startsWith('[Enter]') || line.startsWith('[Esc]')) {
|
|
1293
|
+
// Keyboard hints — centered, dim
|
|
1294
|
+
const lPadding = Math.floor((width - 6 - line.length) / 2);
|
|
1295
|
+
write(ansi.dim + ' '.repeat(lPadding) + line + ' '.repeat(width - 6 - lPadding - line.length) + ansi.reset);
|
|
1296
|
+
} else if (line.startsWith('\u2717 ')) {
|
|
1297
|
+
// Branch name — red cross
|
|
1298
|
+
write(ansi.red + '\u2717 ' + ansi.reset + ansi.gray + truncate(line.slice(2), innerW - 2) + ansi.reset + ' '.repeat(Math.max(0, width - 6 - line.length)));
|
|
1299
|
+
} else {
|
|
1300
|
+
// Regular content
|
|
1301
|
+
write(padRight(line, width - 6));
|
|
1302
|
+
}
|
|
1303
|
+
contentRow++;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// ---------------------------------------------------------------------------
|
|
1308
|
+
// Exports
|
|
1309
|
+
// ---------------------------------------------------------------------------
|
|
1310
|
+
|
|
1311
|
+
module.exports = {
|
|
1312
|
+
renderHeader,
|
|
1313
|
+
renderBranchList,
|
|
1314
|
+
renderActivityLog,
|
|
1315
|
+
renderCasinoStats,
|
|
1316
|
+
renderFooter,
|
|
1317
|
+
renderFlash,
|
|
1318
|
+
renderErrorToast,
|
|
1319
|
+
renderPreview,
|
|
1320
|
+
renderHistory,
|
|
1321
|
+
renderLogView,
|
|
1322
|
+
renderInfo,
|
|
1323
|
+
renderActionModal,
|
|
1324
|
+
renderStashConfirm,
|
|
1325
|
+
renderCleanupConfirm,
|
|
1326
|
+
};
|