cc-viewer 1.0.5 → 1.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/server.js CHANGED
@@ -1,14 +1,44 @@
1
1
  import { createServer } from 'node:http';
2
- import { readFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, readdirSync, renameSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join, extname, basename } from 'node:path';
5
- import { homedir } from 'node:os';
6
- import { LOG_FILE } from './interceptor.js';
5
+ import { homedir, userInfo, platform } from 'node:os';
6
+ import { execSync } from 'node:child_process';
7
+ import { LOG_FILE, _initPromise, _resumeState, resolveResumeChoice } from './interceptor.js';
7
8
  import { t } from './i18n.js';
8
9
 
9
10
  const LOG_DIR = join(homedir(), '.claude', 'cc-viewer');
11
+ const PREFS_FILE = join(LOG_DIR, 'preferences.json');
10
12
  const SHOW_ALL_FILE = '/tmp/cc-viewer-show-all';
11
13
 
14
+ // macOS user profile (avatar + display name), cached once
15
+ let _userProfile = null;
16
+ function getUserProfile() {
17
+ if (_userProfile) return _userProfile;
18
+ const info = userInfo();
19
+ const name = info.username || 'User';
20
+ let displayName = name;
21
+ let avatarBase64 = null;
22
+
23
+ if (platform() === 'darwin') {
24
+ try {
25
+ const rn = execSync(`dscl . -read /Users/${name} RealName`, { encoding: 'utf-8', timeout: 3000 });
26
+ const match = rn.match(/RealName:\n?\s*(.+)/);
27
+ if (match && match[1].trim()) displayName = match[1].trim();
28
+ } catch {}
29
+
30
+ try {
31
+ const buf = execSync(`dscl . -read /Users/${name} JPEGPhoto | tail -1 | xxd -r -p`, { timeout: 5000, maxBuffer: 1024 * 1024 });
32
+ if (buf && buf.length > 100) {
33
+ avatarBase64 = `data:image/jpeg;base64,${buf.toString('base64')}`;
34
+ }
35
+ } catch {}
36
+ }
37
+
38
+ _userProfile = { name: displayName, avatar: avatarBase64 };
39
+ return _userProfile;
40
+ }
41
+
12
42
  const __filename = fileURLToPath(import.meta.url);
13
43
  const __dirname = dirname(__filename);
14
44
  const START_PORT = 7008;
@@ -85,6 +115,11 @@ function watchLogFile(logFile) {
85
115
  }
86
116
  });
87
117
  }
118
+
119
+ // 检测日志文件是否已轮转到新文件
120
+ if (LOG_FILE !== logFile && !watchedFiles.has(LOG_FILE)) {
121
+ watchLogFile(LOG_FILE);
122
+ }
88
123
  } catch (err) {
89
124
  // File not yet created, will retry on next poll
90
125
  }
@@ -109,6 +144,35 @@ function handleRequest(req, res) {
109
144
  return;
110
145
  }
111
146
 
147
+ // User preferences API
148
+ if (url === '/api/preferences' && method === 'GET') {
149
+ let prefs = {};
150
+ try { if (existsSync(PREFS_FILE)) prefs = JSON.parse(readFileSync(PREFS_FILE, 'utf-8')); } catch {}
151
+ res.writeHead(200, { 'Content-Type': 'application/json' });
152
+ res.end(JSON.stringify(prefs));
153
+ return;
154
+ }
155
+
156
+ if (url === '/api/preferences' && method === 'POST') {
157
+ let body = '';
158
+ req.on('data', chunk => { body += chunk; });
159
+ req.on('end', () => {
160
+ try {
161
+ const incoming = JSON.parse(body);
162
+ let prefs = {};
163
+ try { if (existsSync(PREFS_FILE)) prefs = JSON.parse(readFileSync(PREFS_FILE, 'utf-8')); } catch {}
164
+ Object.assign(prefs, incoming);
165
+ writeFileSync(PREFS_FILE, JSON.stringify(prefs, null, 2));
166
+ res.writeHead(200, { 'Content-Type': 'application/json' });
167
+ res.end(JSON.stringify(prefs));
168
+ } catch {
169
+ res.writeHead(400, { 'Content-Type': 'application/json' });
170
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
171
+ }
172
+ });
173
+ return;
174
+ }
175
+
112
176
  // 注册新的日志文件进行 watch(供新进程复用旧服务时调用)
