clawt 2.20.0 → 3.1.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/.claude/agents/docs-sync-updater.md +29 -11
- package/README.md +19 -30
- package/dist/index.js +1127 -222
- package/dist/postinstall.js +73 -8
- package/docs/alias.md +108 -0
- package/docs/completion.md +55 -0
- package/docs/config-file.md +43 -0
- package/docs/config.md +91 -0
- package/docs/create.md +85 -0
- package/docs/init.md +65 -0
- package/docs/list.md +67 -0
- package/docs/log.md +67 -0
- package/docs/merge.md +137 -0
- package/docs/notification.md +94 -0
- package/docs/projects.md +135 -0
- package/docs/remove.md +79 -0
- package/docs/reset.md +35 -0
- package/docs/resume.md +99 -0
- package/docs/run.md +146 -0
- package/docs/spec.md +157 -1906
- package/docs/status.md +298 -0
- package/docs/sync.md +114 -0
- package/docs/update-check.md +95 -0
- package/docs/validate.md +368 -0
- package/package.json +1 -1
- package/src/commands/alias.ts +1 -1
- package/src/commands/create.ts +10 -5
- package/src/commands/init.ts +75 -0
- package/src/commands/list.ts +1 -1
- package/src/commands/merge.ts +11 -4
- package/src/commands/remove.ts +10 -3
- package/src/commands/reset.ts +3 -0
- package/src/commands/resume.ts +1 -1
- package/src/commands/run.ts +9 -3
- package/src/commands/status.ts +14 -5
- package/src/commands/sync.ts +18 -6
- package/src/commands/validate.ts +46 -52
- package/src/constants/branch.ts +3 -0
- package/src/constants/config.ts +1 -1
- package/src/constants/index.ts +14 -2
- package/src/constants/interactive-panel.ts +44 -0
- package/src/constants/messages/completion.ts +1 -1
- package/src/constants/messages/create.ts +3 -0
- package/src/constants/messages/index.ts +4 -0
- package/src/constants/messages/init.ts +18 -0
- package/src/constants/messages/interactive-panel.ts +61 -0
- package/src/constants/messages/remove.ts +2 -0
- package/src/constants/messages/sync.ts +3 -0
- package/src/constants/messages/validate.ts +6 -0
- package/src/constants/paths.ts +3 -0
- package/src/index.ts +2 -0
- package/src/types/command.ts +9 -1
- package/src/types/index.ts +2 -1
- package/src/types/projectConfig.ts +5 -0
- package/src/utils/config.ts +2 -1
- package/src/utils/git.ts +18 -0
- package/src/utils/index.ts +9 -1
- package/src/utils/interactive-panel-render.ts +315 -0
- package/src/utils/interactive-panel.ts +590 -0
- package/src/utils/json.ts +67 -0
- package/src/utils/project-config.ts +77 -0
- package/src/utils/validate-branch.ts +166 -0
- package/src/utils/worktree-matcher.ts +2 -2
- package/src/utils/worktree.ts +6 -2
- package/tests/unit/commands/create.test.ts +20 -16
- package/tests/unit/commands/init.test.ts +146 -0
- package/tests/unit/commands/merge.test.ts +7 -1
- package/tests/unit/commands/remove.test.ts +4 -0
- package/tests/unit/commands/reset.test.ts +2 -0
- package/tests/unit/commands/run.test.ts +2 -0
- package/tests/unit/commands/sync.test.ts +6 -0
- package/tests/unit/commands/validate.test.ts +13 -0
- package/tests/unit/utils/config.test.ts +2 -2
- package/tests/unit/utils/project-config.test.ts +136 -0
- package/tests/unit/utils/update-checker.test.ts +28 -7
- package/tests/unit/utils/validate-branch.test.ts +272 -0
- package/tests/unit/utils/worktree.test.ts +6 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import {
|
|
3
|
+
CURSOR_HIDE,
|
|
4
|
+
CURSOR_SHOW,
|
|
5
|
+
LINE_WRAP_DISABLE,
|
|
6
|
+
LINE_WRAP_ENABLE,
|
|
7
|
+
SYNC_OUTPUT_START,
|
|
8
|
+
SYNC_OUTPUT_END,
|
|
9
|
+
ALT_SCREEN_ENTER,
|
|
10
|
+
ALT_SCREEN_LEAVE,
|
|
11
|
+
CLEAR_SCREEN,
|
|
12
|
+
CURSOR_HOME,
|
|
13
|
+
DEFAULT_TERMINAL_COLUMNS,
|
|
14
|
+
PANEL_REFRESH_INTERVAL_MS,
|
|
15
|
+
PANEL_COUNTDOWN_INTERVAL_MS,
|
|
16
|
+
KEY_ARROW_UP,
|
|
17
|
+
KEY_ARROW_DOWN,
|
|
18
|
+
KEY_CTRL_C,
|
|
19
|
+
PANEL_SHORTCUT_KEYS,
|
|
20
|
+
} from '../constants/index.js';
|
|
21
|
+
import { PANEL_NOT_TTY, PANEL_PRESS_ENTER_TO_RETURN } from '../constants/messages/index.js';
|
|
22
|
+
import { runCommandInherited } from './shell.js';
|
|
23
|
+
import { buildPanelFrame, buildGroupedWorktreeLines, buildDisplayOrder, calculateVisibleRows } from './interactive-panel-render.js';
|
|
24
|
+
import { truncateToTerminalWidth } from './progress-render.js';
|
|
25
|
+
import type { StatusResult } from '../types/index.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 交互式状态面板
|
|
29
|
+
*
|
|
30
|
+
* 提供实时刷新的 TUI 面板,支持键盘导航和快捷键操作。
|
|
31
|
+
* 参照 ProgressRenderer 的生命周期模式实现。
|
|
32
|
+
*/
|
|
33
|
+
export class InteractivePanel {
|
|
34
|
+
/** 当前状态数据 */
|
|
35
|
+
private statusResult: StatusResult | null;
|
|
36
|
+
/** 当前选中的显示位置索引(对应 displayOrder 数组的下标) */
|
|
37
|
+
private selectedDisplayIndex: number;
|
|
38
|
+
/** 显示顺序到原始索引的映射(按日期分组后的排列顺序) */
|
|
39
|
+
private displayOrder: number[];
|
|
40
|
+
/** 滚动偏移(基于行数) */
|
|
41
|
+
private scrollOffset: number;
|
|
42
|
+
/** 数据刷新定时器引用 */
|
|
43
|
+
private refreshTimer: ReturnType<typeof setInterval> | null;
|
|
44
|
+
/** 倒计时定时器引用 */
|
|
45
|
+
private countdownTimer: ReturnType<typeof setInterval> | null;
|
|
46
|
+
/** 刷新倒计时剩余秒数 */
|
|
47
|
+
private refreshCountdown: number;
|
|
48
|
+
/** 是否已停止 */
|
|
49
|
+
private stopped: boolean;
|
|
50
|
+
/** 是否为 TTY 环境 */
|
|
51
|
+
private isTTY: boolean;
|
|
52
|
+
/** resize 事件处理器引用 */
|
|
53
|
+
private resizeHandler: (() => void) | null;
|
|
54
|
+
/** exit 兜底处理器 */
|
|
55
|
+
private exitHandler: (() => void) | null;
|
|
56
|
+
/** stdin 数据处理器引用(用于清理) */
|
|
57
|
+
private stdinDataHandler: ((data: Buffer) => void) | null;
|
|
58
|
+
/** 操作锁(防止操作期间响应按键) */
|
|
59
|
+
private isOperating: boolean;
|
|
60
|
+
/** Promise resolve 函数(stop 时调用以完成 start 返回的 Promise) */
|
|
61
|
+
private resolveStart: (() => void) | null;
|
|
62
|
+
/** 数据收集函数引用 */
|
|
63
|
+
private collectStatusFn: () => StatusResult;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 创建交互式面板
|
|
67
|
+
* @param {() => StatusResult} collectStatusFn - 数据收集函数
|
|
68
|
+
*/
|
|
69
|
+
constructor(collectStatusFn: () => StatusResult) {
|
|
70
|
+
this.statusResult = null;
|
|
71
|
+
this.selectedDisplayIndex = 0;
|
|
72
|
+
this.displayOrder = [];
|
|
73
|
+
this.scrollOffset = 0;
|
|
74
|
+
this.refreshTimer = null;
|
|
75
|
+
this.countdownTimer = null;
|
|
76
|
+
this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
|
|
77
|
+
this.stopped = false;
|
|
78
|
+
this.isTTY = !!process.stdout.isTTY;
|
|
79
|
+
this.resizeHandler = null;
|
|
80
|
+
this.exitHandler = null;
|
|
81
|
+
this.stdinDataHandler = null;
|
|
82
|
+
this.isOperating = false;
|
|
83
|
+
this.resolveStart = null;
|
|
84
|
+
this.collectStatusFn = collectStatusFn;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 启动交互式面板
|
|
89
|
+
* 非 TTY 时打印提示并退出
|
|
90
|
+
* @returns {Promise<void>} 面板关闭时 resolve
|
|
91
|
+
*/
|
|
92
|
+
start(): Promise<void> {
|
|
93
|
+
// 非 TTY 降级
|
|
94
|
+
if (!this.isTTY) {
|
|
95
|
+
console.log(PANEL_NOT_TTY);
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return new Promise<void>((resolve) => {
|
|
100
|
+
this.resolveStart = resolve;
|
|
101
|
+
|
|
102
|
+
// 收集初始数据
|
|
103
|
+
this.statusResult = this.collectStatusFn();
|
|
104
|
+
this.displayOrder = buildDisplayOrder(this.statusResult.worktrees);
|
|
105
|
+
|
|
106
|
+
// 初始化终端
|
|
107
|
+
this.initTerminal();
|
|
108
|
+
|
|
109
|
+
// 启动键盘监听
|
|
110
|
+
this.startKeyboardListener();
|
|
111
|
+
|
|
112
|
+
// 启动自动刷新
|
|
113
|
+
this.startAutoRefresh();
|
|
114
|
+
|
|
115
|
+
// 首次渲染
|
|
116
|
+
this.render();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 停止面板,恢复终端状态
|
|
122
|
+
*/
|
|
123
|
+
stop(): void {
|
|
124
|
+
if (this.stopped) return;
|
|
125
|
+
this.stopped = true;
|
|
126
|
+
|
|
127
|
+
// 停止定时器
|
|
128
|
+
this.clearTimers();
|
|
129
|
+
|
|
130
|
+
// 停止键盘监听
|
|
131
|
+
this.stopKeyboardListener();
|
|
132
|
+
|
|
133
|
+
// 恢复终端
|
|
134
|
+
this.restoreTerminal();
|
|
135
|
+
|
|
136
|
+
// 移除 resize 监听
|
|
137
|
+
if (this.resizeHandler) {
|
|
138
|
+
process.stdout.removeListener('resize', this.resizeHandler);
|
|
139
|
+
this.resizeHandler = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 移除 exit 兜底
|
|
143
|
+
if (this.exitHandler) {
|
|
144
|
+
process.removeListener('exit', this.exitHandler);
|
|
145
|
+
this.exitHandler = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 完成 Promise
|
|
149
|
+
if (this.resolveStart) {
|
|
150
|
+
this.resolveStart();
|
|
151
|
+
this.resolveStart = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 初始化终端:进入备选屏幕、隐藏光标、禁用行换行
|
|
157
|
+
*/
|
|
158
|
+
private initTerminal(): void {
|
|
159
|
+
process.stdout.write(ALT_SCREEN_ENTER);
|
|
160
|
+
process.stdout.write(CURSOR_HIDE);
|
|
161
|
+
process.stdout.write(LINE_WRAP_DISABLE);
|
|
162
|
+
|
|
163
|
+
// 监听终端宽度变化,立即触发重绘
|
|
164
|
+
this.resizeHandler = () => {
|
|
165
|
+
if (!this.stopped && !this.isOperating) {
|
|
166
|
+
this.render();
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
process.stdout.on('resize', this.resizeHandler);
|
|
170
|
+
|
|
171
|
+
// 注册 exit 兜底,确保异常退出时终端状态被恢复
|
|
172
|
+
this.exitHandler = () => {
|
|
173
|
+
process.stdout.write(LINE_WRAP_ENABLE);
|
|
174
|
+
process.stdout.write(CURSOR_SHOW);
|
|
175
|
+
process.stdout.write(ALT_SCREEN_LEAVE);
|
|
176
|
+
};
|
|
177
|
+
process.on('exit', this.exitHandler);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 恢复终端:启用行换行、显示光标、退出备选屏幕
|
|
182
|
+
*/
|
|
183
|
+
private restoreTerminal(): void {
|
|
184
|
+
process.stdout.write(LINE_WRAP_ENABLE);
|
|
185
|
+
process.stdout.write(CURSOR_SHOW);
|
|
186
|
+
process.stdout.write(ALT_SCREEN_LEAVE);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 启动键盘监听
|
|
191
|
+
* 将 stdin 设为 raw 模式以捕获每个按键
|
|
192
|
+
*/
|
|
193
|
+
private startKeyboardListener(): void {
|
|
194
|
+
if (process.stdin.isTTY) {
|
|
195
|
+
process.stdin.setRawMode(true);
|
|
196
|
+
}
|
|
197
|
+
process.stdin.resume();
|
|
198
|
+
|
|
199
|
+
this.stdinDataHandler = (data: Buffer) => {
|
|
200
|
+
this.handleKeypress(data);
|
|
201
|
+
};
|
|
202
|
+
process.stdin.on('data', this.stdinDataHandler);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 停止键盘监听,恢复 stdin 状态
|
|
207
|
+
*/
|
|
208
|
+
private stopKeyboardListener(): void {
|
|
209
|
+
if (this.stdinDataHandler) {
|
|
210
|
+
process.stdin.removeListener('data', this.stdinDataHandler);
|
|
211
|
+
this.stdinDataHandler = null;
|
|
212
|
+
}
|
|
213
|
+
if (process.stdin.isTTY) {
|
|
214
|
+
process.stdin.setRawMode(false);
|
|
215
|
+
}
|
|
216
|
+
process.stdin.pause();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 处理键盘输入
|
|
221
|
+
* @param {Buffer} data - 按键数据
|
|
222
|
+
*/
|
|
223
|
+
private handleKeypress(data: Buffer): void {
|
|
224
|
+
// 操作锁期间不响应按键
|
|
225
|
+
if (this.isOperating) return;
|
|
226
|
+
|
|
227
|
+
const str = data.toString();
|
|
228
|
+
|
|
229
|
+
// Ctrl+C
|
|
230
|
+
if (data[0] === KEY_CTRL_C) {
|
|
231
|
+
this.stop();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 方向键上
|
|
236
|
+
if (str === KEY_ARROW_UP) {
|
|
237
|
+
this.navigateUp();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 方向键下
|
|
242
|
+
if (str === KEY_ARROW_DOWN) {
|
|
243
|
+
this.navigateDown();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 单字符快捷键
|
|
248
|
+
const key = str.toLowerCase();
|
|
249
|
+
|
|
250
|
+
if (key === PANEL_SHORTCUT_KEYS.QUIT) {
|
|
251
|
+
this.stop();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (key === PANEL_SHORTCUT_KEYS.REFRESH) {
|
|
256
|
+
this.refreshData();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (key === PANEL_SHORTCUT_KEYS.VALIDATE) {
|
|
261
|
+
this.executeOperation(() => this.handleValidate());
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (key === PANEL_SHORTCUT_KEYS.MERGE) {
|
|
266
|
+
this.executeOperation(() => this.handleMerge());
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (key === PANEL_SHORTCUT_KEYS.DELETE) {
|
|
271
|
+
this.executeOperation(() => this.handleDelete());
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (key === PANEL_SHORTCUT_KEYS.RESUME) {
|
|
276
|
+
this.executeOperation(() => this.handleResume());
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (key === PANEL_SHORTCUT_KEYS.SYNC) {
|
|
281
|
+
this.executeOperation(() => this.handleSync());
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 向上导航,选中显示顺序中的上一个 worktree
|
|
288
|
+
*/
|
|
289
|
+
private navigateUp(): void {
|
|
290
|
+
if (!this.statusResult || this.displayOrder.length === 0) return;
|
|
291
|
+
|
|
292
|
+
if (this.selectedDisplayIndex > 0) {
|
|
293
|
+
this.selectedDisplayIndex--;
|
|
294
|
+
this.adjustScrollForSelection();
|
|
295
|
+
this.render();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 向下导航,选中显示顺序中的下一个 worktree
|
|
301
|
+
*/
|
|
302
|
+
private navigateDown(): void {
|
|
303
|
+
if (!this.statusResult || this.displayOrder.length === 0) return;
|
|
304
|
+
|
|
305
|
+
if (this.selectedDisplayIndex < this.displayOrder.length - 1) {
|
|
306
|
+
this.selectedDisplayIndex++;
|
|
307
|
+
this.adjustScrollForSelection();
|
|
308
|
+
this.render();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 获取当前选中的原始 worktree 索引
|
|
314
|
+
* @returns {number} 原始 worktrees 数组中的索引
|
|
315
|
+
*/
|
|
316
|
+
private getSelectedOriginalIndex(): number {
|
|
317
|
+
return this.displayOrder[this.selectedDisplayIndex];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 调整滚动偏移以确保选中项在可见区域内
|
|
322
|
+
*/
|
|
323
|
+
private adjustScrollForSelection(): void {
|
|
324
|
+
if (!this.statusResult || this.displayOrder.length === 0) return;
|
|
325
|
+
|
|
326
|
+
const originalIndex = this.getSelectedOriginalIndex();
|
|
327
|
+
const rows = process.stdout.rows || 24;
|
|
328
|
+
const visibleRows = calculateVisibleRows(rows);
|
|
329
|
+
const panelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
|
|
330
|
+
|
|
331
|
+
// 找到选中 worktree 对应的第一行和最后一行
|
|
332
|
+
let firstLine = -1;
|
|
333
|
+
let lastLine = -1;
|
|
334
|
+
for (let i = 0; i < panelLines.length; i++) {
|
|
335
|
+
if (panelLines[i].worktreeIndex === originalIndex) {
|
|
336
|
+
if (firstLine === -1) firstLine = i;
|
|
337
|
+
lastLine = i;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (firstLine === -1) return;
|
|
342
|
+
|
|
343
|
+
// 向前查找该 worktree 所属日期分组的分隔线行,滚动时一并显示
|
|
344
|
+
let groupStart = firstLine;
|
|
345
|
+
while (groupStart > 0 && panelLines[groupStart - 1].type === 'separator') {
|
|
346
|
+
groupStart--;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 如果选中项(含日期分隔线)在可见区域之上,向上滚动
|
|
350
|
+
if (groupStart < this.scrollOffset) {
|
|
351
|
+
this.scrollOffset = groupStart;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 如果选中项的最后一行在可见区域之下,向下滚动
|
|
355
|
+
if (lastLine >= this.scrollOffset + visibleRows) {
|
|
356
|
+
this.scrollOffset = lastLine - visibleRows + 1;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 终端极小时向下滚动可能把日期分组标题推出屏幕,优先保证分组标题可见
|
|
360
|
+
if (this.scrollOffset > groupStart) {
|
|
361
|
+
this.scrollOffset = groupStart;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 启动自动刷新:数据刷新定时器 + 倒计时定时器
|
|
367
|
+
*/
|
|
368
|
+
private startAutoRefresh(): void {
|
|
369
|
+
this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
|
|
370
|
+
|
|
371
|
+
// 数据刷新定时器
|
|
372
|
+
this.refreshTimer = setInterval(() => {
|
|
373
|
+
this.refreshData();
|
|
374
|
+
}, PANEL_REFRESH_INTERVAL_MS);
|
|
375
|
+
|
|
376
|
+
// 倒计时定时器(每秒更新显示)
|
|
377
|
+
this.countdownTimer = setInterval(() => {
|
|
378
|
+
if (this.refreshCountdown > 0) {
|
|
379
|
+
this.refreshCountdown--;
|
|
380
|
+
}
|
|
381
|
+
this.render();
|
|
382
|
+
}, PANEL_COUNTDOWN_INTERVAL_MS);
|
|
383
|
+
|
|
384
|
+
// 确保定时器不阻止进程退出
|
|
385
|
+
if (this.refreshTimer.unref) this.refreshTimer.unref();
|
|
386
|
+
if (this.countdownTimer.unref) this.countdownTimer.unref();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 清除所有定时器
|
|
391
|
+
*/
|
|
392
|
+
private clearTimers(): void {
|
|
393
|
+
if (this.refreshTimer) {
|
|
394
|
+
clearInterval(this.refreshTimer);
|
|
395
|
+
this.refreshTimer = null;
|
|
396
|
+
}
|
|
397
|
+
if (this.countdownTimer) {
|
|
398
|
+
clearInterval(this.countdownTimer);
|
|
399
|
+
this.countdownTimer = null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 刷新数据:记录当前选中分支 → 重新收集 → 恢复选中位置 → 重置倒计时 → 重绘
|
|
405
|
+
*/
|
|
406
|
+
private refreshData(): void {
|
|
407
|
+
if (this.stopped || this.isOperating) return;
|
|
408
|
+
|
|
409
|
+
// 记录当前选中分支名
|
|
410
|
+
const originalIndex = this.displayOrder[this.selectedDisplayIndex];
|
|
411
|
+
const previousBranch = this.statusResult?.worktrees[originalIndex]?.branch;
|
|
412
|
+
|
|
413
|
+
// 重新收集数据
|
|
414
|
+
this.statusResult = this.collectStatusFn();
|
|
415
|
+
this.displayOrder = buildDisplayOrder(this.statusResult.worktrees);
|
|
416
|
+
|
|
417
|
+
// 恢复选中位置
|
|
418
|
+
this.restoreSelection(previousBranch);
|
|
419
|
+
|
|
420
|
+
// 重置倒计时
|
|
421
|
+
this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
|
|
422
|
+
|
|
423
|
+
this.render();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 按分支名恢复选中位置(基于显示顺序)
|
|
428
|
+
* @param {string | undefined} previousBranch - 之前选中的分支名
|
|
429
|
+
*/
|
|
430
|
+
private restoreSelection(previousBranch: string | undefined): void {
|
|
431
|
+
if (!this.statusResult || !previousBranch || this.displayOrder.length === 0) {
|
|
432
|
+
this.selectedDisplayIndex = 0;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 在显示顺序中查找之前选中的分支
|
|
437
|
+
const newDisplayIndex = this.displayOrder.findIndex(
|
|
438
|
+
(origIdx) => this.statusResult!.worktrees[origIdx]?.branch === previousBranch,
|
|
439
|
+
);
|
|
440
|
+
if (newDisplayIndex >= 0) {
|
|
441
|
+
this.selectedDisplayIndex = newDisplayIndex;
|
|
442
|
+
} else {
|
|
443
|
+
// 分支已不存在,调整到安全范围
|
|
444
|
+
this.selectedDisplayIndex = Math.min(this.selectedDisplayIndex, Math.max(0, this.displayOrder.length - 1));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.adjustScrollForSelection();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 渲染一帧面板内容
|
|
452
|
+
* 使用同步输出防止闪烁
|
|
453
|
+
*/
|
|
454
|
+
private render(): void {
|
|
455
|
+
if (this.stopped || this.isOperating || !this.statusResult) return;
|
|
456
|
+
|
|
457
|
+
const cols = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
|
|
458
|
+
const rows = process.stdout.rows || 24;
|
|
459
|
+
|
|
460
|
+
const frameLines = buildPanelFrame(
|
|
461
|
+
this.statusResult,
|
|
462
|
+
this.getSelectedOriginalIndex(),
|
|
463
|
+
this.scrollOffset,
|
|
464
|
+
rows,
|
|
465
|
+
cols,
|
|
466
|
+
this.refreshCountdown,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// 同步输出开始
|
|
470
|
+
process.stdout.write(SYNC_OUTPUT_START);
|
|
471
|
+
process.stdout.write(CLEAR_SCREEN);
|
|
472
|
+
process.stdout.write(CURSOR_HOME);
|
|
473
|
+
|
|
474
|
+
// 最后一行不输出换行符,避免终端滚动导致标题行被推出屏幕
|
|
475
|
+
for (let i = 0; i < frameLines.length; i++) {
|
|
476
|
+
const suffix = i < frameLines.length - 1 ? '\n' : '';
|
|
477
|
+
process.stdout.write(`${truncateToTerminalWidth(frameLines[i], cols)}${suffix}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 同步输出结束
|
|
481
|
+
process.stdout.write(SYNC_OUTPUT_END);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* 执行操作:暂停面板 → 恢复终端 → 执行命令 → 等待回车 → 恢复面板
|
|
486
|
+
* @param {() => void} action - 要执行的操作
|
|
487
|
+
*/
|
|
488
|
+
private async executeOperation(action: () => void): Promise<void> {
|
|
489
|
+
if (!this.statusResult || this.displayOrder.length === 0) return;
|
|
490
|
+
|
|
491
|
+
this.isOperating = true;
|
|
492
|
+
|
|
493
|
+
// 暂停定时器
|
|
494
|
+
this.clearTimers();
|
|
495
|
+
|
|
496
|
+
// 恢复终端以便子命令输出
|
|
497
|
+
this.restoreTerminal();
|
|
498
|
+
|
|
499
|
+
// 恢复 stdin 以便子命令交互
|
|
500
|
+
this.stopKeyboardListener();
|
|
501
|
+
|
|
502
|
+
// 执行操作
|
|
503
|
+
action();
|
|
504
|
+
|
|
505
|
+
// 输出返回提示
|
|
506
|
+
console.log(PANEL_PRESS_ENTER_TO_RETURN);
|
|
507
|
+
|
|
508
|
+
// 等待用户按回车
|
|
509
|
+
await this.waitForEnter();
|
|
510
|
+
|
|
511
|
+
// 重新进入面板模式
|
|
512
|
+
this.initTerminal();
|
|
513
|
+
this.startKeyboardListener();
|
|
514
|
+
|
|
515
|
+
this.isOperating = false;
|
|
516
|
+
|
|
517
|
+
// 刷新数据并重新启动自动刷新
|
|
518
|
+
this.refreshData();
|
|
519
|
+
this.startAutoRefresh();
|
|
520
|
+
|
|
521
|
+
// 渲染
|
|
522
|
+
this.render();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 获取当前选中的分支名
|
|
527
|
+
* @returns {string} 当前选中的分支名
|
|
528
|
+
*/
|
|
529
|
+
private getSelectedBranch(): string {
|
|
530
|
+
const originalIndex = this.getSelectedOriginalIndex();
|
|
531
|
+
return this.statusResult!.worktrees[originalIndex].branch;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* 执行验证操作
|
|
536
|
+
*/
|
|
537
|
+
private handleValidate(): void {
|
|
538
|
+
const branch = this.getSelectedBranch();
|
|
539
|
+
runCommandInherited(`clawt validate -b ${branch}`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* 执行合并操作
|
|
544
|
+
*/
|
|
545
|
+
private handleMerge(): void {
|
|
546
|
+
const branch = this.getSelectedBranch();
|
|
547
|
+
runCommandInherited(`clawt merge -b ${branch}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 执行删除操作
|
|
552
|
+
*/
|
|
553
|
+
private handleDelete(): void {
|
|
554
|
+
const branch = this.getSelectedBranch();
|
|
555
|
+
runCommandInherited(`clawt remove -b ${branch}`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 执行恢复操作
|
|
560
|
+
*/
|
|
561
|
+
private handleResume(): void {
|
|
562
|
+
const branch = this.getSelectedBranch();
|
|
563
|
+
runCommandInherited(`clawt resume -b ${branch}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 执行同步操作
|
|
568
|
+
*/
|
|
569
|
+
private handleSync(): void {
|
|
570
|
+
const branch = this.getSelectedBranch();
|
|
571
|
+
runCommandInherited(`clawt sync -b ${branch}`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 等待用户按回车键
|
|
576
|
+
* @returns {Promise<void>} 用户按回车时 resolve
|
|
577
|
+
*/
|
|
578
|
+
private waitForEnter(): Promise<void> {
|
|
579
|
+
return new Promise<void>((resolve) => {
|
|
580
|
+
const rl = createInterface({
|
|
581
|
+
input: process.stdin,
|
|
582
|
+
output: process.stdout,
|
|
583
|
+
});
|
|
584
|
+
rl.once('line', () => {
|
|
585
|
+
rl.close();
|
|
586
|
+
resolve();
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 非对象类型的值直接转为字符串表示
|
|
3
|
+
* @param {unknown} value - 任意值
|
|
4
|
+
* @returns {string} 字符串表示
|
|
5
|
+
*/
|
|
6
|
+
function primitiveToString(value: unknown): string {
|
|
7
|
+
if (value === undefined) {
|
|
8
|
+
return 'undefined';
|
|
9
|
+
}
|
|
10
|
+
if (value === null) {
|
|
11
|
+
return 'null';
|
|
12
|
+
}
|
|
13
|
+
if (typeof value === 'symbol') {
|
|
14
|
+
return value.toString();
|
|
15
|
+
}
|
|
16
|
+
if (typeof value === 'function') {
|
|
17
|
+
return `[Function: ${value.name || 'anonymous'}]`;
|
|
18
|
+
}
|
|
19
|
+
return String(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 安全的 JSON 序列化,兼容非 JSON 安全类型(undefined、function、Symbol、BigInt、循环引用等)
|
|
24
|
+
* @param {unknown} value - 要序列化的值
|
|
25
|
+
* @param {number} [indent=2] - 缩进空格数
|
|
26
|
+
* @returns {string} 序列化后的 JSON 字符串,失败时返回兜底描述
|
|
27
|
+
*/
|
|
28
|
+
export function safeStringify(value: unknown, indent: number = 2): string {
|
|
29
|
+
// 非对象类型直接转字符串,无需走 JSON.stringify
|
|
30
|
+
if (value === null || typeof value !== 'object') {
|
|
31
|
+
return primitiveToString(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// 利用 WeakSet 检测循环引用
|
|
36
|
+
const seen = new WeakSet();
|
|
37
|
+
return JSON.stringify(
|
|
38
|
+
value,
|
|
39
|
+
(_key: string, val: unknown) => {
|
|
40
|
+
// 处理 BigInt 类型(JSON.stringify 默认不支持)
|
|
41
|
+
if (typeof val === 'bigint') {
|
|
42
|
+
return val.toString();
|
|
43
|
+
}
|
|
44
|
+
// 将 undefined、function、Symbol 转为可读字符串,避免被 JSON.stringify 丢弃
|
|
45
|
+
if (typeof val === 'undefined' || typeof val === 'function' || typeof val === 'symbol') {
|
|
46
|
+
return primitiveToString(val);
|
|
47
|
+
}
|
|
48
|
+
// 检测循环引用:对象类型且非 null 时才需要检查
|
|
49
|
+
if (typeof val === 'object' && val !== null) {
|
|
50
|
+
if (seen.has(val)) {
|
|
51
|
+
return '[Circular]';
|
|
52
|
+
}
|
|
53
|
+
seen.add(val);
|
|
54
|
+
}
|
|
55
|
+
return val;
|
|
56
|
+
},
|
|
57
|
+
indent,
|
|
58
|
+
);
|
|
59
|
+
} catch {
|
|
60
|
+
// 极端情况兜底:尝试用 util.inspect 风格输出
|
|
61
|
+
try {
|
|
62
|
+
return JSON.stringify(String(value), null, indent);
|
|
63
|
+
} catch {
|
|
64
|
+
return '[Unserializable]';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|