clawt 2.19.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.js +234 -89
- package/dist/postinstall.js +4 -0
- package/docs/spec.md +30 -4
- package/package.json +1 -1
- package/src/commands/resume.ts +8 -2
- package/src/constants/index.ts +1 -1
- package/src/constants/prompt.ts +28 -0
- package/src/utils/index.ts +1 -1
- package/src/utils/worktree-matcher.ts +268 -1
- package/tests/unit/commands/resume.test.ts +29 -8
- package/tests/unit/utils/worktree-matcher.test.ts +142 -1
package/dist/postinstall.js
CHANGED
|
@@ -487,6 +487,10 @@ var CONFIG_DESCRIPTIONS = deriveConfigDescriptions(CONFIG_DEFINITIONS);
|
|
|
487
487
|
// src/constants/update.ts
|
|
488
488
|
var UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
489
489
|
|
|
490
|
+
// src/constants/prompt.ts
|
|
491
|
+
import chalk from "chalk";
|
|
492
|
+
var UNKNOWN_DATE_SEPARATOR_LABEL = `\u2550\u2550\u2550\u2550 ${chalk.bold.hex("#FF8C00")("\u672A\u77E5\u65E5\u671F")} \u2550\u2550\u2550\u2550`;
|
|
493
|
+
|
|
490
494
|
// scripts/postinstall.ts
|
|
491
495
|
function ensureDirectory(dirPath) {
|
|
492
496
|
if (!existsSync(dirPath)) {
|
package/docs/spec.md
CHANGED
|
@@ -1346,13 +1346,13 @@ clawt resume
|
|
|
1346
1346
|
|
|
1347
1347
|
1. **主 worktree 校验** (2.1)
|
|
1348
1348
|
2. **Claude Code CLI 校验**:确认 `claude` CLI 可用
|
|
1349
|
-
3. **解析目标 worktree
|
|
1349
|
+
3. **解析目标 worktree**:根据是否传入 `-b` 参数以及 worktree 数量,采用不同的解析策略:
|
|
1350
1350
|
- **未传 `-b` 参数**:
|
|
1351
1351
|
- 获取当前项目所有 worktree
|
|
1352
1352
|
- 无可用 worktree → 报错退出
|
|
1353
|
-
- 仅 1 个 worktree → 直接使用,无需选择
|
|
1354
|
-
- 多个 worktree →
|
|
1355
|
-
- **传了 `-b`
|
|
1353
|
+
- 仅 1 个 worktree → 通过 `resolveTargetWorktrees` 直接使用,无需选择
|
|
1354
|
+
- 多个 worktree → 通过 `promptGroupedMultiSelectBranches` 展示**按日期分组的交互式多选列表**(详见下文「按日期分组多选」)
|
|
1355
|
+
- **传了 `-b` 参数**:通过 `resolveTargetWorktrees` 解析,匹配策略如下:
|
|
1356
1356
|
1. **精确匹配优先**:在 worktree 列表中查找分支名完全相同的 worktree,找到则直接使用
|
|
1357
1357
|
2. **模糊匹配**(子串匹配,大小写不敏感):
|
|
1358
1358
|
- 唯一匹配 → 直接使用
|
|
@@ -1387,6 +1387,32 @@ clawt resume
|
|
|
1387
1387
|
|
|
1388
1388
|
启动命令通过配置项 `claudeCodeCommand`(默认值 `claude`)指定,与 `clawt run` 不传 `--tasks` 时的交互式界面行为一致。
|
|
1389
1389
|
|
|
1390
|
+
**按日期分组多选:**
|
|
1391
|
+
|
|
1392
|
+
当未传 `-b` 且有多个 worktree 时,使用 `promptGroupedMultiSelectBranches` 展示按创建日期分组的交互式多选列表,实现流程如下:
|
|
1393
|
+
|
|
1394
|
+
1. **日期分组**(`groupWorktreesByDate`):通过 `statSync` 获取各 worktree 目录的文件系统创建时间(`birthtime`),按本地时区格式化为 `YYYY-MM-DD` 作为分组键。无法获取创建时间的分支归入「未知日期」组。分组按日期降序排列,未知日期组在最后。
|
|
1395
|
+
2. **构建选项列表**(`buildGroupedChoices`):生成包含以下元素的 Enquirer MultiSelect choices 数组:
|
|
1396
|
+
- 顶部:全局全选选项 `[select-all]`
|
|
1397
|
+
- 每组:日期分隔线(显示日期和相对时间,如「2026-02-26(昨天)」)→ 组级全选选项 `[select-all: YYYY-MM-DD]` → 该组内各分支
|
|
1398
|
+
3. **三级联动选择**:通过继承 Enquirer MultiSelect 并覆写 `space()` 方法实现:
|
|
1399
|
+
- **全局全选**:toggle 所有 choices(含组全选)
|
|
1400
|
+
- **组级全选**:toggle 该组内所有分支,并同步全局全选状态
|
|
1401
|
+
- **普通分支**:toggle 该分支,同步所属组全选和全局全选状态
|
|
1402
|
+
4. **过滤结果**:返回时过滤掉全选项和组全选项,只返回实际选中的 worktree
|
|
1403
|
+
|
|
1404
|
+
相对日期显示规则:`formatRelativeDate` 基于自然日差值计算——今天 / 昨天 / N 天前 / N 个月前 / N 年前。
|
|
1405
|
+
|
|
1406
|
+
相关常量定义在 `src/constants/prompt.ts`:
|
|
1407
|
+
|
|
1408
|
+
| 常量 | 说明 |
|
|
1409
|
+
| ---- | ---- |
|
|
1410
|
+
| `GROUP_SELECT_ALL_PREFIX` | 组级全选选项的 name 前缀(`__group_select_all_`) |
|
|
1411
|
+
| `GROUP_SELECT_ALL_LABEL(dateLabel)` | 生成组级全选选项的显示文本 |
|
|
1412
|
+
| `GROUP_SEPARATOR_LABEL(dateLabel, relativeTime)` | 生成日期分隔线的显示文本(含 chalk 高亮) |
|
|
1413
|
+
| `UNKNOWN_DATE_GROUP` | 无法获取创建日期时的默认分组名称(`未知日期`) |
|
|
1414
|
+
| `UNKNOWN_DATE_SEPARATOR_LABEL` | 未知日期分组的分隔线显示文本 |
|
|
1415
|
+
|
|
1390
1416
|
**会话自动续接:** 启动前会自动检测该 worktree 是否存在 Claude Code 历史会话(通过检查 `~/.claude/projects/<encoded-path>/` 下是否有 `.jsonl` 文件判断),如果存在则自动追加 `--continue` 参数继续上次对话,否则打开新对话。启动信息中会显示当前模式("继续上次对话"或"新对话")。路径编码规则:将绝对路径中所有非字母数字字符替换为 `-`(与 Claude Code 源码的编码逻辑一致)。
|
|
1391
1417
|
|
|
1392
1418
|
---
|
package/package.json
CHANGED
package/src/commands/resume.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
launchInteractiveClaudeInNewTerminal,
|
|
12
12
|
hasClaudeSessionHistory,
|
|
13
13
|
resolveTargetWorktrees,
|
|
14
|
+
promptGroupedMultiSelectBranches,
|
|
14
15
|
printInfo,
|
|
15
16
|
printSuccess,
|
|
16
17
|
confirmAction,
|
|
@@ -51,8 +52,13 @@ async function handleResume(options: ResumeOptions): Promise<void> {
|
|
|
51
52
|
logger.info(`resume 命令执行,分支过滤: ${options.branch ?? '(无)'}`);
|
|
52
53
|
const worktrees = getProjectWorktrees();
|
|
53
54
|
|
|
54
|
-
//
|
|
55
|
-
|
|
55
|
+
// 未指定 -b 且有多个 worktree 时,默认使用按日期分组的多选交互
|
|
56
|
+
let targetWorktrees: WorktreeInfo[];
|
|
57
|
+
if (!options.branch && worktrees.length > 1) {
|
|
58
|
+
targetWorktrees = await promptGroupedMultiSelectBranches(worktrees, RESUME_RESOLVE_MESSAGES.selectBranch);
|
|
59
|
+
} else {
|
|
60
|
+
targetWorktrees = await resolveTargetWorktrees(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
|
|
61
|
+
}
|
|
56
62
|
|
|
57
63
|
// 用户未选择任何分支时直接退出
|
|
58
64
|
if (targetWorktrees.length === 0) {
|
package/src/constants/index.ts
CHANGED
|
@@ -29,4 +29,4 @@ export {
|
|
|
29
29
|
CLEAR_SCREEN,
|
|
30
30
|
CURSOR_HOME,
|
|
31
31
|
} from './progress.js';
|
|
32
|
-
export { SELECT_ALL_NAME, SELECT_ALL_LABEL } from './prompt.js';
|
|
32
|
+
export { SELECT_ALL_NAME, SELECT_ALL_LABEL, GROUP_SELECT_ALL_PREFIX, GROUP_SELECT_ALL_LABEL, GROUP_SEPARATOR_LABEL, UNKNOWN_DATE_GROUP, UNKNOWN_DATE_SEPARATOR_LABEL } from './prompt.js';
|
package/src/constants/prompt.ts
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
1
3
|
/** 多选列表中全选选项的标识名称 */
|
|
2
4
|
export const SELECT_ALL_NAME = '__select_all__';
|
|
3
5
|
|
|
4
6
|
/** 多选列表中全选选项的显示文本 */
|
|
5
7
|
export const SELECT_ALL_LABEL = '[select-all]';
|
|
8
|
+
|
|
9
|
+
/** 组级全选选项的 name 前缀 */
|
|
10
|
+
export const GROUP_SELECT_ALL_PREFIX = '__group_select_all_';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 生成组级全选选项的显示文本
|
|
14
|
+
* @param {string} dateLabel - 日期标签
|
|
15
|
+
* @returns {string} 格式化的组全选显示文本
|
|
16
|
+
*/
|
|
17
|
+
export const GROUP_SELECT_ALL_LABEL = (dateLabel: string): string => `[select-all: ${dateLabel}]`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 生成日期分组分隔线的显示文本
|
|
21
|
+
* 日期部分高亮显示,两侧使用 === 分隔线增强视觉区分
|
|
22
|
+
* @param {string} dateLabel - 日期标签
|
|
23
|
+
* @param {string} relativeTime - 相对时间描述
|
|
24
|
+
* @returns {string} 格式化的分隔线文本
|
|
25
|
+
*/
|
|
26
|
+
export const GROUP_SEPARATOR_LABEL = (dateLabel: string, relativeTime: string): string =>
|
|
27
|
+
`════ ${chalk.bold.hex('#FF8C00')(dateLabel)}(${chalk.hex('#FF8C00')(relativeTime)}) ════`;
|
|
28
|
+
|
|
29
|
+
/** 无法获取创建日期时的默认分组名称 */
|
|
30
|
+
export const UNKNOWN_DATE_GROUP = '未知日期';
|
|
31
|
+
|
|
32
|
+
/** 未知日期分组的分隔线显示文本 */
|
|
33
|
+
export const UNKNOWN_DATE_SEPARATOR_LABEL = `════ ${chalk.bold.hex('#FF8C00')('未知日期')} ════`;
|
package/src/utils/index.ts
CHANGED
|
@@ -56,7 +56,7 @@ export { ensureDir, removeEmptyDir, calculateDirSize } from './fs.js';
|
|
|
56
56
|
export { multilineInput } from './prompt.js';
|
|
57
57
|
export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
|
|
58
58
|
export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
|
|
59
|
-
export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
|
|
59
|
+
export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, promptGroupedMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees, groupWorktreesByDate, buildGroupedChoices, buildGroupMembershipMap } from './worktree-matcher.js';
|
|
60
60
|
export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
|
|
61
61
|
export { ProgressRenderer } from './progress.js';
|
|
62
62
|
export { parseTaskFile, loadTaskFile, parseTasksFromOptions } from './task-file.js';
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import Enquirer from 'enquirer';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
2
3
|
import { ClawtError } from '../errors/index.js';
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
SELECT_ALL_NAME,
|
|
6
|
+
SELECT_ALL_LABEL,
|
|
7
|
+
GROUP_SELECT_ALL_PREFIX,
|
|
8
|
+
GROUP_SELECT_ALL_LABEL,
|
|
9
|
+
GROUP_SEPARATOR_LABEL,
|
|
10
|
+
UNKNOWN_DATE_GROUP,
|
|
11
|
+
UNKNOWN_DATE_SEPARATOR_LABEL,
|
|
12
|
+
} from '../constants/index.js';
|
|
4
13
|
import type { WorktreeInfo } from '../types/index.js';
|
|
5
14
|
|
|
6
15
|
/** enquirer MultiSelect 选项条目的运行时结构 */
|
|
@@ -264,3 +273,261 @@ export async function resolveTargetWorktree(
|
|
|
264
273
|
const allBranches = worktrees.map((wt) => wt.branch);
|
|
265
274
|
throw new ClawtError(messages.noMatch(branchName, allBranches));
|
|
266
275
|
}
|
|
276
|
+
|
|
277
|
+
/** enquirer MultiSelect 分隔线条目结构 */
|
|
278
|
+
interface MultiSelectSeparator {
|
|
279
|
+
role: 'separator';
|
|
280
|
+
message: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** enquirer MultiSelect choices 数组的条目类型 */
|
|
284
|
+
type GroupedChoice = { name: string; message: string } | MultiSelectSeparator;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 将 Date 对象格式化为本地时区的 YYYY-MM-DD 字符串
|
|
288
|
+
* @param {Date} date - 日期对象
|
|
289
|
+
* @returns {string} YYYY-MM-DD 格式的本地日期字符串
|
|
290
|
+
*/
|
|
291
|
+
function formatLocalDate(date: Date): string {
|
|
292
|
+
const year = date.getFullYear();
|
|
293
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
294
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
295
|
+
return `${year}-${month}-${day}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 获取 worktree 目录的创建日期(本地时区)
|
|
300
|
+
* 通过文件系统的 birthtime 获取目录实际创建时间,比 git reflog 更准确
|
|
301
|
+
* @param {string} dirPath - worktree 目录路径
|
|
302
|
+
* @returns {string | null} YYYY-MM-DD 格式的本地日期字符串,无法获取时返回 null
|
|
303
|
+
*/
|
|
304
|
+
function getWorktreeCreatedDate(dirPath: string): string | null {
|
|
305
|
+
try {
|
|
306
|
+
const stat = statSync(dirPath);
|
|
307
|
+
return formatLocalDate(stat.birthtime);
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 将 YYYY-MM-DD 日期字符串格式化为中文相对日期描述
|
|
315
|
+
* 基于自然日计算,适用于日期分组场景
|
|
316
|
+
* @param {string} dateStr - YYYY-MM-DD 格式的日期字符串
|
|
317
|
+
* @returns {string} 中文相对日期描述,如"今天"、"昨天"、"3 天前"
|
|
318
|
+
*/
|
|
319
|
+
function formatRelativeDate(dateStr: string): string {
|
|
320
|
+
const today = formatLocalDate(new Date());
|
|
321
|
+
const todayMs = new Date(today).getTime();
|
|
322
|
+
const targetMs = new Date(dateStr).getTime();
|
|
323
|
+
const diffDays = Math.round((todayMs - targetMs) / (1000 * 60 * 60 * 24));
|
|
324
|
+
|
|
325
|
+
if (diffDays === 0) return '今天';
|
|
326
|
+
if (diffDays === 1) return '昨天';
|
|
327
|
+
if (diffDays < 30) return `${diffDays} 天前`;
|
|
328
|
+
if (diffDays < 365) {
|
|
329
|
+
const months = Math.floor(diffDays / 30);
|
|
330
|
+
return `${months} 个月前`;
|
|
331
|
+
}
|
|
332
|
+
const years = Math.floor(diffDays / 365);
|
|
333
|
+
return `${years} 年前`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 按创建日期对 worktree 列表进行分组
|
|
338
|
+
* 通过 worktree 目录的文件系统创建时间进行分组
|
|
339
|
+
* 无法获取日期的分支归入"未知日期"组
|
|
340
|
+
* @param {WorktreeInfo[]} worktrees - worktree 列表
|
|
341
|
+
* @returns {Map<string, WorktreeInfo[]>} 日期 → worktree 列表的映射,按日期降序排列,未知日期在最后
|
|
342
|
+
*/
|
|
343
|
+
export function groupWorktreesByDate(worktrees: WorktreeInfo[]): Map<string, WorktreeInfo[]> {
|
|
344
|
+
const groups = new Map<string, WorktreeInfo[]>();
|
|
345
|
+
|
|
346
|
+
for (const wt of worktrees) {
|
|
347
|
+
const dateKey = getWorktreeCreatedDate(wt.path) ?? UNKNOWN_DATE_GROUP;
|
|
348
|
+
|
|
349
|
+
if (!groups.has(dateKey)) {
|
|
350
|
+
groups.set(dateKey, []);
|
|
351
|
+
}
|
|
352
|
+
groups.get(dateKey)!.push(wt);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 按日期降序排列,未知日期放最后
|
|
356
|
+
const sortedEntries = [...groups.entries()].sort((a, b) => {
|
|
357
|
+
if (a[0] === UNKNOWN_DATE_GROUP) return 1;
|
|
358
|
+
if (b[0] === UNKNOWN_DATE_GROUP) return -1;
|
|
359
|
+
return b[0].localeCompare(a[0]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return new Map(sortedEntries);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 根据分组数据构建 enquirer MultiSelect 的 choices 数组
|
|
367
|
+
* 包含全局全选、每组的分隔线和组全选、以及各组内的分支选项
|
|
368
|
+
* @param {Map<string, WorktreeInfo[]>} groups - 日期分组数据
|
|
369
|
+
* @returns {GroupedChoice[]} enquirer choices 数组
|
|
370
|
+
*/
|
|
371
|
+
export function buildGroupedChoices(groups: Map<string, WorktreeInfo[]>): GroupedChoice[] {
|
|
372
|
+
const choices: GroupedChoice[] = [];
|
|
373
|
+
|
|
374
|
+
// 顶部插入全局全选
|
|
375
|
+
choices.push({ name: SELECT_ALL_NAME, message: SELECT_ALL_LABEL });
|
|
376
|
+
|
|
377
|
+
for (const [dateKey, worktreeList] of groups) {
|
|
378
|
+
// 分隔线
|
|
379
|
+
if (dateKey === UNKNOWN_DATE_GROUP) {
|
|
380
|
+
choices.push({ role: 'separator', message: UNKNOWN_DATE_SEPARATOR_LABEL });
|
|
381
|
+
} else {
|
|
382
|
+
const relativeTime = formatRelativeDate(dateKey);
|
|
383
|
+
choices.push({ role: 'separator', message: GROUP_SEPARATOR_LABEL(dateKey, relativeTime) });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 组级全选
|
|
387
|
+
const groupSelectAllName = `${GROUP_SELECT_ALL_PREFIX}${dateKey}`;
|
|
388
|
+
choices.push({ name: groupSelectAllName, message: GROUP_SELECT_ALL_LABEL(dateKey) });
|
|
389
|
+
|
|
390
|
+
// 该组内各分支
|
|
391
|
+
for (const wt of worktreeList) {
|
|
392
|
+
choices.push({ name: wt.branch, message: wt.branch });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return choices;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 构建组全选 name 到该组分支 name 列表的映射
|
|
401
|
+
* 用于 space() 方法中快速查找某个组全选项对应的所有分支
|
|
402
|
+
* @param {Map<string, WorktreeInfo[]>} groups - 日期分组数据
|
|
403
|
+
* @returns {Map<string, string[]>} 组全选 name → 分支 name 列表的映射
|
|
404
|
+
*/
|
|
405
|
+
export function buildGroupMembershipMap(groups: Map<string, WorktreeInfo[]>): Map<string, string[]> {
|
|
406
|
+
const map = new Map<string, string[]>();
|
|
407
|
+
|
|
408
|
+
for (const [dateKey, worktreeList] of groups) {
|
|
409
|
+
const groupSelectAllName = `${GROUP_SELECT_ALL_PREFIX}${dateKey}`;
|
|
410
|
+
map.set(groupSelectAllName, worktreeList.map((wt) => wt.branch));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return map;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 通过交互式多选列表(按日期分组)让用户选择多个分支
|
|
418
|
+
* 提供三级联动:全局全选、组级全选、单个分支
|
|
419
|
+
* @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
|
|
420
|
+
* @param {string} message - 选择提示信息
|
|
421
|
+
* @returns {Promise<WorktreeInfo[]>} 用户选择的 worktree 列表
|
|
422
|
+
*/
|
|
423
|
+
export async function promptGroupedMultiSelectBranches(
|
|
424
|
+
worktrees: WorktreeInfo[],
|
|
425
|
+
message: string,
|
|
426
|
+
): Promise<WorktreeInfo[]> {
|
|
427
|
+
const groups = groupWorktreesByDate(worktrees);
|
|
428
|
+
const choices = buildGroupedChoices(groups);
|
|
429
|
+
const groupMembershipMap = buildGroupMembershipMap(groups);
|
|
430
|
+
|
|
431
|
+
// 收集所有组全选的 name,用于判断某个 choice 是否为组全选项
|
|
432
|
+
const groupSelectAllNames = new Set(groupMembershipMap.keys());
|
|
433
|
+
|
|
434
|
+
// 收集所有实际分支的 name
|
|
435
|
+
const allBranchNames = new Set(worktrees.map((wt) => wt.branch));
|
|
436
|
+
|
|
437
|
+
// @ts-expect-error enquirer 类型声明未导出 MultiSelect 类,但运行时存在
|
|
438
|
+
const MultiSelect: new (options: Record<string, unknown>) => MultiSelectInstance = Enquirer.MultiSelect;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 扩展 MultiSelect,实现三级联动的 space() 覆写
|
|
442
|
+
* - 全局全选:切换所有 choices(含组全选)
|
|
443
|
+
* - 组级全选:切换该组内所有分支,同步全局全选状态
|
|
444
|
+
* - 普通分支:toggle 该分支,同步所属组全选和全局全选状态
|
|
445
|
+
*/
|
|
446
|
+
class MultiSelectWithGroupSelectAll extends MultiSelect {
|
|
447
|
+
space(this: MultiSelectInstance) {
|
|
448
|
+
if (!this.focused) return;
|
|
449
|
+
|
|
450
|
+
const focusedName = this.focused.name;
|
|
451
|
+
|
|
452
|
+
if (focusedName === SELECT_ALL_NAME) {
|
|
453
|
+
// 全局全选:切换所有 choices
|
|
454
|
+
const willEnable = !this.focused.enabled;
|
|
455
|
+
for (const ch of this.choices) {
|
|
456
|
+
ch.enabled = willEnable;
|
|
457
|
+
}
|
|
458
|
+
return this.render();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (groupSelectAllNames.has(focusedName)) {
|
|
462
|
+
// 组级全选:切换该组内所有分支
|
|
463
|
+
const willEnable = !this.focused.enabled;
|
|
464
|
+
const memberNames = groupMembershipMap.get(focusedName)!;
|
|
465
|
+
// 切换组全选自身
|
|
466
|
+
this.focused.enabled = willEnable;
|
|
467
|
+
// 切换该组的所有分支
|
|
468
|
+
for (const ch of this.choices) {
|
|
469
|
+
if (memberNames.includes(ch.name)) {
|
|
470
|
+
ch.enabled = willEnable;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// 同步全局全选状态:检查所有实际分支是否全选
|
|
474
|
+
syncGlobalSelectAll(this.choices);
|
|
475
|
+
return this.render();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 普通分支:toggle 该分支
|
|
479
|
+
this.toggle(this.focused);
|
|
480
|
+
|
|
481
|
+
// 同步所属组全选状态
|
|
482
|
+
syncGroupSelectAll(this.choices, focusedName);
|
|
483
|
+
// 同步全局全选状态
|
|
484
|
+
syncGlobalSelectAll(this.choices);
|
|
485
|
+
|
|
486
|
+
return this.render();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* 同步全局全选状态
|
|
492
|
+
* 根据所有实际分支的选中状态更新全局全选项
|
|
493
|
+
* @param {MultiSelectChoice[]} choiceList - choices 列表
|
|
494
|
+
*/
|
|
495
|
+
function syncGlobalSelectAll(choiceList: MultiSelectChoice[]): void {
|
|
496
|
+
const selectAllChoice = choiceList.find((ch) => ch.name === SELECT_ALL_NAME);
|
|
497
|
+
if (!selectAllChoice) return;
|
|
498
|
+
|
|
499
|
+
const branchItems = choiceList.filter((ch) => allBranchNames.has(ch.name));
|
|
500
|
+
selectAllChoice.enabled = branchItems.length > 0 && branchItems.every((ch) => ch.enabled);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 同步指定分支所属组的全选状态
|
|
505
|
+
* 根据该组内所有分支的选中状态更新组全选项
|
|
506
|
+
* @param {MultiSelectChoice[]} choiceList - choices 列表
|
|
507
|
+
* @param {string} branchName - 刚被 toggle 的分支名
|
|
508
|
+
*/
|
|
509
|
+
function syncGroupSelectAll(choiceList: MultiSelectChoice[], branchName: string): void {
|
|
510
|
+
for (const [groupName, memberNames] of groupMembershipMap) {
|
|
511
|
+
if (!memberNames.includes(branchName)) continue;
|
|
512
|
+
|
|
513
|
+
const groupChoice = choiceList.find((ch) => ch.name === groupName);
|
|
514
|
+
if (!groupChoice) continue;
|
|
515
|
+
|
|
516
|
+
const memberChoices = choiceList.filter((ch) => memberNames.includes(ch.name));
|
|
517
|
+
groupChoice.enabled = memberChoices.length > 0 && memberChoices.every((ch) => ch.enabled);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const selectedBranches: string[] = await new MultiSelectWithGroupSelectAll({
|
|
523
|
+
message,
|
|
524
|
+
choices,
|
|
525
|
+
// 使用空心圆/实心圆作为选中指示符
|
|
526
|
+
symbols: {
|
|
527
|
+
indicator: { on: '●', off: '○' },
|
|
528
|
+
},
|
|
529
|
+
}).run();
|
|
530
|
+
|
|
531
|
+
// 过滤掉全选项和组全选项,只返回实际选中的 worktree
|
|
532
|
+
return worktrees.filter((wt) => selectedBranches.includes(wt.branch));
|
|
533
|
+
}
|
|
@@ -20,6 +20,7 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
20
20
|
getProjectWorktrees: vi.fn(),
|
|
21
21
|
launchInteractiveClaude: vi.fn(),
|
|
22
22
|
resolveTargetWorktrees: vi.fn(),
|
|
23
|
+
promptGroupedMultiSelectBranches: vi.fn(),
|
|
23
24
|
}));
|
|
24
25
|
|
|
25
26
|
import { registerResumeCommand } from '../../../src/commands/resume.js';
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
getProjectWorktrees,
|
|
30
31
|
launchInteractiveClaude,
|
|
31
32
|
resolveTargetWorktrees,
|
|
33
|
+
promptGroupedMultiSelectBranches,
|
|
32
34
|
} from '../../../src/utils/index.js';
|
|
33
35
|
|
|
34
36
|
const mockedValidateMainWorktree = vi.mocked(validateMainWorktree);
|
|
@@ -36,6 +38,7 @@ const mockedValidateClaudeCodeInstalled = vi.mocked(validateClaudeCodeInstalled)
|
|
|
36
38
|
const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
|
|
37
39
|
const mockedLaunchInteractiveClaude = vi.mocked(launchInteractiveClaude);
|
|
38
40
|
const mockedResolveTargetWorktrees = vi.mocked(resolveTargetWorktrees);
|
|
41
|
+
const mockedPromptGroupedMultiSelectBranches = vi.mocked(promptGroupedMultiSelectBranches);
|
|
39
42
|
|
|
40
43
|
beforeEach(() => {
|
|
41
44
|
mockedValidateMainWorktree.mockReset();
|
|
@@ -43,6 +46,7 @@ beforeEach(() => {
|
|
|
43
46
|
mockedGetProjectWorktrees.mockReset();
|
|
44
47
|
mockedLaunchInteractiveClaude.mockReset();
|
|
45
48
|
mockedResolveTargetWorktrees.mockReset();
|
|
49
|
+
mockedPromptGroupedMultiSelectBranches.mockReset();
|
|
46
50
|
});
|
|
47
51
|
|
|
48
52
|
describe('registerResumeCommand', () => {
|
|
@@ -55,7 +59,7 @@ describe('registerResumeCommand', () => {
|
|
|
55
59
|
});
|
|
56
60
|
|
|
57
61
|
describe('handleResume', () => {
|
|
58
|
-
it('
|
|
62
|
+
it('传 -b 时走标准解析流程', async () => {
|
|
59
63
|
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
60
64
|
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
61
65
|
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
@@ -68,10 +72,31 @@ describe('handleResume', () => {
|
|
|
68
72
|
expect(mockedValidateMainWorktree).toHaveBeenCalled();
|
|
69
73
|
expect(mockedValidateClaudeCodeInstalled).toHaveBeenCalled();
|
|
70
74
|
expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
|
|
75
|
+
expect(mockedPromptGroupedMultiSelectBranches).not.toHaveBeenCalled();
|
|
71
76
|
expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
|
|
72
77
|
});
|
|
73
78
|
|
|
74
|
-
it('不传 -b
|
|
79
|
+
it('不传 -b 且多个 worktree 时默认使用分组多选', async () => {
|
|
80
|
+
const worktrees = [
|
|
81
|
+
{ path: '/path/feature-a', branch: 'feature-a' },
|
|
82
|
+
{ path: '/path/feature-b', branch: 'feature-b' },
|
|
83
|
+
];
|
|
84
|
+
mockedGetProjectWorktrees.mockReturnValue(worktrees);
|
|
85
|
+
mockedPromptGroupedMultiSelectBranches.mockResolvedValue([worktrees[0]]);
|
|
86
|
+
|
|
87
|
+
const program = new Command();
|
|
88
|
+
program.exitOverride();
|
|
89
|
+
registerResumeCommand(program);
|
|
90
|
+
await program.parseAsync(['resume'], { from: 'user' });
|
|
91
|
+
|
|
92
|
+
expect(mockedPromptGroupedMultiSelectBranches).toHaveBeenCalledWith(
|
|
93
|
+
worktrees,
|
|
94
|
+
expect.any(String),
|
|
95
|
+
);
|
|
96
|
+
expect(mockedResolveTargetWorktrees).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('仅 1 个 worktree 时走标准流程', async () => {
|
|
75
100
|
const worktree = { path: '/path/feature', branch: 'feature' };
|
|
76
101
|
mockedGetProjectWorktrees.mockReturnValue([worktree]);
|
|
77
102
|
mockedResolveTargetWorktrees.mockResolvedValue([worktree]);
|
|
@@ -81,11 +106,7 @@ describe('handleResume', () => {
|
|
|
81
106
|
registerResumeCommand(program);
|
|
82
107
|
await program.parseAsync(['resume'], { from: 'user' });
|
|
83
108
|
|
|
84
|
-
|
|
85
|
-
expect(
|
|
86
|
-
expect.any(Array),
|
|
87
|
-
expect.any(Object),
|
|
88
|
-
undefined,
|
|
89
|
-
);
|
|
109
|
+
expect(mockedResolveTargetWorktrees).toHaveBeenCalled();
|
|
110
|
+
expect(mockedPromptGroupedMultiSelectBranches).not.toHaveBeenCalled();
|
|
90
111
|
});
|
|
91
112
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { findExactMatch, findFuzzyMatches, resolveTargetWorktree, resolveTargetWorktrees } from '../../../src/utils/worktree-matcher.js';
|
|
2
|
+
import { findExactMatch, findFuzzyMatches, resolveTargetWorktree, resolveTargetWorktrees, groupWorktreesByDate, buildGroupedChoices, buildGroupMembershipMap } from '../../../src/utils/worktree-matcher.js';
|
|
3
3
|
import { createWorktreeInfo, createWorktreeList } from '../../helpers/fixtures.js';
|
|
4
4
|
import { ClawtError } from '../../../src/errors/index.js';
|
|
5
|
+
import { SELECT_ALL_NAME, GROUP_SELECT_ALL_PREFIX, UNKNOWN_DATE_GROUP } from '../../../src/constants/index.js';
|
|
5
6
|
import type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from '../../../src/utils/worktree-matcher.js';
|
|
6
7
|
|
|
7
8
|
// mock enquirer
|
|
@@ -16,6 +17,19 @@ vi.mock('enquirer', () => ({
|
|
|
16
17
|
},
|
|
17
18
|
}));
|
|
18
19
|
|
|
20
|
+
// mock getBranchCreatedAt,分组测试中按需控制返回值
|
|
21
|
+
vi.mock('../../../src/utils/git.js', () => ({
|
|
22
|
+
getBranchCreatedAt: vi.fn().mockReturnValue(null),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// mock node:fs 的 statSync,分组测试中按需控制返回值
|
|
26
|
+
vi.mock('node:fs', () => ({
|
|
27
|
+
statSync: vi.fn().mockReturnValue({ birthtime: new Date('2026-02-27T10:00:00') }),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { statSync } from 'node:fs';
|
|
31
|
+
const mockedStatSync = vi.mocked(statSync);
|
|
32
|
+
|
|
19
33
|
/** 测试用消息配置 */
|
|
20
34
|
const testMessages: WorktreeResolveMessages = {
|
|
21
35
|
noWorktrees: '无可用 worktree',
|
|
@@ -190,3 +204,130 @@ describe('resolveTargetWorktrees', () => {
|
|
|
190
204
|
await expect(resolveTargetWorktrees(worktrees, testMultiMessages, 'xyz')).rejects.toThrow(ClawtError);
|
|
191
205
|
});
|
|
192
206
|
});
|
|
207
|
+
|
|
208
|
+
describe('groupWorktreesByDate', () => {
|
|
209
|
+
it('多日期分组:不同日期的分支被正确分组,最新日期在前', () => {
|
|
210
|
+
const worktrees = [
|
|
211
|
+
createWorktreeInfo({ path: '/worktrees/feature-auth', branch: 'feature-auth' }),
|
|
212
|
+
createWorktreeInfo({ path: '/worktrees/feature-login', branch: 'feature-login' }),
|
|
213
|
+
createWorktreeInfo({ path: '/worktrees/bugfix-nav', branch: 'bugfix-nav' }),
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
mockedStatSync
|
|
217
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
|
|
218
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T14:00:00') } as any)
|
|
219
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-25T09:00:00') } as any);
|
|
220
|
+
|
|
221
|
+
const groups = groupWorktreesByDate(worktrees);
|
|
222
|
+
const keys = [...groups.keys()];
|
|
223
|
+
|
|
224
|
+
// 最新日期在前
|
|
225
|
+
expect(keys).toEqual(['2026-02-26', '2026-02-25']);
|
|
226
|
+
// 2026-02-26 组有 2 个分支
|
|
227
|
+
expect(groups.get('2026-02-26')).toHaveLength(2);
|
|
228
|
+
expect(groups.get('2026-02-26')!.map((wt) => wt.branch)).toEqual(['feature-auth', 'feature-login']);
|
|
229
|
+
// 2026-02-25 组有 1 个分支
|
|
230
|
+
expect(groups.get('2026-02-25')).toHaveLength(1);
|
|
231
|
+
expect(groups.get('2026-02-25')![0].branch).toBe('bugfix-nav');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('statSync 异常时归入"未知日期"组', () => {
|
|
235
|
+
const worktrees = [
|
|
236
|
+
createWorktreeInfo({ path: '/worktrees/feature-auth', branch: 'feature-auth' }),
|
|
237
|
+
createWorktreeInfo({ path: '/worktrees/old-branch', branch: 'old-branch' }),
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
mockedStatSync
|
|
241
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
|
|
242
|
+
.mockImplementationOnce(() => { throw new Error('ENOENT'); });
|
|
243
|
+
|
|
244
|
+
const groups = groupWorktreesByDate(worktrees);
|
|
245
|
+
const keys = [...groups.keys()];
|
|
246
|
+
|
|
247
|
+
// 未知日期在最后
|
|
248
|
+
expect(keys).toEqual(['2026-02-26', UNKNOWN_DATE_GROUP]);
|
|
249
|
+
expect(groups.get(UNKNOWN_DATE_GROUP)).toHaveLength(1);
|
|
250
|
+
expect(groups.get(UNKNOWN_DATE_GROUP)![0].branch).toBe('old-branch');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('单日期分组:所有分支同一天', () => {
|
|
254
|
+
const worktrees = [
|
|
255
|
+
createWorktreeInfo({ path: '/worktrees/feature-a', branch: 'feature-a' }),
|
|
256
|
+
createWorktreeInfo({ path: '/worktrees/feature-b', branch: 'feature-b' }),
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
mockedStatSync
|
|
260
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T10:00:00') } as any)
|
|
261
|
+
.mockReturnValueOnce({ birthtime: new Date('2026-02-26T15:00:00') } as any);
|
|
262
|
+
|
|
263
|
+
const groups = groupWorktreesByDate(worktrees);
|
|
264
|
+
const keys = [...groups.keys()];
|
|
265
|
+
|
|
266
|
+
expect(keys).toEqual(['2026-02-26']);
|
|
267
|
+
expect(groups.get('2026-02-26')).toHaveLength(2);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('buildGroupedChoices', () => {
|
|
272
|
+
it('构建的 choices 包含全局全选、分隔线、组全选和分支', () => {
|
|
273
|
+
const groups = new Map<string, Array<{ path: string; branch: string }>>([
|
|
274
|
+
['2026-02-26', [
|
|
275
|
+
createWorktreeInfo({ branch: 'feature-auth' }),
|
|
276
|
+
createWorktreeInfo({ branch: 'feature-login' }),
|
|
277
|
+
]],
|
|
278
|
+
[UNKNOWN_DATE_GROUP, [
|
|
279
|
+
createWorktreeInfo({ branch: 'old-branch' }),
|
|
280
|
+
]],
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
const choices = buildGroupedChoices(groups);
|
|
284
|
+
|
|
285
|
+
// 全局全选在顶部
|
|
286
|
+
expect(choices[0]).toEqual({ name: SELECT_ALL_NAME, message: '[select-all]' });
|
|
287
|
+
|
|
288
|
+
// 第一组分隔线
|
|
289
|
+
expect(choices[1]).toHaveProperty('role', 'separator');
|
|
290
|
+
|
|
291
|
+
// 第一组组全选
|
|
292
|
+
expect(choices[2]).toEqual({
|
|
293
|
+
name: `${GROUP_SELECT_ALL_PREFIX}2026-02-26`,
|
|
294
|
+
message: '[select-all: 2026-02-26]',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// 第一组分支
|
|
298
|
+
expect(choices[3]).toEqual({ name: 'feature-auth', message: 'feature-auth' });
|
|
299
|
+
expect(choices[4]).toEqual({ name: 'feature-login', message: 'feature-login' });
|
|
300
|
+
|
|
301
|
+
// 未知日期组分隔线(含 chalk 高亮,使用 stringContaining 匹配)
|
|
302
|
+
expect(choices[5]).toHaveProperty('role', 'separator');
|
|
303
|
+
expect((choices[5] as { message: string }).message).toContain('未知日期');
|
|
304
|
+
|
|
305
|
+
// 未知日期组全选
|
|
306
|
+
expect(choices[6]).toEqual({
|
|
307
|
+
name: `${GROUP_SELECT_ALL_PREFIX}${UNKNOWN_DATE_GROUP}`,
|
|
308
|
+
message: `[select-all: ${UNKNOWN_DATE_GROUP}]`,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// 未知日期组分支
|
|
312
|
+
expect(choices[7]).toEqual({ name: 'old-branch', message: 'old-branch' });
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('buildGroupMembershipMap', () => {
|
|
317
|
+
it('构建组全选 name 到分支 name 列表的正确映射', () => {
|
|
318
|
+
const groups = new Map<string, Array<{ path: string; branch: string }>>([
|
|
319
|
+
['2026-02-26', [
|
|
320
|
+
createWorktreeInfo({ branch: 'feature-auth' }),
|
|
321
|
+
createWorktreeInfo({ branch: 'feature-login' }),
|
|
322
|
+
]],
|
|
323
|
+
['2026-02-25', [
|
|
324
|
+
createWorktreeInfo({ branch: 'bugfix-nav' }),
|
|
325
|
+
]],
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
const membershipMap = buildGroupMembershipMap(groups);
|
|
329
|
+
|
|
330
|
+
expect(membershipMap.get(`${GROUP_SELECT_ALL_PREFIX}2026-02-26`)).toEqual(['feature-auth', 'feature-login']);
|
|
331
|
+
expect(membershipMap.get(`${GROUP_SELECT_ALL_PREFIX}2026-02-25`)).toEqual(['bugfix-nav']);
|
|
332
|
+
});
|
|
333
|
+
});
|