codebuddy-stats 1.1.2 → 1.1.4
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/dist/index.js +70 -22
- package/dist/lib/paths.js +3 -3
- package/dist/lib/workspace-resolver.js +364 -21
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -40,19 +40,6 @@ Options:
|
|
|
40
40
|
}
|
|
41
41
|
return options;
|
|
42
42
|
}
|
|
43
|
-
// 生成热力图数据
|
|
44
|
-
function generateHeatmapData(dailySummary) {
|
|
45
|
-
const sortedDates = Object.keys(dailySummary).sort();
|
|
46
|
-
if (sortedDates.length === 0)
|
|
47
|
-
return { dates: [], costs: [], maxCost: 0 };
|
|
48
|
-
const costs = sortedDates.map(d => dailySummary[d]?.cost ?? 0);
|
|
49
|
-
const maxCost = Math.max(...costs);
|
|
50
|
-
return {
|
|
51
|
-
dates: sortedDates,
|
|
52
|
-
costs,
|
|
53
|
-
maxCost,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
43
|
// 获取热力图字符
|
|
57
44
|
function getHeatChar(cost, maxCost) {
|
|
58
45
|
if (cost === 0)
|
|
@@ -69,7 +56,6 @@ function getHeatChar(cost, maxCost) {
|
|
|
69
56
|
// 渲染 Overview 视图
|
|
70
57
|
function renderOverview(box, data, width, note) {
|
|
71
58
|
const { dailySummary, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data;
|
|
72
|
-
const heatmap = generateHeatmapData(dailySummary);
|
|
73
59
|
// 根据宽度计算热力图周数
|
|
74
60
|
const availableWidth = width - 10;
|
|
75
61
|
const maxWeeks = Math.min(Math.floor(availableWidth / 2), 26); // 最多 26 周 (半年)
|
|
@@ -96,7 +82,43 @@ function renderOverview(box, data, width, note) {
|
|
|
96
82
|
}
|
|
97
83
|
weeks.push(week);
|
|
98
84
|
}
|
|
99
|
-
|
|
85
|
+
// 以“当前热力图窗口”的最大值做归一化(避免历史极值导致近期全是浅色)
|
|
86
|
+
const visibleCosts = [];
|
|
87
|
+
for (const week of weeks) {
|
|
88
|
+
for (const date of week) {
|
|
89
|
+
if (!date || date > todayStr)
|
|
90
|
+
continue;
|
|
91
|
+
visibleCosts.push(dailySummary[date]?.cost ?? 0);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const maxCost = Math.max(...visibleCosts, 0) || 1;
|
|
95
|
+
// 月份标尺(在列上方标注月份变化)
|
|
96
|
+
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
97
|
+
const colWidth = 2; // 每周一列:字符 + 空格
|
|
98
|
+
const heatStartCol = 4; // 左侧周几标签宽度
|
|
99
|
+
const headerLen = heatStartCol + weeks.length * colWidth;
|
|
100
|
+
const monthHeader = Array.from({ length: headerLen }, () => ' ');
|
|
101
|
+
let lastMonth = -1;
|
|
102
|
+
let lastPlacedAt = -999;
|
|
103
|
+
for (let i = 0; i < weeks.length; i++) {
|
|
104
|
+
const week = weeks[i];
|
|
105
|
+
const repDate = week.find(d => d && d <= todayStr) ?? week[0];
|
|
106
|
+
if (!repDate)
|
|
107
|
+
continue;
|
|
108
|
+
const m = new Date(repDate).getMonth();
|
|
109
|
+
if (m !== lastMonth) {
|
|
110
|
+
const label = monthNames[m];
|
|
111
|
+
const pos = heatStartCol + i * colWidth;
|
|
112
|
+
// 避免月份标签过于拥挤/相互覆盖
|
|
113
|
+
if (pos - lastPlacedAt >= 4 && pos + label.length <= monthHeader.length) {
|
|
114
|
+
for (let k = 0; k < label.length; k++)
|
|
115
|
+
monthHeader[pos + k] = label[k];
|
|
116
|
+
lastPlacedAt = pos;
|
|
117
|
+
}
|
|
118
|
+
lastMonth = m;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
content += `{gray-fg}${monthHeader.join('').trimEnd()}{/gray-fg}\n`;
|
|
100
122
|
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
101
123
|
for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
|
|
102
124
|
let row = dayLabels[dayOfWeek].padEnd(4);
|
|
@@ -114,6 +136,8 @@ function renderOverview(box, data, width, note) {
|
|
|
114
136
|
}
|
|
115
137
|
content += row + '\n';
|
|
116
138
|
}
|
|
139
|
+
const rangeStart = weeks[0]?.[0] ?? todayStr;
|
|
140
|
+
content += `{gray-fg}Range: ${rangeStart} → ${todayStr}{/gray-fg}\n`;
|
|
117
141
|
content += ' Less {gray-fg}·░▒▓{/gray-fg}{white-fg}█{/white-fg} More\n\n';
|
|
118
142
|
// 汇总指标 - 根据宽度决定布局
|
|
119
143
|
const avgDailyCost = activeDays > 0 ? grandTotal.cost / activeDays : 0;
|
|
@@ -164,7 +188,7 @@ function renderByModel(box, data, width, note) {
|
|
|
164
188
|
content +=
|
|
165
189
|
'{underline}' +
|
|
166
190
|
'Model'.padEnd(modelCol) +
|
|
167
|
-
'Cost'.padStart(12) +
|
|
191
|
+
'~Cost'.padStart(12) +
|
|
168
192
|
'Requests'.padStart(12) +
|
|
169
193
|
'Tokens'.padStart(12) +
|
|
170
194
|
'Avg/Req'.padStart(10) +
|
|
@@ -205,7 +229,7 @@ function renderByProject(box, data, width, note) {
|
|
|
205
229
|
content +=
|
|
206
230
|
'{underline}' +
|
|
207
231
|
'Project'.padEnd(projectCol) +
|
|
208
|
-
'Cost'.padStart(12) +
|
|
232
|
+
'~Cost'.padStart(12) +
|
|
209
233
|
'Requests'.padStart(12) +
|
|
210
234
|
'Tokens'.padStart(12) +
|
|
211
235
|
'{/underline}\n';
|
|
@@ -240,8 +264,9 @@ function renderDaily(box, data, scrollOffset = 0, width, note) {
|
|
|
240
264
|
const availableWidth = width - 6; // padding
|
|
241
265
|
const dateCol = 12;
|
|
242
266
|
const costCol = 12;
|
|
267
|
+
const tokensCol = 10;
|
|
243
268
|
const reqCol = 10;
|
|
244
|
-
const fixedCols = dateCol + costCol + reqCol;
|
|
269
|
+
const fixedCols = dateCol + costCol + tokensCol + reqCol;
|
|
245
270
|
const remainingWidth = availableWidth - fixedCols;
|
|
246
271
|
const modelCol = Math.max(15, Math.min(25, Math.floor(remainingWidth * 0.4)));
|
|
247
272
|
const projectCol = Math.max(20, remainingWidth - modelCol);
|
|
@@ -249,7 +274,8 @@ function renderDaily(box, data, scrollOffset = 0, width, note) {
|
|
|
249
274
|
content +=
|
|
250
275
|
'{underline}' +
|
|
251
276
|
'Date'.padEnd(dateCol) +
|
|
252
|
-
'Cost'.padStart(costCol) +
|
|
277
|
+
'~Cost'.padStart(costCol) +
|
|
278
|
+
'Tokens'.padStart(tokensCol) +
|
|
253
279
|
'Requests'.padStart(reqCol) +
|
|
254
280
|
'Top Model'.padStart(modelCol) +
|
|
255
281
|
'Top Project'.padStart(projectCol) +
|
|
@@ -280,6 +306,7 @@ function renderDaily(box, data, scrollOffset = 0, width, note) {
|
|
|
280
306
|
content +=
|
|
281
307
|
date.padEnd(dateCol) +
|
|
282
308
|
formatCost(daySummary.cost).padStart(costCol) +
|
|
309
|
+
formatTokens(daySummary.tokens).padStart(tokensCol) +
|
|
283
310
|
formatNumber(daySummary.requests).padStart(reqCol) +
|
|
284
311
|
truncate(topModel.id, modelCol - 1).padStart(modelCol) +
|
|
285
312
|
truncate(shortProject, projectCol - 1).padStart(projectCol) +
|
|
@@ -444,10 +471,29 @@ async function main() {
|
|
|
444
471
|
function updateStatusBar() {
|
|
445
472
|
const daysInfo = options.days ? `Last ${options.days} days` : 'All time';
|
|
446
473
|
const sourceInfo = currentSource === 'code' ? 'Code' : 'IDE';
|
|
447
|
-
const
|
|
448
|
-
const rightContent = `v${VERSION} `;
|
|
474
|
+
const rightContent = `v${VERSION}`;
|
|
449
475
|
const width = Number(screen.width) || 80;
|
|
450
|
-
|
|
476
|
+
// 根据剩余宽度决定左侧内容详细程度(预留版本号空间)
|
|
477
|
+
const reservedForRight = rightContent.length + 2; // 版本号 + 两侧空格
|
|
478
|
+
const availableForLeft = width - reservedForRight;
|
|
479
|
+
let leftContent;
|
|
480
|
+
const fullContent = ` ${daysInfo} | Source: ${sourceInfo} | Total: ${formatCost(data.grandTotal.cost)} | q quit, Tab view, s source, r refresh`;
|
|
481
|
+
const mediumContent = ` ${daysInfo} | ${sourceInfo} | ${formatCost(data.grandTotal.cost)} | q/Tab/s/r`;
|
|
482
|
+
const shortContent = ` ${sourceInfo} | ${formatCost(data.grandTotal.cost)} | q/Tab/s/r`;
|
|
483
|
+
const minContent = ` ${formatCost(data.grandTotal.cost)}`;
|
|
484
|
+
if (fullContent.length <= availableForLeft) {
|
|
485
|
+
leftContent = fullContent;
|
|
486
|
+
}
|
|
487
|
+
else if (mediumContent.length <= availableForLeft) {
|
|
488
|
+
leftContent = mediumContent;
|
|
489
|
+
}
|
|
490
|
+
else if (shortContent.length <= availableForLeft) {
|
|
491
|
+
leftContent = shortContent;
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
leftContent = minContent;
|
|
495
|
+
}
|
|
496
|
+
const padding = Math.max(1, width - leftContent.length - rightContent.length);
|
|
451
497
|
statusBar.setContent(leftContent + ' '.repeat(padding) + rightContent);
|
|
452
498
|
}
|
|
453
499
|
// 键盘事件
|
|
@@ -517,7 +563,9 @@ async function main() {
|
|
|
517
563
|
});
|
|
518
564
|
// 监听窗口大小变化
|
|
519
565
|
screen.on('resize', () => {
|
|
566
|
+
updateTabBar();
|
|
520
567
|
updateContent();
|
|
568
|
+
updateStatusBar();
|
|
521
569
|
screen.render();
|
|
522
570
|
});
|
|
523
571
|
// 初始渲染
|
package/dist/lib/paths.js
CHANGED
|
@@ -24,7 +24,7 @@ export function getConfigDir() {
|
|
|
24
24
|
* 获取 CodeBuddy IDE (CodeBuddyExtension) 数据目录
|
|
25
25
|
* - macOS: ~/Library/Application Support/CodeBuddyExtension/Data
|
|
26
26
|
* - Windows: %APPDATA%/CodeBuddyExtension/Data
|
|
27
|
-
* - Linux: $
|
|
27
|
+
* - Linux: $XDG_DATA_HOME/CodeBuddyExtension/Data 或 ~/.local/share/CodeBuddyExtension/Data
|
|
28
28
|
*/
|
|
29
29
|
export function getIdeDataDir() {
|
|
30
30
|
if (process.platform === 'darwin') {
|
|
@@ -34,8 +34,8 @@ export function getIdeDataDir() {
|
|
|
34
34
|
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
35
35
|
return path.join(appData, 'CodeBuddyExtension', 'Data');
|
|
36
36
|
}
|
|
37
|
-
const
|
|
38
|
-
return path.join(
|
|
37
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
38
|
+
return path.join(xdgDataHome, 'CodeBuddyExtension', 'Data');
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
41
|
* 获取 CodeBuddy IDE 的 workspaceStorage 目录
|
|
@@ -3,7 +3,7 @@ import fsSync from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
|
-
import { getWorkspaceStorageDir } from './paths.js';
|
|
6
|
+
import { getIdeDataDir, getWorkspaceStorageDir } from './paths.js';
|
|
7
7
|
// 缓存已解析的 CodeBuddy Code 路径名
|
|
8
8
|
const codePathCache = new Map();
|
|
9
9
|
/**
|
|
@@ -86,38 +86,381 @@ function computePathHash(p) {
|
|
|
86
86
|
return crypto.createHash('md5').update(p).digest('hex');
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
89
|
+
* 从 CodeBuddyIDE 的 file-tree 数据中反推出 workspace hash -> 真实路径
|
|
90
|
+
*
|
|
91
|
+
* 背景:Remote SSH 场景下,server 侧 `workspaceStorage/<hash>/workspace.json` 可能不存在,
|
|
92
|
+
* 但 CodeBuddyExtension 会把对话关联的文件路径写入 `file-tree.json`,可据此还原工作区根目录。
|
|
90
93
|
*/
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
94
|
+
async function loadWorkspaceMappingsFromIdeFileTree() {
|
|
95
|
+
const out = new Map();
|
|
96
|
+
const root = getIdeDataDir();
|
|
97
|
+
async function pathExists(p) {
|
|
98
|
+
try {
|
|
99
|
+
await fs.access(p);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function collectFilePaths(node, acc, depth = 0) {
|
|
107
|
+
if (depth > 10)
|
|
108
|
+
return;
|
|
109
|
+
if (typeof node === 'string')
|
|
110
|
+
return;
|
|
111
|
+
if (Array.isArray(node)) {
|
|
112
|
+
for (const item of node)
|
|
113
|
+
collectFilePaths(item, acc, depth + 1);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!node || typeof node !== 'object')
|
|
117
|
+
return;
|
|
118
|
+
for (const [k, v] of Object.entries(node)) {
|
|
119
|
+
if (k === 'filePath' && typeof v === 'string' && v) {
|
|
120
|
+
acc.push(v);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (k === 'createdEntries' && Array.isArray(v)) {
|
|
124
|
+
for (const e of v) {
|
|
125
|
+
if (typeof e === 'string' && e)
|
|
126
|
+
acc.push(e);
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
collectFilePaths(v, acc, depth + 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function resolveWorkspacePathFromFilePaths(hash, filePaths) {
|
|
134
|
+
for (const raw of filePaths) {
|
|
135
|
+
const p = extractPathFromUri(raw) || (path.isAbsolute(raw) ? raw : null);
|
|
136
|
+
if (!p)
|
|
137
|
+
continue;
|
|
138
|
+
let cur = p;
|
|
139
|
+
for (let i = 0; i < 50; i++) {
|
|
140
|
+
if (computePathHash(cur) === hash)
|
|
141
|
+
return cur;
|
|
142
|
+
const parent = path.dirname(cur);
|
|
143
|
+
if (parent === cur)
|
|
144
|
+
break;
|
|
145
|
+
cur = parent;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// 扫描 `Data/*/CodeBuddyIDE/*/file-tree/*/<convId>/file-tree.json`
|
|
151
|
+
let level1 = [];
|
|
95
152
|
try {
|
|
96
|
-
|
|
153
|
+
level1 = await fs.readdir(root, { withFileTypes: true });
|
|
97
154
|
}
|
|
98
155
|
catch {
|
|
99
|
-
return
|
|
156
|
+
return out;
|
|
100
157
|
}
|
|
101
|
-
for (const
|
|
102
|
-
if (!
|
|
158
|
+
for (const dirent of level1) {
|
|
159
|
+
if (!dirent.isDirectory())
|
|
103
160
|
continue;
|
|
104
|
-
const
|
|
161
|
+
const codeBuddyIdeRoot = path.join(root, dirent.name, 'CodeBuddyIDE');
|
|
162
|
+
if (!(await pathExists(codeBuddyIdeRoot)))
|
|
163
|
+
continue;
|
|
164
|
+
// 兼容两种结构:
|
|
165
|
+
// 1) CodeBuddyIDE/file-tree
|
|
166
|
+
// 2) CodeBuddyIDE/<profile>/file-tree(当前主流)
|
|
167
|
+
const profileDirs = [];
|
|
168
|
+
if (await pathExists(path.join(codeBuddyIdeRoot, 'file-tree'))) {
|
|
169
|
+
profileDirs.push(codeBuddyIdeRoot);
|
|
170
|
+
}
|
|
171
|
+
let nested = [];
|
|
105
172
|
try {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
173
|
+
nested = await fs.readdir(codeBuddyIdeRoot, { withFileTypes: true });
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
nested = [];
|
|
177
|
+
}
|
|
178
|
+
for (const child of nested) {
|
|
179
|
+
if (!child.isDirectory())
|
|
110
180
|
continue;
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
181
|
+
const profileDir = path.join(codeBuddyIdeRoot, child.name);
|
|
182
|
+
if (await pathExists(path.join(profileDir, 'file-tree'))) {
|
|
183
|
+
profileDirs.push(profileDir);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const profileDir of profileDirs) {
|
|
187
|
+
const fileTreeDir = path.join(profileDir, 'file-tree');
|
|
188
|
+
let workspaces = [];
|
|
189
|
+
try {
|
|
190
|
+
workspaces = await fs.readdir(fileTreeDir, { withFileTypes: true });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
113
193
|
continue;
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
194
|
+
}
|
|
195
|
+
for (const ws of workspaces) {
|
|
196
|
+
if (!ws.isDirectory())
|
|
197
|
+
continue;
|
|
198
|
+
const workspaceHash = ws.name;
|
|
199
|
+
if (!/^[a-f0-9]{32}$/.test(workspaceHash))
|
|
200
|
+
continue;
|
|
201
|
+
if (out.has(workspaceHash))
|
|
202
|
+
continue;
|
|
203
|
+
const workspaceDir = path.join(fileTreeDir, workspaceHash);
|
|
204
|
+
let convDirs = [];
|
|
205
|
+
try {
|
|
206
|
+
convDirs = await fs.readdir(workspaceDir, { withFileTypes: true });
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
let resolved = null;
|
|
212
|
+
for (const conv of convDirs) {
|
|
213
|
+
if (!conv.isDirectory())
|
|
214
|
+
continue;
|
|
215
|
+
const fileTreeJson = path.join(workspaceDir, conv.name, 'file-tree.json');
|
|
216
|
+
try {
|
|
217
|
+
const raw = await fs.readFile(fileTreeJson, 'utf8');
|
|
218
|
+
const parsed = JSON.parse(raw);
|
|
219
|
+
const paths = [];
|
|
220
|
+
collectFilePaths(parsed, paths);
|
|
221
|
+
resolved = resolveWorkspacePathFromFilePaths(workspaceHash, paths);
|
|
222
|
+
if (resolved)
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// ignore
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!resolved)
|
|
230
|
+
continue;
|
|
231
|
+
const folderUri = 'file://' + resolved;
|
|
232
|
+
out.set(workspaceHash, {
|
|
233
|
+
hash: workspaceHash,
|
|
234
|
+
folderUri,
|
|
235
|
+
displayPath: getDisplayPath(folderUri),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 从 CodeBuddyIDE 的 history/messages 中反推出 workspace hash -> 真实路径
|
|
244
|
+
*
|
|
245
|
+
* 场景:有些对话没有触发 file-tree 落盘,但 messages 里常包含 tool-result 的绝对路径。
|
|
246
|
+
*/
|
|
247
|
+
async function loadWorkspaceMappingsFromIdeHistory() {
|
|
248
|
+
const out = new Map();
|
|
249
|
+
const root = getIdeDataDir();
|
|
250
|
+
async function pathExists(p) {
|
|
251
|
+
try {
|
|
252
|
+
await fs.access(p);
|
|
253
|
+
return true;
|
|
117
254
|
}
|
|
118
255
|
catch {
|
|
119
|
-
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function readFileHeadUtf8(filePath, bytes = 64 * 1024) {
|
|
260
|
+
const fh = await fs.open(filePath, 'r');
|
|
261
|
+
try {
|
|
262
|
+
const buf = Buffer.alloc(bytes);
|
|
263
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
264
|
+
return buf.toString('utf8', 0, bytesRead);
|
|
120
265
|
}
|
|
266
|
+
finally {
|
|
267
|
+
await fh.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function extractCandidatePathsFromText(text) {
|
|
271
|
+
const set = new Set();
|
|
272
|
+
const uriRe = /(vscode-remote:\/\/[^\s"']+|file:\/\/[^\s"']+)/g;
|
|
273
|
+
for (const m of text.matchAll(uriRe)) {
|
|
274
|
+
const v = m[1];
|
|
275
|
+
if (v)
|
|
276
|
+
set.add(v);
|
|
277
|
+
}
|
|
278
|
+
const absPathRe = /\/[A-Za-z0-9._~@%+=:,\-]+(?:\/[A-Za-z0-9._~@%+=:,\-]+)+/g;
|
|
279
|
+
for (const m of text.matchAll(absPathRe)) {
|
|
280
|
+
const v = m[0];
|
|
281
|
+
if (v)
|
|
282
|
+
set.add(v);
|
|
283
|
+
}
|
|
284
|
+
return [...set];
|
|
285
|
+
}
|
|
286
|
+
function resolveWorkspacePathFromCandidates(hash, candidates) {
|
|
287
|
+
for (const raw of candidates) {
|
|
288
|
+
const p = extractPathFromUri(raw) || (path.isAbsolute(raw) ? raw : null);
|
|
289
|
+
if (!p)
|
|
290
|
+
continue;
|
|
291
|
+
let cur = p;
|
|
292
|
+
for (let i = 0; i < 50; i++) {
|
|
293
|
+
if (computePathHash(cur) === hash)
|
|
294
|
+
return cur;
|
|
295
|
+
const parent = path.dirname(cur);
|
|
296
|
+
if (parent === cur)
|
|
297
|
+
break;
|
|
298
|
+
cur = parent;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
let level1 = [];
|
|
304
|
+
try {
|
|
305
|
+
level1 = await fs.readdir(root, { withFileTypes: true });
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
for (const dirent of level1) {
|
|
311
|
+
if (!dirent.isDirectory())
|
|
312
|
+
continue;
|
|
313
|
+
const codeBuddyIdeRoot = path.join(root, dirent.name, 'CodeBuddyIDE');
|
|
314
|
+
if (!(await pathExists(codeBuddyIdeRoot)))
|
|
315
|
+
continue;
|
|
316
|
+
const profileDirs = [];
|
|
317
|
+
if (await pathExists(path.join(codeBuddyIdeRoot, 'history'))) {
|
|
318
|
+
profileDirs.push(codeBuddyIdeRoot);
|
|
319
|
+
}
|
|
320
|
+
let nested = [];
|
|
321
|
+
try {
|
|
322
|
+
nested = await fs.readdir(codeBuddyIdeRoot, { withFileTypes: true });
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
nested = [];
|
|
326
|
+
}
|
|
327
|
+
for (const child of nested) {
|
|
328
|
+
if (!child.isDirectory())
|
|
329
|
+
continue;
|
|
330
|
+
const profileDir = path.join(codeBuddyIdeRoot, child.name);
|
|
331
|
+
if (await pathExists(path.join(profileDir, 'history'))) {
|
|
332
|
+
profileDirs.push(profileDir);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const profileDir of profileDirs) {
|
|
336
|
+
const historyDir = path.join(profileDir, 'history');
|
|
337
|
+
let workspaces = [];
|
|
338
|
+
try {
|
|
339
|
+
workspaces = await fs.readdir(historyDir, { withFileTypes: true });
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
for (const ws of workspaces) {
|
|
345
|
+
if (!ws.isDirectory())
|
|
346
|
+
continue;
|
|
347
|
+
const workspaceHash = ws.name;
|
|
348
|
+
if (!/^[a-f0-9]{32}$/.test(workspaceHash))
|
|
349
|
+
continue;
|
|
350
|
+
if (out.has(workspaceHash))
|
|
351
|
+
continue;
|
|
352
|
+
const workspaceDir = path.join(historyDir, workspaceHash);
|
|
353
|
+
let convDirs = [];
|
|
354
|
+
try {
|
|
355
|
+
convDirs = await fs.readdir(workspaceDir, { withFileTypes: true });
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
let resolved = null;
|
|
361
|
+
for (const conv of convDirs) {
|
|
362
|
+
if (!conv.isDirectory())
|
|
363
|
+
continue;
|
|
364
|
+
const messagesDir = path.join(workspaceDir, conv.name, 'messages');
|
|
365
|
+
if (!(await pathExists(messagesDir)))
|
|
366
|
+
continue;
|
|
367
|
+
let msgFiles = [];
|
|
368
|
+
try {
|
|
369
|
+
msgFiles = await fs.readdir(messagesDir, { withFileTypes: true });
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
let tried = 0;
|
|
375
|
+
for (const msg of msgFiles) {
|
|
376
|
+
if (!msg.isFile() || !msg.name.endsWith('.json'))
|
|
377
|
+
continue;
|
|
378
|
+
const msgPath = path.join(messagesDir, msg.name);
|
|
379
|
+
tried += 1;
|
|
380
|
+
if (tried > 30)
|
|
381
|
+
break;
|
|
382
|
+
try {
|
|
383
|
+
const head = await readFileHeadUtf8(msgPath);
|
|
384
|
+
const candidates = extractCandidatePathsFromText(head);
|
|
385
|
+
resolved = resolveWorkspacePathFromCandidates(workspaceHash, candidates);
|
|
386
|
+
if (resolved)
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// ignore
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (resolved)
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
if (!resolved)
|
|
397
|
+
continue;
|
|
398
|
+
const folderUri = 'file://' + resolved;
|
|
399
|
+
out.set(workspaceHash, {
|
|
400
|
+
hash: workspaceHash,
|
|
401
|
+
folderUri,
|
|
402
|
+
displayPath: getDisplayPath(folderUri),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* 加载所有工作区映射
|
|
411
|
+
*/
|
|
412
|
+
export async function loadWorkspaceMappings() {
|
|
413
|
+
const mappings = new Map();
|
|
414
|
+
// 1) 尝试从客户端 workspaceStorage/workspace.json 解析
|
|
415
|
+
const storageDir = getWorkspaceStorageDir();
|
|
416
|
+
try {
|
|
417
|
+
const entries = await fs.readdir(storageDir, { withFileTypes: true });
|
|
418
|
+
for (const entry of entries) {
|
|
419
|
+
if (!entry.isDirectory())
|
|
420
|
+
continue;
|
|
421
|
+
const workspaceJsonPath = path.join(storageDir, entry.name, 'workspace.json');
|
|
422
|
+
try {
|
|
423
|
+
const content = await fs.readFile(workspaceJsonPath, 'utf8');
|
|
424
|
+
const data = JSON.parse(content);
|
|
425
|
+
const folderUri = data.folder;
|
|
426
|
+
if (!folderUri)
|
|
427
|
+
continue;
|
|
428
|
+
const extractedPath = extractPathFromUri(folderUri);
|
|
429
|
+
if (!extractedPath)
|
|
430
|
+
continue;
|
|
431
|
+
const hash = computePathHash(extractedPath);
|
|
432
|
+
const displayPath = getDisplayPath(folderUri);
|
|
433
|
+
mappings.set(hash, { hash, folderUri, displayPath });
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// ignore
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// ignore
|
|
442
|
+
}
|
|
443
|
+
// 2) Remote SSH 等场景的兜底:从 CodeBuddyIDE file-tree 反推
|
|
444
|
+
try {
|
|
445
|
+
const ideMappings = await loadWorkspaceMappingsFromIdeFileTree();
|
|
446
|
+
for (const [hash, mapping] of ideMappings) {
|
|
447
|
+
if (!mappings.has(hash))
|
|
448
|
+
mappings.set(hash, mapping);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// ignore
|
|
453
|
+
}
|
|
454
|
+
// 3) 进一步兜底:从 CodeBuddyIDE history/messages 反推(tool-result 常带绝对路径)
|
|
455
|
+
try {
|
|
456
|
+
const ideMappings = await loadWorkspaceMappingsFromIdeHistory();
|
|
457
|
+
for (const [hash, mapping] of ideMappings) {
|
|
458
|
+
if (!mappings.has(hash))
|
|
459
|
+
mappings.set(hash, mapping);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// ignore
|
|
121
464
|
}
|
|
122
465
|
return mappings;
|
|
123
466
|
}
|