113
177
  if (url === '/api/register-log' && method === 'POST') {
114
178
  let body = '';
@@ -132,6 +196,50 @@ function handleRequest(req, res) {
132
196
  return;
133
197
  }
134
198
 
199
+ // 用户选择继续/新开日志
200
+ if (url === '/api/resume-choice' && method === 'POST') {
201
+ let body = '';
202
+ req.on('data', chunk => { body += chunk; });
203
+ req.on('end', () => {
204
+ try {
205
+ const { choice } = JSON.parse(body);
206
+ if (choice !== 'continue' && choice !== 'new') {
207
+ res.writeHead(400, { 'Content-Type': 'application/json' });
208
+ res.end(JSON.stringify({ error: 'Invalid choice' }));
209
+ return;
210
+ }
211
+ const result = resolveResumeChoice(choice);
212
+ if (!result) {
213
+ res.writeHead(409, { 'Content-Type': 'application/json' });
214
+ res.end(JSON.stringify({ error: 'Already resolved' }));
215
+ return;
216
+ }
217
+ // 重新 watch 最终的日志文件
218
+ watchLogFile(result.logFile);
219
+ // 广播 resume_resolved + full_reload
220
+ const resolvedData = JSON.stringify({ logFile: result.logFile });
221
+ clients.forEach(client => {
222
+ try {
223
+ client.write(`event: resume_resolved\ndata: ${resolvedData}\n\n`);
224
+ } catch {}
225
+ });
226
+ // 发送 full_reload 让客户端重新加载数据
227
+ const entries = readLogFile();
228
+ clients.forEach(client => {
229
+ try {
230
+ client.write(`event: full_reload\ndata: ${JSON.stringify(entries)}\n\n`);
231
+ } catch {}
232
+ });
233
+ res.writeHead(200, { 'Content-Type': 'application/json' });
234
+ res.end(JSON.stringify({ ok: true, logFile: result.logFile }));
235
+ } catch {
236
+ res.writeHead(400, { 'Content-Type': 'application/json' });
237
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
238
+ }
239
+ });
240
+ return;
241
+ }
242
+
135
243
  // SSE endpoint
136
244
  if (url === '/events' && method === 'GET') {
137
245
  res.writeHead(200, {
@@ -142,6 +250,11 @@ function handleRequest(req, res) {
142
250
 
143
251
  clients.push(res);
144
252
 
253
+ // 如果有待决的 resume 选择,发送 resume_prompt 事件
254
+ if (_resumeState) {
255
+ res.write(`event: resume_prompt\ndata: ${JSON.stringify({ recentFileName: _resumeState.recentFileName })}\n\n`);
256
+ }
257
+
145
258
  const entries = readLogFile();
146
259
  entries.forEach(entry => {
147
260
  res.write(`data: ${JSON.stringify(entry)}\n\n`);
@@ -169,6 +282,14 @@ function handleRequest(req, res) {
169
282
  return;
170
283
  }
171
284
 
285
+ // macOS 用户头像和显示名
286
+ if (url === '/api/user-profile' && method === 'GET') {
287
+ const profile = getUserProfile();
288
+ res.writeHead(200, { 'Content-Type': 'application/json' });
289
+ res.end(JSON.stringify(profile));
290
+ return;
291
+ }
292
+
172
293
  // 列出本地日志文件(按项目分组)
173
294
  if (url === '/api/local-logs' && method === 'GET') {
174
295
  try {
@@ -319,6 +440,16 @@ export async function startViewer() {
319
440
  }
320
441
 
321
442
  export function stopViewer() {
443
+ // 如果用户未做选择,将临时文件转为正式文件
444
+ if (_resumeState && _resumeState.tempFile) {
445
+ try {
446
+ const { tempFile } = _resumeState;
447
+ if (existsSync(tempFile)) {
448
+ const newPath = tempFile.replace('_temp.jsonl', '.jsonl');
449
+ renameSync(tempFile, newPath);
450
+ }
451
+ } catch {}
452
+ }
322
453
  for (const logFile of watchedFiles.keys()) {
323
454
  unwatchFile(logFile);
324
455
  }
@@ -330,7 +461,24 @@ export function stopViewer() {
330
461
  }
331
462
  }
332
463
 
333
- // Auto-start the viewer when imported
334
- startViewer().catch(err => {
335
- console.error('Failed to start CC Viewer:', err);
464
+ // Auto-start the viewer after log file init completes
465
+ _initPromise.then(() => {
466
+ startViewer().catch(err => {
467
+ console.error('Failed to start CC Viewer:', err);
468
+ });
336
469
  });
470
+
471
+ // 进程退出时,将未决的临时文件转为正式文件
472
+ function handleExit() {
473
+ if (_resumeState && _resumeState.tempFile) {
474
+ try {
475
+ if (existsSync(_resumeState.tempFile)) {
476
+ const newPath = _resumeState.tempFile.replace('_temp.jsonl', '.jsonl');
477
+ renameSync(_resumeState.tempFile, newPath);
478
+ }
479
+ } catch {}
480
+ }
481
+ }
482
+ process.on('exit', handleExit);
483
+ process.on('SIGINT', () => { handleExit(); process.exit(); });
484
+ process.on('SIGTERM', () => { handleExit(); process.exit(); });
@@ -1 +0,0 @@
1
- body{margin:0;background-color:#0d0d0d}*{scrollbar-width:thin;scrollbar-color:#3a3a3a #0d0d0d}*::-webkit-scrollbar{width:6px;height:6px}*::-webkit-scrollbar-track{background:#0d0d0d}*::-webkit-scrollbar-thumb{background:#3a3a3a;border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:#555}.diff-view{background:#1a1a2e;border:1px solid #2a2a3e;border-radius:8px;padding:8px 12px}.diff-line-del{background:#ef444426;color:#fca5a5;padding:0 4px}.diff-line-add{background:#22c55e26;color:#86efac;padding:0 4px}.code-highlight{color:#e6edf3}.hl-keyword{color:#ff7b72}.hl-string{color:#a5d6ff}.hl-comment{color:#8b949e;font-style:italic}.hl-number{color:#79c0ff}.hl-linenum{color:#484f58;-webkit-user-select:none;user-select:none}._GzYRV{line-height:1.2;white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word}._3eOF8{margin-right:5px;font-weight:700}._3eOF8+._3eOF8{margin-left:-5px}._1MFti{cursor:pointer}._f10Tu{font-size:1.2em;margin-right:5px;-webkit-user-select:none;-moz-user-select:none;user-select:none}._1UmXx:after{content:"▸"}._1LId0:after{content:"▾"}._1pNG9{margin-right:5px}._1pNG9:after{content:"...";font-size:.8em}._2IvMF{background:#eee}._2bkNM{margin:0;padding:0 10px}._1BXBN{margin:0;padding:0}._1MGIk{font-weight:600;margin-right:5px;color:#000}._3uHL6{color:#000}._2T6PJ,._1Gho6{color:#df113a}._vGjyY{color:#2a3f3c}._1bQdo{color:#0b75f5}._3zQKs{color:#469038}._1xvuR{color:#43413d}._oLqym,._2AXVT,._2KJWg{color:#000}._11RoI{background:#002b36}._17H2C,._3QHg2,._3fDAz{color:#fdf6e3}._2bSDX{font-weight:bolder;margin-right:5px;color:#fdf6e3}._gsbQL{color:#fdf6e3}._LaAZe,._GTKgm{color:#81b5ac}._Chy1W{color:#cb4b16}._2bveF{color:#d33682}._2vRm-{color:#ae81ff}._1prJR{color:#268bd2}