@zzusp/ccsm 1.0.0 → 1.0.2
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/LICENSE +21 -21
- package/README.md +236 -232
- package/dist/assets/DiskUsage-BY6XwffG.js +2 -0
- package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
- package/dist/assets/{ImportPage-b8NORa8b.js → ImportPage-Cwq5bx7G.js} +2 -2
- package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
- package/dist/assets/{ProjectMemory-aSV8UzQ9.js → ProjectMemory-CcE3KbUK.js} +2 -2
- package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
- package/dist/assets/{charts-A5eNHLjX.js → charts-jxJqXXUr.js} +2 -2
- package/dist/assets/{charts-A5eNHLjX.js.map → charts-jxJqXXUr.js.map} +1 -1
- package/dist/assets/index-CrWxV6sb.css +1 -0
- package/dist/assets/index-DTbWl1jb.js +11 -0
- package/dist/assets/index-DTbWl1jb.js.map +1 -0
- package/dist/assets/markdown-Bag5rX3T.js +30 -0
- package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
- package/dist/assets/{query-C1K1uQRu.js → query-CS7JQ86v.js} +2 -2
- package/dist/assets/{query-C1K1uQRu.js.map → query-CS7JQ86v.js.map} +1 -1
- package/dist/assets/{react-W0jzChlo.js → react-CPkiFScu.js} +10 -10
- package/dist/assets/{react-W0jzChlo.js.map → react-CPkiFScu.js.map} +1 -1
- package/dist/assets/{router-DfbutHY3.js → router-DwaHAh1G.js} +2 -2
- package/dist/assets/{router-DfbutHY3.js.map → router-DwaHAh1G.js.map} +1 -1
- package/dist/assets/vendor-Cs8vYp-N.js +27 -0
- package/dist/assets/vendor-Cs8vYp-N.js.map +1 -0
- package/dist/favicon.svg +7 -7
- package/dist/index.html +30 -30
- package/package.json +24 -11
- package/server/index.ts +4 -0
- package/server/lib/active-sessions.test.ts +119 -0
- package/server/lib/active-sessions.ts +95 -95
- package/server/lib/bundle.test.ts +182 -0
- package/server/lib/bundle.ts +86 -86
- package/server/lib/claude-paths.test.ts +126 -0
- package/server/lib/claude-paths.ts +43 -36
- package/server/lib/cleanup-suggestions.ts +131 -0
- package/server/lib/constants.ts +8 -7
- package/server/lib/delete-project.ts +100 -100
- package/server/lib/delete.test.ts +244 -0
- package/server/lib/delete.ts +192 -203
- package/server/lib/disk-usage.ts +81 -83
- package/server/lib/encode-cwd.ts +24 -24
- package/server/lib/export-bundle.ts +236 -236
- package/server/lib/export-import-bundle.test.ts +337 -0
- package/server/lib/fs-size.ts +38 -38
- package/server/lib/import-bundle.ts +488 -488
- package/server/lib/load-memory.ts +120 -120
- package/server/lib/load-session.ts +209 -209
- package/server/lib/modified-files.test.ts +280 -0
- package/server/lib/modified-files.ts +228 -0
- package/server/lib/open-folder.ts +47 -40
- package/server/lib/parse-jsonl.ts +160 -107
- package/server/lib/port.ts +23 -23
- package/server/lib/safe-id.test.ts +41 -0
- package/server/lib/safe-id.ts +6 -6
- package/server/lib/safe-remove.test.ts +73 -0
- package/server/lib/safe-remove.ts +25 -0
- package/server/lib/scan.ts +289 -183
- package/server/lib/search-all.ts +130 -130
- package/server/lib/search-session.ts +203 -203
- package/server/lib/system-tags.ts +20 -20
- package/server/lib/update.ts +67 -0
- package/server/lib/version.test.ts +39 -0
- package/server/lib/version.ts +117 -0
- package/server/routes/disk-cleanup.ts +54 -0
- package/server/routes/disk.ts +9 -9
- package/server/routes/import.ts +87 -87
- package/server/routes/projects.ts +104 -104
- package/server/routes/search.ts +79 -79
- package/server/routes/sessions.ts +130 -81
- package/server/routes/version.ts +34 -0
- package/server/types.ts +1 -1
- package/shared/constants.ts +7 -2
- package/shared/types.ts +513 -359
- package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
- package/dist/assets/ImportPage-b8NORa8b.js.map +0 -1
- package/dist/assets/ProjectMemory-aSV8UzQ9.js.map +0 -1
- package/dist/assets/index-DLATR3tZ.js +0 -5
- package/dist/assets/index-DLATR3tZ.js.map +0 -1
- package/dist/assets/index-DLDtbkux.css +0 -1
- package/dist/assets/vendor-CH80ylbS.js +0 -19
- package/dist/assets/vendor-CH80ylbS.js.map +0 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
// 用 CCSM_TEST_ROOT 跨进程桥接的写法跟 delete.test.ts 保持一致——vi.mock 的工厂
|
|
7
|
+
// 必须 hoist 安全,闭包里不能引用模块作用域变量。
|
|
8
|
+
vi.mock('./claude-paths.ts', () => {
|
|
9
|
+
return {
|
|
10
|
+
get PATHS() {
|
|
11
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
12
|
+
return {
|
|
13
|
+
root,
|
|
14
|
+
projects: path.join(root, 'projects'),
|
|
15
|
+
fileHistory: path.join(root, 'file-history'),
|
|
16
|
+
sessionEnv: path.join(root, 'session-env'),
|
|
17
|
+
sessions: path.join(root, 'sessions'),
|
|
18
|
+
history: path.join(root, 'history.jsonl'),
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
isUnderClaudeRoot(target: string): boolean {
|
|
22
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
23
|
+
const resolved = path.resolve(target);
|
|
24
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
25
|
+
},
|
|
26
|
+
getCacheDir(): string {
|
|
27
|
+
return path.join(process.env.CCSM_TEST_ROOT!, '_cache');
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
import { loadModifiedFiles } from './modified-files.ts';
|
|
33
|
+
|
|
34
|
+
let fakeRoot: string;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-modfiles-test-'));
|
|
38
|
+
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
39
|
+
fs.mkdirSync(path.join(fakeRoot, 'projects'), { recursive: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
44
|
+
delete process.env.CCSM_TEST_ROOT;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function writeJsonl(projectId: string, sessionId: string, lines: object[]): string {
|
|
48
|
+
const dir = path.join(fakeRoot, 'projects', projectId);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
const file = path.join(dir, `${sessionId}.jsonl`);
|
|
51
|
+
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function userMsg(uuid: string, ts: string, content: unknown): object {
|
|
56
|
+
return { type: 'user', uuid, timestamp: ts, message: { content } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assistantMsg(uuid: string, ts: string, content: unknown): object {
|
|
60
|
+
return { type: 'assistant', uuid, timestamp: ts, message: { content } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toolUse(id: string, name: string, input: unknown): object {
|
|
64
|
+
return { type: 'tool_use', id, name, input };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toolResult(toolUseId: string, isError = false): object {
|
|
68
|
+
return { type: 'tool_result', tool_use_id: toolUseId, content: '', is_error: isError };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('loadModifiedFiles', () => {
|
|
72
|
+
it('returns null when session jsonl missing', async () => {
|
|
73
|
+
const out = await loadModifiedFiles('proj', 'no-such-sid');
|
|
74
|
+
expect(out).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('aggregates Edit/Write/MultiEdit/NotebookEdit by filePath and counts per-tool', async () => {
|
|
78
|
+
writeJsonl('proj-1', 'sid-1', [
|
|
79
|
+
{ type: 'summary', cwd: '/Users/me/repo' },
|
|
80
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
81
|
+
toolUse('t1', 'Edit', { file_path: '/Users/me/repo/src/a.ts', old_string: 'x', new_string: 'y' }),
|
|
82
|
+
]),
|
|
83
|
+
userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t1', false)]),
|
|
84
|
+
assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
|
|
85
|
+
toolUse('t2', 'Edit', { file_path: '/Users/me/repo/src/a.ts', old_string: 'a', new_string: 'b' }),
|
|
86
|
+
]),
|
|
87
|
+
userMsg('u2', '2026-06-01T10:01:01.000Z', [toolResult('t2', false)]),
|
|
88
|
+
assistantMsg('a3', '2026-06-01T10:02:00.000Z', [
|
|
89
|
+
toolUse('t3', 'Write', { file_path: '/Users/me/repo/src/b.ts', content: 'new file' }),
|
|
90
|
+
]),
|
|
91
|
+
userMsg('u3', '2026-06-01T10:02:01.000Z', [toolResult('t3', false)]),
|
|
92
|
+
assistantMsg('a4', '2026-06-01T10:03:00.000Z', [
|
|
93
|
+
toolUse('t4', 'MultiEdit', {
|
|
94
|
+
file_path: '/Users/me/repo/src/b.ts',
|
|
95
|
+
edits: [{ old_string: 'a', new_string: 'b' }],
|
|
96
|
+
}),
|
|
97
|
+
]),
|
|
98
|
+
userMsg('u4', '2026-06-01T10:03:01.000Z', [toolResult('t4', false)]),
|
|
99
|
+
assistantMsg('a5', '2026-06-01T10:04:00.000Z', [
|
|
100
|
+
toolUse('t5', 'NotebookEdit', {
|
|
101
|
+
notebook_path: '/Users/me/repo/nb.ipynb',
|
|
102
|
+
cell_id: 'c1',
|
|
103
|
+
new_source: 'print(1)',
|
|
104
|
+
}),
|
|
105
|
+
]),
|
|
106
|
+
userMsg('u5', '2026-06-01T10:04:01.000Z', [toolResult('t5', false)]),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const out = await loadModifiedFiles('proj-1', 'sid-1');
|
|
110
|
+
expect(out).not.toBeNull();
|
|
111
|
+
expect(out!.cwd).toBe('/Users/me/repo');
|
|
112
|
+
expect(out!.files).toHaveLength(3);
|
|
113
|
+
|
|
114
|
+
// 排序 by lastAt desc: nb.ipynb (10:04) → b.ts (10:03) → a.ts (10:01)
|
|
115
|
+
const nb = out!.files[0]!;
|
|
116
|
+
const b = out!.files[1]!;
|
|
117
|
+
const a = out!.files[2]!;
|
|
118
|
+
expect(nb.filePath).toBe('/Users/me/repo/nb.ipynb');
|
|
119
|
+
expect(nb.notebookEditCount).toBe(1);
|
|
120
|
+
expect(nb.relativePath).toBe('nb.ipynb');
|
|
121
|
+
|
|
122
|
+
expect(b.filePath).toBe('/Users/me/repo/src/b.ts');
|
|
123
|
+
expect(b.relativePath).toBe('src/b.ts');
|
|
124
|
+
expect(b.writeCount).toBe(1);
|
|
125
|
+
expect(b.multiEditCount).toBe(1);
|
|
126
|
+
expect(b.totalCount).toBe(2);
|
|
127
|
+
|
|
128
|
+
expect(a.filePath).toBe('/Users/me/repo/src/a.ts');
|
|
129
|
+
expect(a.relativePath).toBe('src/a.ts');
|
|
130
|
+
expect(a.editCount).toBe(2);
|
|
131
|
+
expect(a.totalCount).toBe(2);
|
|
132
|
+
expect(a.errorCount).toBe(0);
|
|
133
|
+
expect(a.firstAt).toBe('2026-06-01T10:00:00.000Z');
|
|
134
|
+
expect(a.lastAt).toBe('2026-06-01T10:01:00.000Z');
|
|
135
|
+
expect(a.operations.map((o) => o.toolUseId)).toEqual(['t1', 't2']);
|
|
136
|
+
expect(a.operations.every((o) => !o.pending && !o.errored)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('marks errored when tool_result.is_error=true and pending when no result line found', async () => {
|
|
140
|
+
writeJsonl('proj', 'sid', [
|
|
141
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
142
|
+
toolUse('t-err', 'Edit', { file_path: '/repo/x.ts', old_string: 'a', new_string: 'b' }),
|
|
143
|
+
]),
|
|
144
|
+
userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t-err', true)]),
|
|
145
|
+
assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
|
|
146
|
+
toolUse('t-pend', 'Edit', { file_path: '/repo/x.ts', old_string: 'b', new_string: 'c' }),
|
|
147
|
+
]),
|
|
148
|
+
// 没有 t-pend 的 tool_result
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
152
|
+
const x = out!.files[0]!;
|
|
153
|
+
expect(x.totalCount).toBe(2);
|
|
154
|
+
expect(x.errorCount).toBe(1);
|
|
155
|
+
const err = x.operations.find((o) => o.toolUseId === 't-err')!;
|
|
156
|
+
expect(err.errored).toBe(true);
|
|
157
|
+
expect(err.pending).toBe(false);
|
|
158
|
+
const pend = x.operations.find((o) => o.toolUseId === 't-pend')!;
|
|
159
|
+
expect(pend.errored).toBe(false);
|
|
160
|
+
expect(pend.pending).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('ignores non-file-mutating tools (Bash/Read/Grep/Task)', async () => {
|
|
164
|
+
writeJsonl('proj', 'sid', [
|
|
165
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
166
|
+
toolUse('t1', 'Bash', { command: 'ls' }),
|
|
167
|
+
toolUse('t2', 'Read', { file_path: '/repo/x.ts' }),
|
|
168
|
+
toolUse('t3', 'Grep', { pattern: 'foo' }),
|
|
169
|
+
]),
|
|
170
|
+
userMsg('u1', '2026-06-01T10:00:01.000Z', [
|
|
171
|
+
toolResult('t1', false),
|
|
172
|
+
toolResult('t2', false),
|
|
173
|
+
toolResult('t3', false),
|
|
174
|
+
]),
|
|
175
|
+
]);
|
|
176
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
177
|
+
expect(out!.files).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('relativePath is null when filePath sits outside cwd', async () => {
|
|
181
|
+
writeJsonl('proj', 'sid', [
|
|
182
|
+
{ type: 'summary', cwd: '/Users/me/repo' },
|
|
183
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
184
|
+
toolUse('t1', 'Write', { file_path: '/tmp/elsewhere.txt', content: 'hi' }),
|
|
185
|
+
]),
|
|
186
|
+
userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t1')]),
|
|
187
|
+
]);
|
|
188
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
189
|
+
expect(out!.files[0]!.relativePath).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('operations within a file are ordered by ts asc', async () => {
|
|
193
|
+
writeJsonl('proj', 'sid', [
|
|
194
|
+
assistantMsg('a1', '2026-06-01T10:02:00.000Z', [
|
|
195
|
+
toolUse('t2', 'Edit', { file_path: '/repo/x.ts', old_string: 'a', new_string: 'b' }),
|
|
196
|
+
]),
|
|
197
|
+
userMsg('u1', '2026-06-01T10:02:01.000Z', [toolResult('t2')]),
|
|
198
|
+
assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
|
|
199
|
+
toolUse('t1', 'Edit', { file_path: '/repo/x.ts', old_string: 'c', new_string: 'd' }),
|
|
200
|
+
]),
|
|
201
|
+
userMsg('u2', '2026-06-01T10:01:01.000Z', [toolResult('t1')]),
|
|
202
|
+
]);
|
|
203
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
204
|
+
expect(out!.files[0]!.operations.map((o) => o.toolUseId)).toEqual(['t1', 't2']);
|
|
205
|
+
expect(out!.files[0]!.firstAt).toBe('2026-06-01T10:01:00.000Z');
|
|
206
|
+
expect(out!.files[0]!.lastAt).toBe('2026-06-01T10:02:00.000Z');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('skips malformed jsonl lines and tool_use entries without id/file_path', async () => {
|
|
210
|
+
const file = path.join(fakeRoot, 'projects', 'proj', 'sid.jsonl');
|
|
211
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
212
|
+
fs.writeFileSync(
|
|
213
|
+
file,
|
|
214
|
+
[
|
|
215
|
+
'{not json',
|
|
216
|
+
JSON.stringify(
|
|
217
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
218
|
+
toolUse('', 'Edit', { file_path: '/x.ts', old_string: 'a', new_string: 'b' }), // empty id → skip
|
|
219
|
+
toolUse('t1', 'Edit', { /* no file_path */ old_string: 'a', new_string: 'b' }), // no path → skip
|
|
220
|
+
toolUse('t2', 'Write', { file_path: '/ok.ts', content: 'hi' }),
|
|
221
|
+
]),
|
|
222
|
+
),
|
|
223
|
+
JSON.stringify(userMsg('u1', '2026-06-01T10:00:01.000Z', [toolResult('t2')])),
|
|
224
|
+
'',
|
|
225
|
+
].join('\n'),
|
|
226
|
+
);
|
|
227
|
+
const out = await loadModifiedFiles('proj', 'sid');
|
|
228
|
+
expect(out!.files).toHaveLength(1);
|
|
229
|
+
expect(out!.files[0]!.filePath).toBe('/ok.ts');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('attaches structuredPatch from toolUseResult; [] for create, null when absent', async () => {
|
|
233
|
+
writeJsonl('proj-sp', 'sid-sp', [
|
|
234
|
+
{ type: 'summary', cwd: '/repo' },
|
|
235
|
+
// Edit whose result line carries a real structuredPatch (accurate file line numbers).
|
|
236
|
+
assistantMsg('a1', '2026-06-01T10:00:00.000Z', [
|
|
237
|
+
toolUse('t1', 'Edit', { file_path: '/repo/a.ts', old_string: 'x', new_string: 'y' }),
|
|
238
|
+
]),
|
|
239
|
+
{
|
|
240
|
+
type: 'user',
|
|
241
|
+
uuid: 'u1',
|
|
242
|
+
timestamp: '2026-06-01T10:00:01.000Z',
|
|
243
|
+
message: { content: [toolResult('t1', false)] },
|
|
244
|
+
toolUseResult: {
|
|
245
|
+
type: 'update',
|
|
246
|
+
filePath: '/repo/a.ts',
|
|
247
|
+
structuredPatch: [
|
|
248
|
+
{ oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx', '-x', '+y'] },
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
// Write create → structuredPatch is an empty array (render input as all-added).
|
|
253
|
+
assistantMsg('a2', '2026-06-01T10:01:00.000Z', [
|
|
254
|
+
toolUse('t2', 'Write', { file_path: '/repo/b.ts', content: 'new' }),
|
|
255
|
+
]),
|
|
256
|
+
{
|
|
257
|
+
type: 'user',
|
|
258
|
+
uuid: 'u2',
|
|
259
|
+
timestamp: '2026-06-01T10:01:01.000Z',
|
|
260
|
+
message: { content: [toolResult('t2', false)] },
|
|
261
|
+
toolUseResult: { type: 'create', filePath: '/repo/b.ts', structuredPatch: [] },
|
|
262
|
+
},
|
|
263
|
+
// Edit whose result has no toolUseResult sentinel → null (fall back to input diff).
|
|
264
|
+
assistantMsg('a3', '2026-06-01T10:02:00.000Z', [
|
|
265
|
+
toolUse('t3', 'Edit', { file_path: '/repo/c.ts', old_string: 'm', new_string: 'n' }),
|
|
266
|
+
]),
|
|
267
|
+
userMsg('u3', '2026-06-01T10:02:01.000Z', [toolResult('t3', false)]),
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
const out = await loadModifiedFiles('proj-sp', 'sid-sp');
|
|
271
|
+
expect(out).not.toBeNull();
|
|
272
|
+
const byPath = new Map(out!.files.map((f) => [f.filePath, f]));
|
|
273
|
+
|
|
274
|
+
expect(byPath.get('/repo/a.ts')!.operations[0]!.structuredPatch).toEqual([
|
|
275
|
+
{ oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [' ctx', '-x', '+y'] },
|
|
276
|
+
]);
|
|
277
|
+
expect(byPath.get('/repo/b.ts')!.operations[0]!.structuredPatch).toEqual([]);
|
|
278
|
+
expect(byPath.get('/repo/c.ts')!.operations[0]!.structuredPatch).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { PATHS } from './claude-paths.ts';
|
|
5
|
+
import type {
|
|
6
|
+
DiffHunk,
|
|
7
|
+
ModifiedFileOperation,
|
|
8
|
+
ModifiedFileSummary,
|
|
9
|
+
ModifiedFileToolName,
|
|
10
|
+
ModifiedFilesResponse,
|
|
11
|
+
} from '../types.ts';
|
|
12
|
+
|
|
13
|
+
const FILE_MOD_TOOLS = new Set<ModifiedFileToolName>([
|
|
14
|
+
'Edit',
|
|
15
|
+
'Write',
|
|
16
|
+
'MultiEdit',
|
|
17
|
+
'NotebookEdit',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
interface PendingOp {
|
|
21
|
+
toolUseId: string;
|
|
22
|
+
toolName: ModifiedFileToolName;
|
|
23
|
+
ts: string | null;
|
|
24
|
+
messageUuid: string | null;
|
|
25
|
+
filePath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadModifiedFiles(
|
|
29
|
+
projectId: string,
|
|
30
|
+
sessionId: string,
|
|
31
|
+
): Promise<ModifiedFilesResponse | null> {
|
|
32
|
+
const jsonlPath = path.join(PATHS.projects, projectId, `${sessionId}.jsonl`);
|
|
33
|
+
if (!fs.existsSync(jsonlPath)) return null;
|
|
34
|
+
|
|
35
|
+
const ops: PendingOp[] = [];
|
|
36
|
+
// tool_use_id → is_error;tool_result 在 jsonl 中通常出现在对应 tool_use 之后,
|
|
37
|
+
// 但不强依赖顺序——单次扫完再回填。
|
|
38
|
+
const resultErr = new Map<string, boolean>();
|
|
39
|
+
// tool_use_id → 该次工具结果的 structuredPatch(带真实行号的 hunk)。
|
|
40
|
+
// 哨兵记录在 user 消息顶层的 obj.toolUseResult 上,与同一行 content 里的 tool_result 一一对应。
|
|
41
|
+
const resultPatch = new Map<string, DiffHunk[]>();
|
|
42
|
+
let cwd: string | null = null;
|
|
43
|
+
|
|
44
|
+
const rl = readline.createInterface({
|
|
45
|
+
input: fs.createReadStream(jsonlPath, { encoding: 'utf8' }),
|
|
46
|
+
crlfDelay: Infinity,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
for await (const raw of rl) {
|
|
50
|
+
const line = raw.trim();
|
|
51
|
+
if (!line) continue;
|
|
52
|
+
let obj: Record<string, unknown>;
|
|
53
|
+
try {
|
|
54
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof obj.cwd === 'string' && !cwd) cwd = obj.cwd;
|
|
60
|
+
|
|
61
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue;
|
|
62
|
+
const message = obj.message;
|
|
63
|
+
if (!message || typeof message !== 'object') continue;
|
|
64
|
+
const content = (message as { content?: unknown }).content;
|
|
65
|
+
if (!Array.isArray(content)) continue;
|
|
66
|
+
|
|
67
|
+
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
|
|
68
|
+
const messageUuid = typeof obj.uuid === 'string' ? obj.uuid : null;
|
|
69
|
+
|
|
70
|
+
for (const block of content) {
|
|
71
|
+
if (!block || typeof block !== 'object') continue;
|
|
72
|
+
const b = block as Record<string, unknown>;
|
|
73
|
+
if (b.type === 'tool_use') {
|
|
74
|
+
const name = b.name;
|
|
75
|
+
if (typeof name !== 'string') continue;
|
|
76
|
+
if (!FILE_MOD_TOOLS.has(name as ModifiedFileToolName)) continue;
|
|
77
|
+
const input = b.input;
|
|
78
|
+
if (!input || typeof input !== 'object') continue;
|
|
79
|
+
const filePath = extractFilePath(input as Record<string, unknown>);
|
|
80
|
+
if (!filePath) continue;
|
|
81
|
+
const id = typeof b.id === 'string' ? b.id : '';
|
|
82
|
+
if (!id) continue;
|
|
83
|
+
ops.push({
|
|
84
|
+
toolUseId: id,
|
|
85
|
+
toolName: name as ModifiedFileToolName,
|
|
86
|
+
ts,
|
|
87
|
+
messageUuid,
|
|
88
|
+
filePath,
|
|
89
|
+
});
|
|
90
|
+
} else if (b.type === 'tool_result') {
|
|
91
|
+
const id = b.tool_use_id;
|
|
92
|
+
if (typeof id !== 'string' || !id) continue;
|
|
93
|
+
// 同一 tool_use_id 理论上只对应一条 result;以首次出现为准。
|
|
94
|
+
if (!resultErr.has(id)) resultErr.set(id, b.is_error === true);
|
|
95
|
+
if (!resultPatch.has(id)) {
|
|
96
|
+
const patch = extractStructuredPatch(obj.toolUseResult);
|
|
97
|
+
if (patch) resultPatch.set(id, patch);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 按 filePath 聚合
|
|
104
|
+
const byPath = new Map<string, ModifiedFileSummary>();
|
|
105
|
+
for (const op of ops) {
|
|
106
|
+
const errored = resultErr.get(op.toolUseId) === true;
|
|
107
|
+
const pending = !resultErr.has(op.toolUseId);
|
|
108
|
+
const operation: ModifiedFileOperation = {
|
|
109
|
+
toolUseId: op.toolUseId,
|
|
110
|
+
toolName: op.toolName,
|
|
111
|
+
ts: op.ts,
|
|
112
|
+
messageUuid: op.messageUuid,
|
|
113
|
+
errored,
|
|
114
|
+
pending,
|
|
115
|
+
structuredPatch: resultPatch.has(op.toolUseId) ? resultPatch.get(op.toolUseId)! : null,
|
|
116
|
+
};
|
|
117
|
+
let summary = byPath.get(op.filePath);
|
|
118
|
+
if (!summary) {
|
|
119
|
+
summary = {
|
|
120
|
+
filePath: op.filePath,
|
|
121
|
+
relativePath: null,
|
|
122
|
+
editCount: 0,
|
|
123
|
+
writeCount: 0,
|
|
124
|
+
multiEditCount: 0,
|
|
125
|
+
notebookEditCount: 0,
|
|
126
|
+
totalCount: 0,
|
|
127
|
+
errorCount: 0,
|
|
128
|
+
firstAt: null,
|
|
129
|
+
lastAt: null,
|
|
130
|
+
operations: [],
|
|
131
|
+
};
|
|
132
|
+
byPath.set(op.filePath, summary);
|
|
133
|
+
}
|
|
134
|
+
summary.operations.push(operation);
|
|
135
|
+
summary.totalCount += 1;
|
|
136
|
+
if (errored) summary.errorCount += 1;
|
|
137
|
+
switch (op.toolName) {
|
|
138
|
+
case 'Edit':
|
|
139
|
+
summary.editCount += 1;
|
|
140
|
+
break;
|
|
141
|
+
case 'Write':
|
|
142
|
+
summary.writeCount += 1;
|
|
143
|
+
break;
|
|
144
|
+
case 'MultiEdit':
|
|
145
|
+
summary.multiEditCount += 1;
|
|
146
|
+
break;
|
|
147
|
+
case 'NotebookEdit':
|
|
148
|
+
summary.notebookEditCount += 1;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
if (op.ts) {
|
|
152
|
+
if (!summary.firstAt || op.ts < summary.firstAt) summary.firstAt = op.ts;
|
|
153
|
+
if (!summary.lastAt || op.ts > summary.lastAt) summary.lastAt = op.ts;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const summary of byPath.values()) {
|
|
158
|
+
summary.operations.sort(compareByTs);
|
|
159
|
+
summary.relativePath = relativizeIfUnder(summary.filePath, cwd);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const files = Array.from(byPath.values()).sort((a, b) => {
|
|
163
|
+
// lastAt desc,null 排末尾
|
|
164
|
+
if (a.lastAt && b.lastAt) return a.lastAt < b.lastAt ? 1 : a.lastAt > b.lastAt ? -1 : 0;
|
|
165
|
+
if (a.lastAt) return -1;
|
|
166
|
+
if (b.lastAt) return 1;
|
|
167
|
+
return a.filePath.localeCompare(b.filePath);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return { sessionId, projectId, cwd, files };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pull the structuredPatch hunks out of a `toolUseResult` sentinel. Returns an
|
|
174
|
+
* empty array for a brand-new file (Write/NotebookEdit create, where Claude Code
|
|
175
|
+
* records `structuredPatch: []`), and null when the field is absent/malformed. */
|
|
176
|
+
function extractStructuredPatch(tur: unknown): DiffHunk[] | null {
|
|
177
|
+
if (!tur || typeof tur !== 'object') return null;
|
|
178
|
+
const sp = (tur as Record<string, unknown>).structuredPatch;
|
|
179
|
+
if (!Array.isArray(sp)) return null;
|
|
180
|
+
const hunks: DiffHunk[] = [];
|
|
181
|
+
for (const h of sp) {
|
|
182
|
+
if (!h || typeof h !== 'object') continue;
|
|
183
|
+
const r = h as Record<string, unknown>;
|
|
184
|
+
if (
|
|
185
|
+
typeof r.oldStart === 'number' &&
|
|
186
|
+
typeof r.oldLines === 'number' &&
|
|
187
|
+
typeof r.newStart === 'number' &&
|
|
188
|
+
typeof r.newLines === 'number' &&
|
|
189
|
+
Array.isArray(r.lines)
|
|
190
|
+
) {
|
|
191
|
+
hunks.push({
|
|
192
|
+
oldStart: r.oldStart,
|
|
193
|
+
oldLines: r.oldLines,
|
|
194
|
+
newStart: r.newStart,
|
|
195
|
+
newLines: r.newLines,
|
|
196
|
+
lines: r.lines.filter((l): l is string => typeof l === 'string'),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return hunks;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extractFilePath(input: Record<string, unknown>): string | null {
|
|
204
|
+
const fp = input.file_path;
|
|
205
|
+
if (typeof fp === 'string' && fp) return fp;
|
|
206
|
+
// NotebookEdit uses notebook_path.
|
|
207
|
+
const np = input.notebook_path;
|
|
208
|
+
if (typeof np === 'string' && np) return np;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function relativizeIfUnder(filePath: string, cwd: string | null): string | null {
|
|
213
|
+
if (!cwd) return null;
|
|
214
|
+
// 用 posix-style 简单前缀判断即可——session 是在 macOS/Linux/Windows
|
|
215
|
+
// 各自原生路径下记录的 cwd,不跨平台。
|
|
216
|
+
const normCwd = cwd.replace(/[\\/]+$/, '');
|
|
217
|
+
if (filePath === normCwd) return '.';
|
|
218
|
+
if (filePath.startsWith(normCwd + '/')) return filePath.slice(normCwd.length + 1);
|
|
219
|
+
if (filePath.startsWith(normCwd + '\\')) return filePath.slice(normCwd.length + 1);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function compareByTs(a: ModifiedFileOperation, b: ModifiedFileOperation): number {
|
|
224
|
+
if (a.ts && b.ts) return a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0;
|
|
225
|
+
if (a.ts) return -1;
|
|
226
|
+
if (b.ts) return 1;
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
@@ -1,40 +1,47 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
|
|
4
|
-
export interface
|
|
5
|
-
ok: boolean;
|
|
6
|
-
error?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function openFolder(folderPath: string):
|
|
10
|
-
try {
|
|
11
|
-
const st = fs.statSync(folderPath);
|
|
12
|
-
if (!st.isDirectory()) return { ok: false, error: 'not a directory' };
|
|
13
|
-
} catch {
|
|
14
|
-
return { ok: false, error: 'path not found' };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export interface OpenResult {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function openFolder(folderPath: string): OpenResult {
|
|
10
|
+
try {
|
|
11
|
+
const st = fs.statSync(folderPath);
|
|
12
|
+
if (!st.isDirectory()) return { ok: false, error: 'not a directory' };
|
|
13
|
+
} catch {
|
|
14
|
+
return { ok: false, error: 'path not found' };
|
|
15
|
+
}
|
|
16
|
+
return launch(folderPath);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function openFile(filePath: string): OpenResult {
|
|
20
|
+
try {
|
|
21
|
+
const st = fs.statSync(filePath);
|
|
22
|
+
if (!st.isFile()) return { ok: false, error: 'not a file' };
|
|
23
|
+
} catch {
|
|
24
|
+
return { ok: false, error: 'path not found' };
|
|
25
|
+
}
|
|
26
|
+
return launch(filePath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 交给系统默认程序打开(文件 → 关联应用;文件夹 → 资源管理器),等价于双击。
|
|
30
|
+
// detached + unref,不阻塞、不等子进程;spawn 失败在异步回调里只记日志。
|
|
31
|
+
function launch(target: string): OpenResult {
|
|
32
|
+
let cmd: string;
|
|
33
|
+
if (process.platform === 'win32') cmd = 'explorer.exe';
|
|
34
|
+
else if (process.platform === 'darwin') cmd = 'open';
|
|
35
|
+
else cmd = 'xdg-open';
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const child = spawn(cmd, [target], { detached: true, stdio: 'ignore' });
|
|
39
|
+
child.on('error', (err) => {
|
|
40
|
+
console.error(`[open] spawn ${cmd} failed:`, err);
|
|
41
|
+
});
|
|
42
|
+
child.unref();
|
|
43
|
+
return { ok: true };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { ok: false, error: (err as Error).message };
|
|
46
|
+
}
|
|
47
|
+
}
|