ai-git-tools 2.0.6 → 2.0.7
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/package.json +1 -1
- package/src/commands/pr.js +128 -44
- package/src/core/github-api.js +161 -0
- package/src/utils/interactive-select.js +154 -0
package/package.json
CHANGED
package/src/commands/pr.js
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
|
-
import * as readline from 'readline/promises';
|
|
9
|
-
import { stdin as input, stdout as output } from 'process';
|
|
10
8
|
import { loadPRConfig } from '../core/config-loader.js';
|
|
11
9
|
import { GitOperations } from '../core/git-operations.js';
|
|
12
10
|
import { AIClient } from '../core/ai-client.js';
|
|
11
|
+
import { GitHubAPI } from '../core/github-api.js';
|
|
13
12
|
import { Logger } from '../utils/logger.js';
|
|
13
|
+
import { InteractiveSelect } from '../utils/interactive-select.js';
|
|
14
14
|
import { handleError, getProjectTypePrompt } from '../utils/helpers.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -78,65 +78,139 @@ function getCurrentUser() {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
* 互動式選擇 Reviewers
|
|
81
|
+
* 互動式選擇 Reviewers(使用鍵盤上下選擇)
|
|
82
82
|
*/
|
|
83
|
-
async function selectReviewers(suggestedReviewers, currentUser) {
|
|
83
|
+
async function selectReviewers(suggestedReviewers, currentUser, config) {
|
|
84
84
|
const logger = new Logger();
|
|
85
85
|
|
|
86
86
|
console.log('\n' + '═'.repeat(80));
|
|
87
87
|
console.log('🎯 選擇 Reviewers');
|
|
88
88
|
console.log('═'.repeat(80) + '\n');
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
// 先嘗試從 GitHub 抓取團隊和成員
|
|
91
|
+
let githubTeams = {};
|
|
92
|
+
let githubMembers = [];
|
|
93
|
+
|
|
94
|
+
if (config.orgName) {
|
|
95
|
+
const githubAPI = new GitHubAPI({ orgName: config.orgName });
|
|
96
|
+
const githubData = await githubAPI.fetchTeams(config.orgName);
|
|
97
|
+
githubTeams = githubData.teams || {};
|
|
98
|
+
githubMembers = githubData.members || [];
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
//
|
|
101
|
+
// 準備選項列表
|
|
102
|
+
const options = [];
|
|
103
|
+
|
|
104
|
+
// 添加團隊選項
|
|
105
|
+
Object.entries(githubTeams).forEach(([slug, team]) => {
|
|
106
|
+
const memberCount = team.members.length;
|
|
107
|
+
options.push({
|
|
108
|
+
type: 'team',
|
|
109
|
+
slug,
|
|
110
|
+
name: team.name,
|
|
111
|
+
label: `${team.name}`,
|
|
112
|
+
extra: ` (${memberCount} 位成員)`,
|
|
113
|
+
members: team.members,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// 添加個人選項(優先使用 Git 歷史建議)
|
|
118
|
+
const individualOptions = new Map();
|
|
119
|
+
|
|
120
|
+
// 先加入建議的 reviewers(基於 Git 歷史)
|
|
102
121
|
console.log('💡 建議的 Reviewers(基於 Git 歷史):\n');
|
|
103
|
-
|
|
122
|
+
let suggestionDisplayed = false;
|
|
123
|
+
|
|
124
|
+
suggestedReviewers.forEach((reviewer) => {
|
|
125
|
+
// 自動排除當前使用者(在顯示時就排除)
|
|
126
|
+
if (currentUser && reviewer.email.toLowerCase() === currentUser.email.toLowerCase()) {
|
|
127
|
+
return; // 跳過當前使用者
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
suggestionDisplayed = true;
|
|
104
131
|
console.log(
|
|
105
|
-
`
|
|
132
|
+
` • ${reviewer.name} (@${reviewer.username}) - ${reviewer.commits} commits`
|
|
106
133
|
);
|
|
134
|
+
|
|
135
|
+
individualOptions.set(reviewer.username, {
|
|
136
|
+
type: 'individual',
|
|
137
|
+
login: reviewer.username,
|
|
138
|
+
name: reviewer.name,
|
|
139
|
+
label: `${reviewer.name} (@${reviewer.username})`,
|
|
140
|
+
extra: ` 💡 ${reviewer.commits} commits`,
|
|
141
|
+
suggested: true,
|
|
142
|
+
});
|
|
107
143
|
});
|
|
108
|
-
console.log('');
|
|
109
144
|
|
|
110
|
-
|
|
145
|
+
if (suggestionDisplayed) {
|
|
146
|
+
console.log('');
|
|
147
|
+
}
|
|
111
148
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
149
|
+
// 再加入其他 GitHub 組織成員
|
|
150
|
+
githubMembers.forEach((member) => {
|
|
151
|
+
if (!individualOptions.has(member.login)) {
|
|
152
|
+
// 同樣自動排除當前使用者
|
|
153
|
+
if (currentUser && member.login.toLowerCase() === currentUser.username?.toLowerCase()) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
117
156
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
157
|
+
individualOptions.set(member.login, {
|
|
158
|
+
type: 'individual',
|
|
159
|
+
login: member.login,
|
|
160
|
+
name: member.name,
|
|
161
|
+
label: `${member.name} (@${member.login})`,
|
|
162
|
+
extra: '',
|
|
163
|
+
suggested: false,
|
|
164
|
+
});
|
|
121
165
|
}
|
|
166
|
+
});
|
|
122
167
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
168
|
+
// 將個人選項加入列表(建議的排在前面)
|
|
169
|
+
const sortedIndividuals = Array.from(individualOptions.values()).sort((a, b) => {
|
|
170
|
+
if (a.suggested && !b.suggested) return -1;
|
|
171
|
+
if (!a.suggested && b.suggested) return 1;
|
|
172
|
+
return 0;
|
|
173
|
+
});
|
|
127
174
|
|
|
128
|
-
|
|
175
|
+
options.push(...sortedIndividuals);
|
|
129
176
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
177
|
+
// 如果完全沒有選項
|
|
178
|
+
if (options.length === 0) {
|
|
179
|
+
logger.warning('沒有找到可用的 reviewers');
|
|
180
|
+
console.log('💡 您可以在創建 PR 後手動添加 reviewers\n');
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
133
183
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
184
|
+
// 使用互動式選擇器
|
|
185
|
+
const selector = new InteractiveSelect();
|
|
186
|
+
const result = await selector.select(options, '🎯 選擇 Reviewers (可多選)');
|
|
187
|
+
|
|
188
|
+
if (result.cancelled) {
|
|
189
|
+
logger.info('已跳過 reviewer 選擇\n');
|
|
138
190
|
return [];
|
|
139
191
|
}
|
|
192
|
+
|
|
193
|
+
// 處理選擇結果
|
|
194
|
+
const selectedReviewers = [];
|
|
195
|
+
const selectedTeams = [];
|
|
196
|
+
|
|
197
|
+
result.selected.forEach((item) => {
|
|
198
|
+
if (item.type === 'team') {
|
|
199
|
+
selectedTeams.push(item.slug);
|
|
200
|
+
} else {
|
|
201
|
+
selectedReviewers.push(item.login);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (selectedReviewers.length > 0 || selectedTeams.length > 0) {
|
|
206
|
+
const reviewerList = selectedReviewers.map((u) => `@${u}`).join(', ');
|
|
207
|
+
const teamList = selectedTeams.map((t) => `@${config.orgName}/${t}`).join(', ');
|
|
208
|
+
const allSelected = [reviewerList, teamList].filter(Boolean).join(', ');
|
|
209
|
+
|
|
210
|
+
logger.success(`已選擇: ${allSelected}\n`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { individuals: selectedReviewers, teams: selectedTeams };
|
|
140
214
|
}
|
|
141
215
|
|
|
142
216
|
/**
|
|
@@ -624,13 +698,13 @@ export async function prCommand() {
|
|
|
624
698
|
}
|
|
625
699
|
|
|
626
700
|
// 5. Reviewer 選擇(如果啟用)
|
|
627
|
-
let selectedReviewers = [];
|
|
701
|
+
let selectedReviewers = { individuals: [], teams: [] };
|
|
628
702
|
if (config.reviewers.autoSelect) {
|
|
629
703
|
const currentUser = getCurrentUser();
|
|
630
704
|
const suggestedReviewers = getReviewersByGitHistory(changedFiles, config);
|
|
631
705
|
|
|
632
|
-
if (suggestedReviewers.length > 0) {
|
|
633
|
-
selectedReviewers = await selectReviewers(suggestedReviewers, currentUser);
|
|
706
|
+
if (suggestedReviewers.length > 0 || config.orgName) {
|
|
707
|
+
selectedReviewers = await selectReviewers(suggestedReviewers, currentUser, config);
|
|
634
708
|
} else {
|
|
635
709
|
logger.info('未找到建議的 reviewers(可能是新專案或檔案無歷史記錄)\n');
|
|
636
710
|
}
|
|
@@ -655,9 +729,19 @@ export async function prCommand() {
|
|
|
655
729
|
ghCommand += ' --draft';
|
|
656
730
|
}
|
|
657
731
|
|
|
658
|
-
// 添加 reviewers
|
|
659
|
-
if (selectedReviewers.length > 0) {
|
|
660
|
-
ghCommand += ` --reviewer ${selectedReviewers.join(',')}`;
|
|
732
|
+
// 添加 individual reviewers
|
|
733
|
+
if (selectedReviewers.individuals && selectedReviewers.individuals.length > 0) {
|
|
734
|
+
ghCommand += ` --reviewer ${selectedReviewers.individuals.join(',')}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 添加 team reviewers(需要加上組織名稱前綴)
|
|
738
|
+
if (selectedReviewers.teams && selectedReviewers.teams.length > 0) {
|
|
739
|
+
const teamReviewers = selectedReviewers.teams.map((team) => `${config.orgName}/${team}`);
|
|
740
|
+
if (selectedReviewers.individuals && selectedReviewers.individuals.length > 0) {
|
|
741
|
+
ghCommand += `,${teamReviewers.join(',')}`;
|
|
742
|
+
} else {
|
|
743
|
+
ghCommand += ` --reviewer ${teamReviewers.join(',')}`;
|
|
744
|
+
}
|
|
661
745
|
}
|
|
662
746
|
|
|
663
747
|
try {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub API 操作封裝
|
|
3
|
+
* 基於 scripts/ai-pr-modules/core/github-api.mjs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { Logger } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GitHub API 操作類
|
|
11
|
+
*/
|
|
12
|
+
export class GitHubAPI {
|
|
13
|
+
constructor(config = {}) {
|
|
14
|
+
this.orgName = config.orgName || 'kingsinfo-project';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 檢查 GitHub CLI 認證狀態
|
|
19
|
+
*/
|
|
20
|
+
checkAuth() {
|
|
21
|
+
try {
|
|
22
|
+
const authStatus = execSync('gh auth status 2>&1', {
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
authenticated: authStatus.includes('Logged in'),
|
|
29
|
+
details: authStatus,
|
|
30
|
+
};
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return {
|
|
33
|
+
authenticated: false,
|
|
34
|
+
details: error.message,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 從 GitHub 抓取組織的成員列表
|
|
41
|
+
*/
|
|
42
|
+
async fetchOrgMembers(orgName = this.orgName) {
|
|
43
|
+
const logger = new Logger();
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
logger.step(`正在嘗試抓取 ${orgName} 組織的成員列表...`);
|
|
47
|
+
|
|
48
|
+
const membersJson = execSync(`gh api orgs/${orgName}/members --jq '.'`, {
|
|
49
|
+
encoding: 'utf-8',
|
|
50
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const membersData = JSON.parse(membersJson);
|
|
54
|
+
const members = membersData.map((member) => ({
|
|
55
|
+
login: member.login,
|
|
56
|
+
name: member.name || member.login,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
if (members.length > 0) {
|
|
60
|
+
logger.success(`成功抓取 ${members.length} 位組織成員\n`);
|
|
61
|
+
return members;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return [];
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 從 GitHub 抓取組織的團隊列表
|
|
72
|
+
*/
|
|
73
|
+
async fetchTeams(orgName = this.orgName) {
|
|
74
|
+
const logger = new Logger();
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// 先檢查認證狀態
|
|
78
|
+
const authStatus = this.checkAuth();
|
|
79
|
+
if (!authStatus.authenticated) {
|
|
80
|
+
logger.warning('GitHub CLI 未認證');
|
|
81
|
+
logger.info('請執行: gh auth login\n');
|
|
82
|
+
return { teams: {}, members: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
logger.step(`正在從 GitHub 抓取 ${orgName} 的團隊資訊...`);
|
|
86
|
+
|
|
87
|
+
let teamsJson;
|
|
88
|
+
try {
|
|
89
|
+
teamsJson = execSync(`gh api orgs/${orgName}/teams --jq '.'`, {
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// 無法存取團隊 API,使用替代方案
|
|
95
|
+
logger.warning('未找到任何團隊,嘗試直接抓取組織成員...');
|
|
96
|
+
|
|
97
|
+
const members = await this.fetchOrgMembers(orgName);
|
|
98
|
+
if (members.length > 0) {
|
|
99
|
+
return { teams: {}, members };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const teams = JSON.parse(teamsJson);
|
|
106
|
+
|
|
107
|
+
if (!teams || teams.length === 0) {
|
|
108
|
+
logger.warning('未找到任何團隊,嘗試直接抓取組織成員...');
|
|
109
|
+
const members = await this.fetchOrgMembers(orgName);
|
|
110
|
+
return { teams: {}, members };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const teamData = {};
|
|
114
|
+
|
|
115
|
+
for (const team of teams) {
|
|
116
|
+
try {
|
|
117
|
+
const membersJson = execSync(
|
|
118
|
+
`gh api orgs/${orgName}/teams/${team.slug}/members --jq '.'`,
|
|
119
|
+
{
|
|
120
|
+
encoding: 'utf-8',
|
|
121
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const members = JSON.parse(membersJson);
|
|
126
|
+
teamData[team.slug] = {
|
|
127
|
+
name: team.name,
|
|
128
|
+
slug: team.slug,
|
|
129
|
+
description: team.description || '',
|
|
130
|
+
members: members.map((m) => ({
|
|
131
|
+
login: m.login,
|
|
132
|
+
name: m.name || m.login,
|
|
133
|
+
})),
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
logger.warning(`無法抓取團隊 ${team.slug} 的成員: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 同時取得所有成員作為備選
|
|
141
|
+
const allMembers = await this.fetchOrgMembers(orgName);
|
|
142
|
+
|
|
143
|
+
logger.success(
|
|
144
|
+
`成功抓取 ${Object.keys(teamData).length} 個團隊和 ${allMembers.length} 位成員\n`
|
|
145
|
+
);
|
|
146
|
+
return { teams: teamData, members: allMembers };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
logger.warning('無法從 GitHub 抓取資訊');
|
|
149
|
+
logger.info('請確認:');
|
|
150
|
+
console.log(' 1. 已安裝 GitHub CLI: brew install gh');
|
|
151
|
+
console.log(' 2. 已執行認證: gh auth login');
|
|
152
|
+
console.log(' 3. 選擇正確的認證範圍(需要 read:org 權限)');
|
|
153
|
+
console.log(` 4. 有權限存取 ${orgName} 組織`);
|
|
154
|
+
console.log(' 5. 組織名稱正確(可用 --org 參數指定)\n');
|
|
155
|
+
|
|
156
|
+
logger.info('提示: 你仍可以在創建 PR 後手動添加 reviewers\n');
|
|
157
|
+
|
|
158
|
+
return { teams: {}, members: [] };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 互動式選擇工具(使用方向鍵和空白鍵)
|
|
3
|
+
* 基於 scripts/ai-pr-modules/ui/interactive-select.mjs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { emitKeypressEvents } from 'readline';
|
|
7
|
+
|
|
8
|
+
// ANSI 顏色碼
|
|
9
|
+
const colors = {
|
|
10
|
+
reset: '\x1b[0m',
|
|
11
|
+
bright: '\x1b[1m',
|
|
12
|
+
cyan: '\x1b[36m',
|
|
13
|
+
green: '\x1b[32m',
|
|
14
|
+
yellow: '\x1b[33m',
|
|
15
|
+
magenta: '\x1b[35m',
|
|
16
|
+
blue: '\x1b[34m',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// 游標控制
|
|
20
|
+
const cursor = {
|
|
21
|
+
hide: '\x1b[?25l',
|
|
22
|
+
show: '\x1b[?25h',
|
|
23
|
+
up: (n = 1) => `\x1b[${n}A`,
|
|
24
|
+
clearLine: '\x1b[2K',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 互動式選擇器
|
|
29
|
+
*/
|
|
30
|
+
export class InteractiveSelect {
|
|
31
|
+
/**
|
|
32
|
+
* @param {Array} options - 選項陣列
|
|
33
|
+
* @param {string} title - 標題
|
|
34
|
+
* @returns {Promise<{cancelled: boolean, selected: Array}>}
|
|
35
|
+
*/
|
|
36
|
+
async select(options, title = '選擇項目') {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let currentIndex = 0;
|
|
39
|
+
const selected = new Set();
|
|
40
|
+
|
|
41
|
+
// 準備選項列表
|
|
42
|
+
const items = options.map((opt, idx) => ({
|
|
43
|
+
...opt,
|
|
44
|
+
index: idx,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const render = () => {
|
|
48
|
+
// 清除整個選擇區域
|
|
49
|
+
if (items.length > 0) {
|
|
50
|
+
// 向上移動到開始位置
|
|
51
|
+
for (let i = 0; i < items.length + 3; i++) {
|
|
52
|
+
process.stdout.write(cursor.up(1));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 清除並重新繪製每一行
|
|
57
|
+
process.stdout.write(cursor.clearLine);
|
|
58
|
+
console.log(`${colors.bright}${title}${colors.reset}`);
|
|
59
|
+
|
|
60
|
+
process.stdout.write(cursor.clearLine);
|
|
61
|
+
console.log(
|
|
62
|
+
`${colors.yellow}↑/↓: 移動 Space: 選擇/取消 Enter: 確認 q: 跳過${colors.reset}`
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
process.stdout.write(cursor.clearLine);
|
|
66
|
+
console.log('');
|
|
67
|
+
|
|
68
|
+
items.forEach((item, idx) => {
|
|
69
|
+
const isSelected = selected.has(idx);
|
|
70
|
+
const isCurrent = idx === currentIndex;
|
|
71
|
+
|
|
72
|
+
const checkbox = isSelected ? `${colors.green}[✓]${colors.reset}` : '[ ]';
|
|
73
|
+
const cursorMarker = isCurrent ? `${colors.cyan}▶${colors.reset}` : ' ';
|
|
74
|
+
const label = item.label || item.name || item.login;
|
|
75
|
+
const extra = item.extra || '';
|
|
76
|
+
|
|
77
|
+
process.stdout.write(cursor.clearLine);
|
|
78
|
+
console.log(`${cursorMarker} ${checkbox} ${label}${extra}`);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const cleanup = () => {
|
|
83
|
+
process.stdin.setRawMode(false);
|
|
84
|
+
process.stdin.removeAllListeners('keypress');
|
|
85
|
+
process.stdout.write(cursor.show);
|
|
86
|
+
console.log('');
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleKeypress = (str, key) => {
|
|
90
|
+
if (!key) return;
|
|
91
|
+
|
|
92
|
+
// q or Ctrl+C to quit
|
|
93
|
+
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
94
|
+
cleanup();
|
|
95
|
+
resolve({ cancelled: true, selected: [] });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Arrow up
|
|
100
|
+
if (key.name === 'up') {
|
|
101
|
+
currentIndex = Math.max(0, currentIndex - 1);
|
|
102
|
+
render();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Arrow down
|
|
106
|
+
else if (key.name === 'down') {
|
|
107
|
+
currentIndex = Math.min(items.length - 1, currentIndex + 1);
|
|
108
|
+
render();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Space to toggle selection
|
|
112
|
+
else if (key.name === 'space') {
|
|
113
|
+
if (selected.has(currentIndex)) {
|
|
114
|
+
selected.delete(currentIndex);
|
|
115
|
+
} else {
|
|
116
|
+
selected.add(currentIndex);
|
|
117
|
+
}
|
|
118
|
+
render();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Enter to confirm
|
|
122
|
+
else if (key.name === 'return') {
|
|
123
|
+
cleanup();
|
|
124
|
+
const selectedItems = Array.from(selected).map((idx) => items[idx]);
|
|
125
|
+
resolve({ cancelled: false, selected: selectedItems });
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// 初始化
|
|
130
|
+
process.stdout.write(cursor.hide);
|
|
131
|
+
|
|
132
|
+
// 先印出佔位用的空行
|
|
133
|
+
for (let i = 0; i < items.length + 3; i++) {
|
|
134
|
+
console.log('');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
render();
|
|
138
|
+
|
|
139
|
+
// 啟用 raw mode 和 keypress
|
|
140
|
+
if (process.stdin.isTTY) {
|
|
141
|
+
process.stdin.setRawMode(true);
|
|
142
|
+
process.stdin.resume();
|
|
143
|
+
|
|
144
|
+
// 需要監聽 keypress 事件
|
|
145
|
+
emitKeypressEvents(process.stdin);
|
|
146
|
+
|
|
147
|
+
process.stdin.on('keypress', handleKeypress);
|
|
148
|
+
} else {
|
|
149
|
+
cleanup();
|
|
150
|
+
resolve({ cancelled: true, selected: [] });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|