claude-opencode-viewer 2.6.4 → 2.6.5

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/index-pc.html ADDED
@@ -0,0 +1,2141 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
+ <title>Claude OpenCode Viewer [PC]</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+ html, body { margin: 0; padding: 0; overflow: hidden; }
11
+
12
+ /* 参考 cc-viewer 的 App.jsx 行 1319: 移动端容器使用 100vw/100vh */
13
+ #layout {
14
+ width: 100vw;
15
+ height: 100vh;
16
+ display: flex;
17
+ flex-direction: column;
18
+ background: #000;
19
+ overflow: hidden;
20
+ }
21
+
22
+ /* 参考 cc-viewer 的 App.jsx 行 1320-1335: 顶部工具栏 */
23
+ #header {
24
+ padding: 10px 12px;
25
+ background: #111;
26
+ border-bottom: 1px solid #222;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ flex-shrink: 0;
31
+ height: 40px;
32
+ gap: 12px;
33
+ }
34
+
35
+ #session-history-bar {
36
+ display: none;
37
+ position: absolute;
38
+ top: 0;
39
+ left: 0;
40
+ right: 0;
41
+ bottom: 0;
42
+ background: #0a0a0a;
43
+ z-index: 1000;
44
+ flex-direction: column;
45
+ }
46
+
47
+ #session-history-bar.visible {
48
+ display: flex;
49
+ }
50
+
51
+ #session-list-view {
52
+ display: flex;
53
+ flex-direction: column;
54
+ flex: 1;
55
+ min-height: 0;
56
+ }
57
+
58
+ #session-list-view.hidden {
59
+ display: none;
60
+ }
61
+
62
+ #session-history-header {
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: space-between;
66
+ padding: 12px 16px;
67
+ background: #111;
68
+ border-bottom: 1px solid #222;
69
+ flex-shrink: 0;
70
+ }
71
+
72
+ #session-history-title {
73
+ font-size: 14px;
74
+ color: #ddd;
75
+ font-weight: 600;
76
+ }
77
+
78
+ #session-history-actions {
79
+ display: flex;
80
+ gap: 8px;
81
+ }
82
+
83
+ #session-list-container {
84
+ flex: 1;
85
+ overflow-y: auto;
86
+ padding: 12px;
87
+ -webkit-overflow-scrolling: touch;
88
+ }
89
+
90
+ #session-detail-view {
91
+ display: none;
92
+ flex-direction: column;
93
+ flex: 1;
94
+ min-height: 0;
95
+ }
96
+
97
+ #session-detail-view.visible {
98
+ display: flex;
99
+ }
100
+
101
+ #session-detail-header {
102
+ padding: 12px 16px;
103
+ background: #0d0d0d;
104
+ border-bottom: 1px solid #222;
105
+ flex-shrink: 0;
106
+ }
107
+
108
+ #session-detail-title {
109
+ font-size: 14px;
110
+ color: #ddd;
111
+ font-weight: 500;
112
+ margin-bottom: 4px;
113
+ }
114
+
115
+ #session-detail-meta {
116
+ font-size: 11px;
117
+ color: #888;
118
+ display: flex;
119
+ gap: 12px;
120
+ }
121
+
122
+ #session-detail-content {
123
+ flex: 1;
124
+ overflow-y: auto;
125
+ padding: 16px;
126
+ -webkit-overflow-scrolling: touch;
127
+ }
128
+
129
+ .message-item {
130
+ margin-bottom: 20px;
131
+ display: flex;
132
+ gap: 12px;
133
+ align-items: flex-start;
134
+ }
135
+
136
+ /* User 消息:左对齐 */
137
+ .message-user {
138
+ justify-content: flex-start;
139
+ }
140
+
141
+ /* Assistant 消息:右对齐 */
142
+ .message-assistant {
143
+ justify-content: flex-end;
144
+ flex-direction: row-reverse;
145
+ }
146
+
147
+ .message-avatar {
148
+ flex-shrink: 0;
149
+ width: 32px;
150
+ height: 32px;
151
+ border-radius: 50%;
152
+ background: #2a4a7c;
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ font-size: 14px;
157
+ }
158
+
159
+ .message-assistant .message-avatar {
160
+ background: #1a5a3a;
161
+ }
162
+
163
+ .message-content {
164
+ flex: 1;
165
+ min-width: 0;
166
+ max-width: 80%;
167
+ }
168
+
169
+ .message-header {
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 8px;
173
+ margin-bottom: 6px;
174
+ }
175
+
176
+ /* User 的标题左对齐 */
177
+ .message-user .message-header {
178
+ justify-content: flex-start;
179
+ }
180
+
181
+ /* Assistant 的标题右对齐 */
182
+ .message-assistant .message-header {
183
+ justify-content: flex-end;
184
+ flex-direction: row-reverse;
185
+ }
186
+
187
+ .message-role {
188
+ font-size: 11px;
189
+ color: #888;
190
+ font-weight: 600;
191
+ text-transform: uppercase;
192
+ letter-spacing: 0.5px;
193
+ }
194
+
195
+ .message-text {
196
+ color: #ddd;
197
+ font-size: 13px;
198
+ line-height: 1.6;
199
+ white-space: pre-wrap;
200
+ word-break: break-word;
201
+ background: #141414;
202
+ padding: 12px;
203
+ border-radius: 8px;
204
+ border: 1px solid #222;
205
+ }
206
+
207
+ /* User 消息气泡 */
208
+ .message-user .message-text {
209
+ background: #1a2332;
210
+ border-color: #2a4a7c;
211
+ }
212
+
213
+ /* Assistant 消息气泡 */
214
+ .message-assistant .message-text {
215
+ background: #1a2e1a;
216
+ border-color: #2a5a3a;
217
+ }
218
+
219
+ .message-tool-call {
220
+ margin-top: 8px;
221
+ padding: 8px 12px;
222
+ background: #1a1a0a;
223
+ border: 1px solid #333;
224
+ border-radius: 6px;
225
+ font-size: 12px;
226
+ }
227
+
228
+ .message-tool-name {
229
+ color: #f0ad4e;
230
+ font-weight: 600;
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 6px;
234
+ }
235
+
236
+ .message-tool-result {
237
+ margin-top: 6px;
238
+ padding: 8px;
239
+ background: #0a0a0a;
240
+ border-radius: 4px;
241
+ font-size: 11px;
242
+ color: #999;
243
+ max-height: 100px;
244
+ overflow-y: auto;
245
+ }
246
+
247
+ .message-empty {
248
+ text-align: center;
249
+ padding: 40px 20px;
250
+ color: #666;
251
+ font-size: 13px;
252
+ }
253
+
254
+ #session-list {
255
+ display: flex;
256
+ flex-direction: column;
257
+ gap: 6px;
258
+ }
259
+
260
+ .session-item {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 8px;
264
+ padding: 8px 12px;
265
+ background: #1a1a1a;
266
+ border: 1px solid #333;
267
+ border-radius: 6px;
268
+ cursor: pointer;
269
+ transition: all 0.15s;
270
+ min-width: 0;
271
+ }
272
+
273
+ .session-item:hover {
274
+ background: #252525;
275
+ border-color: #555;
276
+ }
277
+
278
+ .session-item.active {
279
+ background: #2a4a7c;
280
+ border-color: #4a8cff;
281
+ }
282
+
283
+ .session-icon {
284
+ flex-shrink: 0;
285
+ width: 8px;
286
+ height: 8px;
287
+ border-radius: 50%;
288
+ background: #666;
289
+ }
290
+
291
+ .session-item.active .session-icon {
292
+ background: #4a8cff;
293
+ }
294
+
295
+ .session-info {
296
+ flex: 1;
297
+ min-width: 0;
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 2px;
301
+ }
302
+
303
+ .session-title {
304
+ font-size: 13px;
305
+ color: #ddd;
306
+ font-weight: 500;
307
+ line-height: 1.4;
308
+ display: -webkit-box;
309
+ -webkit-line-clamp: 2;
310
+ -webkit-box-orient: vertical;
311
+ overflow: hidden;
312
+ text-overflow: ellipsis;
313
+ word-break: break-word;
314
+ }
315
+
316
+ .session-item.active .session-title {
317
+ color: #fff;
318
+ }
319
+
320
+ .session-meta {
321
+ font-size: 11px;
322
+ color: #888;
323
+ display: flex;
324
+ gap: 8px;
325
+ }
326
+
327
+ .session-time {
328
+ flex-shrink: 0;
329
+ }
330
+
331
+ .session-dir {
332
+ overflow: hidden;
333
+ text-overflow: ellipsis;
334
+ white-space: nowrap;
335
+ }
336
+
337
+ .history-toggle-btn {
338
+ background: none;
339
+ border: 1px solid #333;
340
+ color: #aaa;
341
+ padding: 4px 10px;
342
+ font-size: 12px;
343
+ cursor: pointer;
344
+ border-radius: 4px;
345
+ display: flex;
346
+ align-items: center;
347
+ gap: 4px;
348
+ flex-shrink: 0;
349
+ }
350
+
351
+ .history-toggle-btn:hover {
352
+ background: #2a2a2a;
353
+ color: #ddd;
354
+ border-color: #555;
355
+ }
356
+
357
+ .history-toggle-btn svg {
358
+ width: 14px;
359
+ height: 14px;
360
+ }
361
+
362
+ .session-loading {
363
+ text-align: center;
364
+ padding: 16px;
365
+ color: #888;
366
+ font-size: 12px;
367
+ }
368
+
369
+ .session-empty {
370
+ text-align: center;
371
+ padding: 16px;
372
+ color: #666;
373
+ font-size: 12px;
374
+ }
375
+
376
+ .session-delete-btn {
377
+ flex-shrink: 0;
378
+ width: 28px;
379
+ height: 28px;
380
+ border: none;
381
+ background: none;
382
+ color: #f85149;
383
+ font-size: 14px;
384
+ cursor: pointer;
385
+ border-radius: 50%;
386
+ display: flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ transition: all 0.15s;
390
+ -webkit-tap-highlight-color: transparent;
391
+ }
392
+
393
+ .session-delete-btn:hover {
394
+ background: rgba(248, 81, 73, 0.15);
395
+ color: #f85149;
396
+ }
397
+
398
+ .session-delete-btn:active {
399
+ background: rgba(248, 81, 73, 0.3);
400
+ color: #f85149;
401
+ }
402
+
403
+ .session-restore-hint {
404
+ margin-top: 8px;
405
+ padding: 8px 12px;
406
+ background: #1a1a1a;
407
+ border: 1px solid #333;
408
+ border-radius: 6px;
409
+ font-size: 11px;
410
+ color: #888;
411
+ text-align: center;
412
+ }
413
+
414
+ .session-restore-hint code {
415
+ color: #4a8cff;
416
+ background: #0a0a0a;
417
+ padding: 2px 6px;
418
+ border-radius: 3px;
419
+ }
420
+
421
+ #mode-switcher {
422
+ display: flex;
423
+ gap: 4px;
424
+ align-items: center;
425
+ }
426
+
427
+ #mode-label {
428
+ font-size: 12px;
429
+ color: #666;
430
+ }
431
+
432
+ #mode-select {
433
+ background: #1a1a1a;
434
+ color: #d4d4d4;
435
+ border: 1px solid #333;
436
+ border-radius: 4px;
437
+ padding: 4px 24px 4px 8px;
438
+ font-size: 12px;
439
+ cursor: pointer;
440
+ -webkit-appearance: none;
441
+ appearance: none;
442
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 10 10'%3E%3Cpath fill='%23888' d='M5 7L1 3h8z'/%3E%3C/svg%3E");
443
+ background-repeat: no-repeat;
444
+ background-position: right 6px center;
445
+ }
446
+
447
+ /* 参考 cc-viewer 的 App.jsx 行 1425: 内容区使用 flex: 1 + relative + overflow: hidden */
448
+ #content {
449
+ flex: 1;
450
+ position: relative;
451
+ overflow: hidden;
452
+ }
453
+
454
+ /* 参考 cc-viewer 的 TerminalPanel.jsx: 终端容器样式 */
455
+ #terminal-container {
456
+ height: 100%;
457
+ display: flex;
458
+ flex-direction: column;
459
+ background: #0a0a0a;
460
+ }
461
+
462
+ #terminal {
463
+ flex: 1;
464
+ overflow: hidden;
465
+ touch-action: none;
466
+ overscroll-behavior: contain;
467
+ }
468
+
469
+ #terminal.transitioning {
470
+ opacity: 0.3;
471
+ transition: opacity 0.3s ease;
472
+ }
473
+
474
+
475
+ /* 选择模式:原位文本层 */
476
+ #terminal.select-mode .xterm-screen {
477
+ visibility: hidden;
478
+ }
479
+
480
+ #select-text-layer {
481
+ display: none;
482
+ position: absolute;
483
+ top: 0; left: 0; right: 0; bottom: 0;
484
+ overflow-y: auto;
485
+ -webkit-overflow-scrolling: touch;
486
+ background: #0a0a0a;
487
+ padding: 4px 8px;
488
+ touch-action: auto;
489
+ z-index: 10;
490
+ }
491
+
492
+ #select-text-layer.visible {
493
+ display: block;
494
+ }
495
+
496
+ #select-hint {
497
+ position: sticky;
498
+ top: 0;
499
+ background: rgba(30,30,30,0.95);
500
+ color: #888;
501
+ font-size: 11px;
502
+ text-align: center;
503
+ padding: 6px 0;
504
+ border-bottom: 1px solid #333;
505
+ z-index: 1;
506
+ -webkit-user-select: none;
507
+ user-select: none;
508
+ }
509
+
510
+ #select-text-layer pre {
511
+ margin: 0;
512
+ color: #d4d4d4;
513
+ font-family: Menlo, Monaco, "Courier New", monospace;
514
+ font-size: 11px;
515
+ line-height: 1.4;
516
+ white-space: pre-wrap;
517
+ word-break: break-all;
518
+ -webkit-user-select: text;
519
+ user-select: text;
520
+ }
521
+
522
+ #select-mode-close {
523
+ position: absolute;
524
+ top: 6px;
525
+ right: 6px;
526
+ z-index: 20;
527
+ display: none;
528
+ background: rgba(50,50,50,0.9);
529
+ border: 1px solid #555;
530
+ color: #ccc;
531
+ width: 28px;
532
+ height: 28px;
533
+ border-radius: 50%;
534
+ font-size: 14px;
535
+ line-height: 26px;
536
+ text-align: center;
537
+ cursor: pointer;
538
+ -webkit-user-select: none;
539
+ user-select: none;
540
+ }
541
+
542
+ /* 复制成功提示 */
543
+ #copy-toast {
544
+ display: none;
545
+ position: fixed;
546
+ top: 50%;
547
+ left: 50%;
548
+ transform: translate(-50%, -50%);
549
+ background: rgba(40, 167, 69, 0.9);
550
+ color: #fff;
551
+ padding: 10px 24px;
552
+ border-radius: 8px;
553
+ font-size: 14px;
554
+ z-index: 9999;
555
+ pointer-events: none;
556
+ }
557
+
558
+ #copy-toast.show {
559
+ display: block;
560
+ }
561
+
562
+ /* Git Diff 面板 */
563
+ #git-diff-bar {
564
+ display: none;
565
+ position: absolute;
566
+ top: 0;
567
+ left: 0;
568
+ right: 0;
569
+ bottom: 0;
570
+ background: #0a0a0a;
571
+ z-index: 1000;
572
+ flex-direction: column;
573
+ }
574
+
575
+ #git-diff-bar.visible {
576
+ display: flex;
577
+ }
578
+
579
+ #git-diff-header {
580
+ display: flex;
581
+ align-items: center;
582
+ justify-content: space-between;
583
+ padding: 12px 16px;
584
+ background: #111;
585
+ border-bottom: 1px solid #222;
586
+ flex-shrink: 0;
587
+ }
588
+
589
+ #git-diff-title {
590
+ font-size: 14px;
591
+ color: #ddd;
592
+ font-weight: 600;
593
+ }
594
+
595
+ .git-diff-file-list {
596
+ height: 250px;
597
+ flex-shrink: 0;
598
+ overflow-y: auto;
599
+ border-bottom: 1px solid #2a2a2a;
600
+ -webkit-overflow-scrolling: touch;
601
+ }
602
+
603
+ .git-diff-file-item {
604
+ display: flex;
605
+ align-items: center;
606
+ padding: 6px 12px;
607
+ cursor: pointer;
608
+ color: #ccc;
609
+ font-size: 13px;
610
+ gap: 8px;
611
+ white-space: nowrap;
612
+ }
613
+
614
+ .git-diff-file-item:hover {
615
+ background: #1a1a1a;
616
+ }
617
+
618
+ .git-diff-file-item.active {
619
+ background: rgba(74, 158, 255, 0.12);
620
+ color: #fff;
621
+ }
622
+
623
+ .git-diff-file-status {
624
+ width: 18px;
625
+ flex-shrink: 0;
626
+ font-size: 11px;
627
+ font-weight: 700;
628
+ text-align: center;
629
+ }
630
+
631
+ .git-diff-file-name {
632
+ overflow: hidden;
633
+ text-overflow: ellipsis;
634
+ flex: 1;
635
+ font-family: Menlo, Monaco, monospace;
636
+ font-size: 12px;
637
+ }
638
+
639
+ .git-diff-content-area {
640
+ flex: 1;
641
+ display: flex;
642
+ flex-direction: column;
643
+ min-height: 0;
644
+ overflow: hidden;
645
+ }
646
+
647
+ .git-diff-content-header {
648
+ display: flex;
649
+ align-items: center;
650
+ gap: 10px;
651
+ padding: 8px 12px;
652
+ border-bottom: 1px solid #2a2a2a;
653
+ background: #111;
654
+ flex-shrink: 0;
655
+ }
656
+
657
+ .git-diff-content-path {
658
+ font-size: 12px;
659
+ color: #ccc;
660
+ font-family: Menlo, Monaco, monospace;
661
+ overflow: hidden;
662
+ text-overflow: ellipsis;
663
+ white-space: nowrap;
664
+ flex: 1;
665
+ }
666
+
667
+ .git-diff-badge {
668
+ padding: 2px 8px;
669
+ background: #2a2a2a;
670
+ border: 1px solid #444;
671
+ border-radius: 4px;
672
+ font-size: 10px;
673
+ font-weight: 600;
674
+ color: #e2c08d;
675
+ letter-spacing: 0.5px;
676
+ flex-shrink: 0;
677
+ }
678
+
679
+ .git-diff-content-scroll {
680
+ flex: 1;
681
+ overflow: auto;
682
+ -webkit-overflow-scrolling: touch;
683
+ }
684
+
685
+ .git-diff-placeholder {
686
+ flex: 1;
687
+ display: flex;
688
+ flex-direction: column;
689
+ align-items: center;
690
+ justify-content: center;
691
+ gap: 12px;
692
+ color: #333;
693
+ font-size: 13px;
694
+ }
695
+
696
+ .git-diff-loading {
697
+ text-align: center;
698
+ padding: 16px;
699
+ color: #888;
700
+ font-size: 12px;
701
+ }
702
+
703
+ .git-diff-error {
704
+ color: #ff6b6b;
705
+ font-size: 12px;
706
+ padding: 16px 12px;
707
+ text-align: center;
708
+ }
709
+
710
+ /* Unified diff 行 */
711
+ .diff-table {
712
+ width: 100%;
713
+ border-collapse: collapse;
714
+ font-family: Menlo, Monaco, 'Courier New', monospace;
715
+ font-size: 12px;
716
+ line-height: 1.5;
717
+ }
718
+
719
+ .diff-line {
720
+ border: none;
721
+ }
722
+
723
+ .diff-line-num {
724
+ width: 28px;
725
+ min-width: 28px;
726
+ padding: 0 3px;
727
+ text-align: right;
728
+ color: #555;
729
+ font-size: 11px;
730
+ user-select: none;
731
+ -webkit-user-select: none;
732
+ vertical-align: top;
733
+ }
734
+
735
+ .diff-line-content {
736
+ padding: 0 8px;
737
+ white-space: pre-wrap;
738
+ word-break: break-all;
739
+ color: #d4d4d4;
740
+ }
741
+
742
+ .diff-line-add {
743
+ background: rgba(35, 134, 54, 0.2);
744
+ }
745
+
746
+ .diff-line-add .diff-line-content {
747
+ color: #7ee787;
748
+ }
749
+
750
+ .diff-line-del {
751
+ background: rgba(248, 81, 73, 0.2);
752
+ }
753
+
754
+ .diff-line-del .diff-line-content {
755
+ color: #ffa198;
756
+ }
757
+
758
+ .diff-line-hunk {
759
+ background: rgba(56, 139, 253, 0.1);
760
+ }
761
+
762
+ .diff-line-hunk .diff-line-content {
763
+ color: #79c0ff;
764
+ font-style: italic;
765
+ }
766
+
767
+ .diff-file-count {
768
+ font-size: 10px;
769
+ color: #555;
770
+ background: #1a1a1a;
771
+ padding: 1px 6px;
772
+ border-radius: 8px;
773
+ }
774
+ </style>
775
+ </head>
776
+ <body>
777
+ <!-- 参考 cc-viewer 的 App.jsx 行 1315-1607: 完整的移动端布局结构 -->
778
+ <div id="layout">
779
+ <div id="header">
780
+ <div style="display: flex; gap: 8px; align-items: center;">
781
+ <button class="history-toggle-btn" id="new-session-btn" style="color:#73c991; border-color:#2a5a3a;">
782
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
783
+ <line x1="12" y1="5" x2="12" y2="19"></line>
784
+ <line x1="5" y1="12" x2="19" y2="12"></line>
785
+ </svg>
786
+ <span>新会话</span>
787
+ </button>
788
+ <button class="history-toggle-btn" id="history-toggle" style="color:#79c0ff; border-color:#2a4a7c;">
789
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
790
+ <circle cx="12" cy="12" r="10"></circle>
791
+ <polyline points="12 6 12 12 16 14"></polyline>
792
+ </svg>
793
+ <span>历史</span>
794
+ </button>
795
+ <button class="history-toggle-btn" id="diff-toggle" style="color:#e2c08d; border-color:#5a4a2a;">
796
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
797
+ <line x1="6" y1="3" x2="6" y2="15"></line>
798
+ <circle cx="18" cy="6" r="3"></circle>
799
+ <circle cx="6" cy="18" r="3"></circle>
800
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
801
+ </svg>
802
+ <span>Diff</span>
803
+ </button>
804
+ </div>
805
+ <div id="mode-switcher">
806
+ <span id="mode-label"></span>
807
+ <select id="mode-select">
808
+ <option value="opencode">OpenCode</option>
809
+ <option value="claude">Claude</option>
810
+ </select>
811
+ </div>
812
+ </div>
813
+
814
+ <!-- 会话历史栏 -->
815
+ <div id="session-history-bar">
816
+ <!-- 会话列表视图 -->
817
+ <div id="session-list-view">
818
+ <div id="session-history-header">
819
+ <div id="session-history-title">历史会话</div>
820
+ <div id="session-history-actions">
821
+ <button class="history-toggle-btn" id="refresh-sessions">
822
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
823
+ <polyline points="23 4 23 10 17 10"></polyline>
824
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
825
+ </svg>
826
+ <span>刷新</span>
827
+ </button>
828
+ <button class="history-toggle-btn" id="close-history">
829
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
830
+ <line x1="18" y1="6" x2="6" y2="18"></line>
831
+ <line x1="6" y1="6" x2="18" y2="18"></line>
832
+ </svg>
833
+ <span>返回</span>
834
+ </button>
835
+ </div>
836
+ </div>
837
+ <div id="session-list-container">
838
+ <div id="session-list">
839
+ <div class="session-loading">加载历史会话...</div>
840
+ </div>
841
+ </div>
842
+ </div>
843
+
844
+ <!-- 会话详情视图 -->
845
+ <div id="session-detail-view">
846
+ <div id="session-history-header">
847
+ <div id="session-history-title">会话详情</div>
848
+ <div id="session-history-actions">
849
+ <button class="history-toggle-btn" id="restore-session" style="background: #1a5a3a; border-color: #2a7a4a;">
850
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
851
+ <polyline points="1 4 1 10 7 10"></polyline>
852
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
853
+ </svg>
854
+ <span>恢复会话</span>
855
+ </button>
856
+ <button class="history-toggle-btn" id="back-to-list">
857
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
858
+ <line x1="19" y1="12" x2="5" y2="12"></line>
859
+ <polyline points="12 19 5 12 12 5"></polyline>
860
+ </svg>
861
+ <span>返回列表</span>
862
+ </button>
863
+ </div>
864
+ </div>
865
+ <div id="session-detail-header">
866
+ <div id="session-detail-title">会话标题</div>
867
+ <div id="session-detail-meta">
868
+ <span id="session-detail-time">时间</span>
869
+ <span id="session-detail-dir">目录</span>
870
+ </div>
871
+ </div>
872
+ <div id="session-detail-content">
873
+ <div class="message-empty">加载中...</div>
874
+ </div>
875
+ </div>
876
+ </div>
877
+
878
+ <!-- Git Diff 面板 -->
879
+ <div id="git-diff-bar">
880
+ <div id="git-diff-header">
881
+ <div style="display: flex; align-items: center; gap: 8px;">
882
+ <span id="git-diff-title">Git Changes</span>
883
+ <span class="diff-file-count" id="git-diff-count">0</span>
884
+ </div>
885
+ <div style="display: flex; gap: 8px;">
886
+ <button class="history-toggle-btn" id="refresh-diff">
887
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
888
+ <polyline points="23 4 23 10 17 10"></polyline>
889
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
890
+ </svg>
891
+ <span>刷新</span>
892
+ </button>
893
+ <button class="history-toggle-btn" id="close-diff">
894
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
895
+ <line x1="18" y1="6" x2="6" y2="18"></line>
896
+ <line x1="6" y1="6" x2="18" y2="18"></line>
897
+ </svg>
898
+ <span>返回</span>
899
+ </button>
900
+ </div>
901
+ </div>
902
+ <div class="git-diff-file-list" id="git-diff-file-list">
903
+ <div class="git-diff-loading">加载中...</div>
904
+ </div>
905
+ <div class="git-diff-content-area" id="git-diff-content-area">
906
+ <div class="git-diff-placeholder">
907
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">
908
+ <line x1="6" y1="3" x2="6" y2="15"></line>
909
+ <circle cx="18" cy="6" r="3"></circle>
910
+ <circle cx="6" cy="18" r="3"></circle>
911
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
912
+ </svg>
913
+ <span>点击文件查看 diff</span>
914
+ </div>
915
+ </div>
916
+ </div>
917
+
918
+ <div id="content">
919
+ <div id="terminal-container">
920
+ <div id="terminal">
921
+ <div id="select-text-layer">
922
+ <div id="select-hint">长按选择文本 · 点右上角 ✕ 返回终端</div>
923
+ <pre id="select-text-pre"></pre>
924
+ </div>
925
+ <button id="select-mode-close">✕</button>
926
+ </div>
927
+ </div>
928
+ </div>
929
+ </div>
930
+
931
+ <div id="copy-toast">已复制</div>
932
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
933
+ <script>
934
+ (function() {
935
+ var isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
936
+ var isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
937
+ var MOBILE_COLS = 60;
938
+ var fontSize = isMobile ? 11 : 13;
939
+ var currentMode = 'opencode';
940
+ var isTransitioning = false;
941
+ var isBufferReplay = true; // 初始缓冲区回放中,不弹 toast
942
+
943
+ var term = new Terminal({
944
+ cursorBlink: !isMobile,
945
+ fontSize: fontSize,
946
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
947
+ theme: {
948
+ background: '#0a0a0a',
949
+ foreground: '#d4d4d4',
950
+ cursor: '#d4d4d4',
951
+ selectionBackground: '#264f78',
952
+ },
953
+ allowProposedApi: true,
954
+ scrollback: isIOS ? 200 : isMobile ? 1000 : 3000,
955
+ smoothScrollDuration: 0,
956
+ scrollOnUserInput: true,
957
+ });
958
+
959
+ term.open(document.getElementById('terminal'));
960
+
961
+ // PC端复制:用 xterm.js selection API 获取纯文本,避免复制出乱码
962
+ document.getElementById('terminal').addEventListener('copy', function(e) {
963
+ var sel = term.getSelection();
964
+ if (sel) {
965
+ e.preventDefault();
966
+ e.clipboardData.setData('text/plain', sel);
967
+ }
968
+ });
969
+
970
+ // OSC 52 剪贴板支持:拦截应用发送的剪贴板设置请求
971
+ term.parser.registerOscHandler(52, function(data) {
972
+ var idx = data.indexOf(';');
973
+ if (idx === -1) return false;
974
+ var b64 = data.substring(idx + 1);
975
+ if (!b64 || b64 === '?') return false;
976
+ try {
977
+ var text = atob(b64);
978
+ if (navigator.clipboard && navigator.clipboard.writeText) {
979
+ navigator.clipboard.writeText(text).then(function() {
980
+ if (!isBufferReplay) showCopyToast();
981
+ });
982
+ } else {
983
+ var ta = document.createElement('textarea');
984
+ ta.value = text;
985
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
986
+ document.body.appendChild(ta);
987
+ ta.select();
988
+ document.execCommand('copy');
989
+ document.body.removeChild(ta);
990
+ if (!isBufferReplay) showCopyToast();
991
+ }
992
+ } catch (e) {}
993
+ return true;
994
+ });
995
+
996
+ var modeSelect = document.getElementById('mode-select');
997
+ var terminalEl = document.getElementById('terminal');
998
+ var ws = null;
999
+ var writeBuffer = '';
1000
+ var writeTimer = null;
1001
+ var lastY = 0;
1002
+ var lastTime = 0;
1003
+ var pixelAccum = 0;
1004
+ var pendingDy = 0;
1005
+ var scrollRaf = null;
1006
+ var momentumRaf = null;
1007
+ var velocitySamples = [];
1008
+
1009
+ // 未发送消息缓存
1010
+ var currentInputBuffer = '';
1011
+ var CACHE_KEY = 'claude_opencode_input_cache';
1012
+ var cacheRestored = false; // 防止重复恢复
1013
+
1014
+ // 会话历史相关
1015
+ var sessions = [];
1016
+ var currentSessionId = null;
1017
+ var currentSessionData = null;
1018
+ var historyBarVisible = false;
1019
+
1020
+ function saveInputCache() {
1021
+ if (currentInputBuffer) {
1022
+ localStorage.setItem(CACHE_KEY, currentInputBuffer);
1023
+ console.log('[cache] saved:', currentInputBuffer);
1024
+ } else {
1025
+ localStorage.removeItem(CACHE_KEY);
1026
+ }
1027
+ }
1028
+
1029
+ function loadInputCache() {
1030
+ if (cacheRestored) return;
1031
+ cacheRestored = true;
1032
+
1033
+ var cached = localStorage.getItem(CACHE_KEY);
1034
+ if (cached) {
1035
+ console.log('[cache] restoring buffer:', cached);
1036
+ // 只恢复跟踪变量,不重新发送到 pty
1037
+ // 因为 outputBuffer 回放已经包含了之前的回显
1038
+ currentInputBuffer = cached;
1039
+ }
1040
+ }
1041
+
1042
+ function clearInputCache() {
1043
+ currentInputBuffer = '';
1044
+ localStorage.removeItem(CACHE_KEY);
1045
+ console.log('[cache] cleared');
1046
+ }
1047
+
1048
+ function getCellDims() {
1049
+ return term._core && term._core._renderService && term._core._renderService.dimensions && term._core._renderService.dimensions.css && term._core._renderService.dimensions.css.cell;
1050
+ }
1051
+
1052
+ // PC端尺寸计算
1053
+ function resize() {
1054
+ var container = document.getElementById('terminal');
1055
+ var cellDims = getCellDims();
1056
+ if (!cellDims) return;
1057
+ var charW = cellDims.width;
1058
+ var charH = cellDims.height;
1059
+ var availW = container.clientWidth;
1060
+ var availH = container.clientHeight;
1061
+ var cols = Math.max(20, Math.floor(availW / charW));
1062
+ var rows = Math.max(10, Math.floor(availH / charH));
1063
+ term.resize(cols, rows);
1064
+ if (ws && ws.readyState === 1 && !isTransitioning) {
1065
+ ws.send(JSON.stringify({ type: 'resize', cols: cols, rows: rows }));
1066
+ }
1067
+ }
1068
+
1069
+ function flushWrite() {
1070
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1071
+ if (!writeBuffer || !term) return;
1072
+ var buf = writeBuffer;
1073
+ writeBuffer = '';
1074
+ term.write(buf);
1075
+ }
1076
+
1077
+ function throttledWrite(data) {
1078
+ writeBuffer += data;
1079
+ if (!writeTimer) {
1080
+ writeTimer = requestAnimationFrame(flushWrite);
1081
+ }
1082
+ }
1083
+
1084
+ function stopMomentum() {
1085
+ if (momentumRaf) { cancelAnimationFrame(momentumRaf); momentumRaf = null; }
1086
+ if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
1087
+ pendingDy = 0;
1088
+ pixelAccum = 0;
1089
+ }
1090
+
1091
+ // 触摸滚动实现 - 混合模式:alternate buffer 发鼠标滚轮,normal buffer 用 scrollLines
1092
+ var touchScreen = null;
1093
+ var touchEventsBound = false;
1094
+
1095
+ function isAlternateBuffer() {
1096
+ return term.buffer.active.type === 'alternate';
1097
+ }
1098
+
1099
+ function emitWheelEvent(lines) {
1100
+ var screen = terminalEl.querySelector('.xterm-screen');
1101
+ if (!screen) return;
1102
+ var lh = getLineHeight();
1103
+ var rect = screen.getBoundingClientRect();
1104
+ var cx = rect.left + rect.width / 2;
1105
+ var cy = rect.top + rect.height / 2;
1106
+ var count = Math.abs(lines);
1107
+ var dy = lines < 0 ? -lh : lh;
1108
+ for (var i = 0; i < count; i++) {
1109
+ screen.dispatchEvent(new WheelEvent('wheel', {
1110
+ deltaY: dy,
1111
+ deltaMode: 0,
1112
+ clientX: cx,
1113
+ clientY: cy,
1114
+ bubbles: true,
1115
+ cancelable: true,
1116
+ }));
1117
+ }
1118
+ }
1119
+
1120
+ var altScrollAccum = 0;
1121
+ var ALT_SCROLL_THRESHOLD = 2;
1122
+
1123
+ function doScroll(lines) {
1124
+ if (lines === 0) return;
1125
+ if (isAlternateBuffer()) {
1126
+ altScrollAccum += lines;
1127
+ if (Math.abs(altScrollAccum) >= ALT_SCROLL_THRESHOLD) {
1128
+ var scrollLines = Math.trunc(altScrollAccum);
1129
+ emitWheelEvent(scrollLines);
1130
+ altScrollAccum -= scrollLines;
1131
+ }
1132
+ } else {
1133
+ term.scrollLines(lines);
1134
+ }
1135
+ }
1136
+
1137
+ function getLineHeight() {
1138
+ var cellDims = getCellDims();
1139
+ var height = (cellDims && cellDims.height) || 15;
1140
+ return height;
1141
+ }
1142
+
1143
+ function stopMomentum() {
1144
+ if (momentumRaf) {
1145
+ cancelAnimationFrame(momentumRaf);
1146
+ momentumRaf = null;
1147
+ }
1148
+ if (scrollRaf) {
1149
+ cancelAnimationFrame(scrollRaf);
1150
+ scrollRaf = null;
1151
+ }
1152
+ pendingDy = 0;
1153
+ pixelAccum = 0;
1154
+ }
1155
+
1156
+ function flushScroll() {
1157
+ scrollRaf = null;
1158
+ if (pendingDy === 0) return;
1159
+
1160
+ pixelAccum += pendingDy;
1161
+ pendingDy = 0;
1162
+
1163
+ var lh = getLineHeight();
1164
+ var lines = Math.trunc(pixelAccum / lh);
1165
+
1166
+ if (lines !== 0) {
1167
+ doScroll(lines);
1168
+ pixelAccum -= lines * lh;
1169
+ }
1170
+ }
1171
+
1172
+ // 长按检测
1173
+ var longPressTimer = null;
1174
+ var longPressTriggered = false;
1175
+ var LONG_PRESS_DELAY = 550; // ms
1176
+
1177
+ function clearLongPress() {
1178
+ if (longPressTimer) {
1179
+ clearTimeout(longPressTimer);
1180
+ longPressTimer = null;
1181
+ }
1182
+ // 始终恢复 xterm textarea,防止 disabled 残留
1183
+ var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1184
+ if (xtermTa) xtermTa.removeAttribute('disabled');
1185
+ }
1186
+
1187
+ function handleTouchStart(e) {
1188
+ stopMomentum();
1189
+ altScrollAccum = 0;
1190
+ longPressTriggered = false;
1191
+ clearLongPress();
1192
+ if (e.touches.length !== 1) return;
1193
+ lastY = e.touches[0].clientY;
1194
+ lastTime = performance.now();
1195
+ velocitySamples = [];
1196
+
1197
+ // 启动长按计时器
1198
+ // 在长按检测期间阻止 xterm textarea 获取焦点,防止弹出键盘
1199
+ var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1200
+ if (xtermTa) {
1201
+ xtermTa.setAttribute('disabled', 'true');
1202
+ }
1203
+
1204
+ longPressTimer = setTimeout(function() {
1205
+ longPressTriggered = true;
1206
+ longPressTimer = null;
1207
+ openSelectMode();
1208
+ }, LONG_PRESS_DELAY);
1209
+ }
1210
+
1211
+ function handleTouchMove(e) {
1212
+ if (e.touches.length !== 1) return;
1213
+ // 有移动则取消长按
1214
+ clearLongPress();
1215
+ var y = e.touches[0].clientY;
1216
+ var now = performance.now();
1217
+ var dt = now - lastTime;
1218
+ var dy = lastY - y;
1219
+
1220
+ if (dt > 0) {
1221
+ var v = dy / dt * 16;
1222
+ velocitySamples.push({ v: v, t: now });
1223
+ while (velocitySamples.length > 0 && now - velocitySamples[0].t > 100) {
1224
+ velocitySamples.shift();
1225
+ }
1226
+ }
1227
+
1228
+ pendingDy += dy;
1229
+ console.log('[scroll] touchmove dy:', dy, 'pendingDy:', pendingDy);
1230
+
1231
+ if (!scrollRaf) scrollRaf = requestAnimationFrame(flushScroll);
1232
+ lastY = y;
1233
+ lastTime = now;
1234
+ }
1235
+
1236
+ function handleTouchEnd() {
1237
+ console.log('[scroll] touchend');
1238
+ clearLongPress();
1239
+
1240
+ // 长按已触发,不执行滚动惯性
1241
+ if (longPressTriggered) {
1242
+ longPressTriggered = false;
1243
+ pendingDy = 0;
1244
+ pixelAccum = 0;
1245
+ velocitySamples = [];
1246
+ return;
1247
+ }
1248
+
1249
+ if (scrollRaf) {
1250
+ cancelAnimationFrame(scrollRaf);
1251
+ scrollRaf = null;
1252
+ }
1253
+
1254
+ if (pendingDy !== 0) {
1255
+ pixelAccum += pendingDy;
1256
+ pendingDy = 0;
1257
+ var lh = getLineHeight();
1258
+ var lines = Math.trunc(pixelAccum / lh);
1259
+ if (lines !== 0) {
1260
+ doScroll(lines);
1261
+ }
1262
+ pixelAccum = 0;
1263
+ }
1264
+
1265
+ var velocity = 0;
1266
+ if (velocitySamples.length >= 2) {
1267
+ var totalWeight = 0;
1268
+ var weightedV = 0;
1269
+ var latest = velocitySamples[velocitySamples.length - 1].t;
1270
+ for (var i = 0; i < velocitySamples.length; i++) {
1271
+ var s = velocitySamples[i];
1272
+ var w = Math.max(0, 1 - (latest - s.t) / 100);
1273
+ weightedV += s.v * w;
1274
+ totalWeight += w;
1275
+ }
1276
+ velocity = totalWeight > 0 ? weightedV / totalWeight : 0;
1277
+ }
1278
+ velocitySamples = [];
1279
+
1280
+ console.log('[scroll] velocity:', velocity);
1281
+ if (Math.abs(velocity) < 0.5) return;
1282
+
1283
+ var friction = 0.95;
1284
+ var mAccum = 0;
1285
+ var tick = function() {
1286
+ if (Math.abs(velocity) < 0.3) {
1287
+ var lh = getLineHeight();
1288
+ var rest = Math.round(mAccum / lh);
1289
+ if (rest !== 0) {
1290
+ doScroll(rest);
1291
+ }
1292
+ momentumRaf = null;
1293
+ return;
1294
+ }
1295
+ mAccum += velocity;
1296
+ var lh = getLineHeight();
1297
+ var lines = Math.trunc(mAccum / lh);
1298
+ if (lines !== 0) {
1299
+ doScroll(lines);
1300
+ mAccum -= lines * lh;
1301
+ }
1302
+ velocity *= friction;
1303
+ momentumRaf = requestAnimationFrame(tick);
1304
+ };
1305
+ momentumRaf = requestAnimationFrame(tick);
1306
+ }
1307
+
1308
+ function unbindTouchScroll() {
1309
+ if (touchScreen && touchEventsBound) {
1310
+ console.log('[scroll] unbinding touch events');
1311
+ touchScreen.removeEventListener('touchstart', handleTouchStart);
1312
+ touchScreen.removeEventListener('touchmove', handleTouchMove);
1313
+ touchScreen.removeEventListener('touchend', handleTouchEnd);
1314
+ touchEventsBound = false;
1315
+ touchScreen = null;
1316
+ }
1317
+ }
1318
+
1319
+ function setupMobileTouchScroll() {
1320
+ // 先解绑旧的
1321
+ unbindTouchScroll();
1322
+
1323
+ var screen = terminalEl.querySelector('.xterm-screen');
1324
+ if (!screen) {
1325
+ console.log('[scroll] .xterm-screen not found, retrying...');
1326
+ setTimeout(setupMobileTouchScroll, 50);
1327
+ return;
1328
+ }
1329
+
1330
+ touchScreen = screen;
1331
+ screen.addEventListener('touchstart', handleTouchStart, { passive: true });
1332
+ screen.addEventListener('touchmove', handleTouchMove, { passive: true });
1333
+ screen.addEventListener('touchend', handleTouchEnd, { passive: true });
1334
+ touchEventsBound = true;
1335
+ console.log('[scroll] Touch events bound to .xterm-screen');
1336
+ }
1337
+
1338
+ function rebindTouchScroll() {
1339
+ if (isMobile) {
1340
+ setTimeout(setupMobileTouchScroll, 100);
1341
+ }
1342
+ }
1343
+
1344
+ if (isMobile) {
1345
+ setupMobileTouchScroll();
1346
+ }
1347
+
1348
+ function startTransition() {
1349
+ isTransitioning = true;
1350
+ terminalEl.classList.add('transitioning');
1351
+ }
1352
+
1353
+ function endTransition(mode) {
1354
+ currentMode = mode;
1355
+ modeSelect.value = mode;
1356
+ terminalEl.classList.remove('transitioning');
1357
+ isTransitioning = false;
1358
+ }
1359
+
1360
+ function switchMode(mode) {
1361
+ if (mode === currentMode || !ws || ws.readyState !== 1 || isTransitioning) return;
1362
+ startTransition();
1363
+ ws.send(JSON.stringify({ type: 'switch', mode: mode }));
1364
+ }
1365
+
1366
+ modeSelect.addEventListener('change', function() {
1367
+ switchMode(modeSelect.value);
1368
+ });
1369
+
1370
+ // 设置初始选中项
1371
+ modeSelect.value = currentMode;
1372
+
1373
+ // 只绑定一次 term.onData,避免重连时重复绑定
1374
+ term.onData(function(d) {
1375
+ if (ws && ws.readyState === 1) {
1376
+ ws.send(JSON.stringify({ type: 'input', data: d }));
1377
+
1378
+ // 缓存管理:跟踪当前行的输入(仅在 opencode 模式且未在恢复中)
1379
+ if (currentMode === 'opencode' && cacheRestored) {
1380
+ if (d === '\r' || d === '\n' || d === '\r\n') {
1381
+ // 回车:命令已发送,清除缓存
1382
+ clearInputCache();
1383
+ } else if (d === '\x7f' || d === '\b') {
1384
+ // 退格:删除最后一个字符
1385
+ if (currentInputBuffer.length > 0) {
1386
+ currentInputBuffer = currentInputBuffer.slice(0, -1);
1387
+ saveInputCache();
1388
+ }
1389
+ } else if (d === '\x03') {
1390
+ // Ctrl+C:中断,清除缓存
1391
+ clearInputCache();
1392
+ } else if (d === '\x15') {
1393
+ // Ctrl+U:清空整行
1394
+ clearInputCache();
1395
+ } else if (d.charCodeAt(0) >= 32 && d.charCodeAt(0) !== 127) {
1396
+ // 可打印字符(含中文等多字节):添加到缓冲区
1397
+ currentInputBuffer += d;
1398
+ saveInputCache();
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ // 点击终端输入时,滚动到底部
1404
+ setTimeout(function() {
1405
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
1406
+ }, 50);
1407
+ });
1408
+
1409
+ function connect() {
1410
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1411
+ ws = new WebSocket(proto + '//' + location.host + '/ws');
1412
+
1413
+ ws.onopen = function() {
1414
+ isBufferReplay = true;
1415
+ resize();
1416
+ rebindTouchScroll();
1417
+ setTimeout(function() { isBufferReplay = false; }, 500);
1418
+ };
1419
+
1420
+ ws.onclose = function() {
1421
+ ws = null;
1422
+ setTimeout(connect, 2000);
1423
+ };
1424
+
1425
+ ws.onmessage = function(e) {
1426
+ try {
1427
+ var msg = JSON.parse(e.data);
1428
+ if (msg.type === 'data') {
1429
+ if (!isCreatingNewSession) {
1430
+ throttledWrite(msg.data);
1431
+ }
1432
+ }
1433
+ else if (msg.type === 'exit') {
1434
+ if (!isCreatingNewSession) {
1435
+ throttledWrite('\r\n\x1b[33m[进程已退出: ' + msg.exitCode + ']\x1b[0m\r\n');
1436
+ throttledWrite('\x1b[90m按 Enter 键重新启动 ' + currentMode + '...\x1b[0m\r\n');
1437
+ }
1438
+ }
1439
+ else if (msg.type === 'mode') {
1440
+ endTransition(msg.mode);
1441
+ // 模式切换完成后,重新绑定触摸事件
1442
+ rebindTouchScroll();
1443
+ }
1444
+ else if (msg.type === 'switching') {
1445
+ // 服务端开始切换,前端清屏
1446
+ term.clear();
1447
+ writeBuffer = '';
1448
+ }
1449
+ else if (msg.type === 'state') {
1450
+ if (msg.mode) {
1451
+ currentMode = msg.mode;
1452
+ modeSelect.value = msg.mode;
1453
+ modeIndicator.textContent = msg.mode === 'claude' ? 'Claude' : 'OpenCode';
1454
+ }
1455
+ }
1456
+ else if (msg.type === 'restored') {
1457
+ // 会话恢复成功
1458
+ term.write('\x1b[32m✓ 会话已恢复: ' + msg.sessionId + '\x1b[0m\r\n');
1459
+ term.write('\x1b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n');
1460
+ term.write('\r\n');
1461
+ }
1462
+ else if (msg.type === 'restore-error') {
1463
+ // 恢复失败
1464
+ term.write('\x1b[31m✗ 恢复失败: ' + msg.error + '\x1b[0m\r\n');
1465
+ }
1466
+ else if (msg.type === 'started') {
1467
+ rebindTouchScroll();
1468
+ }
1469
+ else if (msg.type === 'new-session-ok') {
1470
+ isCreatingNewSession = false;
1471
+ term.clear();
1472
+ }
1473
+ else if (msg.type === 'new-session-error') {
1474
+ isCreatingNewSession = false;
1475
+ term.write('\x1b[31m✗ 新会话启动失败: ' + msg.error + '\x1b[0m\r\n');
1476
+ }
1477
+ } catch(err) {}
1478
+ };
1479
+
1480
+ // 页面加载后尝试恢复缓存的输入(仅在 opencode 模式)
1481
+ setTimeout(function() {
1482
+ if (currentMode === 'opencode') {
1483
+ loadInputCache();
1484
+ } else {
1485
+ cacheRestored = true;
1486
+ }
1487
+ }, 800);
1488
+ }
1489
+
1490
+ window.addEventListener('resize', resize);
1491
+ if (isMobile) {
1492
+ window.addEventListener('orientationchange', function() {
1493
+ setTimeout(resize, 200);
1494
+ });
1495
+ }
1496
+
1497
+ // 页面卸载前保存输入缓存
1498
+ window.addEventListener('beforeunload', function() {
1499
+ if (currentInputBuffer) {
1500
+ saveInputCache();
1501
+ }
1502
+ });
1503
+
1504
+ // 页面可见性变化时保存缓存
1505
+ document.addEventListener('visibilitychange', function() {
1506
+ if (document.hidden && currentInputBuffer) {
1507
+ saveInputCache();
1508
+ }
1509
+ });
1510
+
1511
+ // 虚拟按键映射表
1512
+ var KEY_MAP = {
1513
+ 'up': '\x1b[A',
1514
+ 'down': '\x1b[B',
1515
+ 'left': '\x1b[D',
1516
+ 'right': '\x1b[C',
1517
+ 'esc': '\x1b',
1518
+ 'ctrlc': '\x03',
1519
+ 'tab': '\t',
1520
+ 'enter': '\r'
1521
+ };
1522
+
1523
+ function sendKey(keyName) {
1524
+ var seq = KEY_MAP[keyName];
1525
+ if (seq && ws && ws.readyState === 1 && !isTransitioning) {
1526
+ ws.send(JSON.stringify({ type: 'input', data: seq }));
1527
+ }
1528
+ }
1529
+
1530
+ function scrollTerminal(lines) {
1531
+ if (!term) return;
1532
+ doScroll(lines);
1533
+ }
1534
+
1535
+
1536
+ // 会话历史功能
1537
+ function formatTime(timestamp) {
1538
+ var date = new Date(timestamp);
1539
+ var now = new Date();
1540
+ var diff = now - date;
1541
+ var minutes = Math.floor(diff / 60000);
1542
+ var hours = Math.floor(diff / 3600000);
1543
+ var days = Math.floor(diff / 86400000);
1544
+
1545
+ if (minutes < 1) return '刚刚';
1546
+ if (minutes < 60) return minutes + '分钟前';
1547
+ if (hours < 24) return hours + '小时前';
1548
+ if (days < 7) return days + '天前';
1549
+
1550
+ return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
1551
+ }
1552
+
1553
+ function loadSessions() {
1554
+ var sessionList = document.getElementById('session-list');
1555
+ sessionList.innerHTML = '<div class="session-loading">加载历史会话...</div>';
1556
+
1557
+ fetch('/api/sessions')
1558
+ .then(function(response) { return response.json(); })
1559
+ .then(function(data) {
1560
+ sessions = data;
1561
+ renderSessions();
1562
+ })
1563
+ .catch(function(err) {
1564
+ console.error('[sessions] 加载失败:', err);
1565
+ sessionList.innerHTML = '<div class="session-empty">无法加载历史会话</div>';
1566
+ });
1567
+ }
1568
+
1569
+ function renderSessions() {
1570
+ var sessionList = document.getElementById('session-list');
1571
+
1572
+ if (sessions.length === 0) {
1573
+ sessionList.innerHTML = '<div class="session-empty">暂无历史会话</div>';
1574
+ return;
1575
+ }
1576
+
1577
+ sessionList.innerHTML = '';
1578
+
1579
+ sessions.forEach(function(session) {
1580
+ var item = document.createElement('div');
1581
+ item.className = 'session-item';
1582
+ if (session.id === currentSessionId) {
1583
+ item.classList.add('active');
1584
+ }
1585
+
1586
+ var icon = document.createElement('div');
1587
+ icon.className = 'session-icon';
1588
+
1589
+ var info = document.createElement('div');
1590
+ info.className = 'session-info';
1591
+
1592
+ var title = document.createElement('div');
1593
+ title.className = 'session-title';
1594
+ // 使用预览文本,如果没有预览则使用标题
1595
+ title.textContent = session.preview || session.title;
1596
+
1597
+ var meta = document.createElement('div');
1598
+ meta.className = 'session-meta';
1599
+
1600
+ var time = document.createElement('span');
1601
+ time.className = 'session-time';
1602
+ time.textContent = formatTime(session.time_updated);
1603
+
1604
+ var dir = document.createElement('span');
1605
+ dir.className = 'session-dir';
1606
+ dir.textContent = session.directory.replace(/^\/Users\/[^\/]+/, '~');
1607
+
1608
+ meta.appendChild(time);
1609
+ meta.appendChild(dir);
1610
+ info.appendChild(title);
1611
+ info.appendChild(meta);
1612
+
1613
+ var deleteBtn = document.createElement('button');
1614
+ deleteBtn.className = 'session-delete-btn';
1615
+ deleteBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
1616
+ deleteBtn.title = '删除会话';
1617
+ deleteBtn.addEventListener('click', function(e) {
1618
+ e.stopPropagation();
1619
+ deleteSession(session.id, item);
1620
+ });
1621
+
1622
+ item.appendChild(icon);
1623
+ item.appendChild(info);
1624
+ item.appendChild(deleteBtn);
1625
+
1626
+ item.addEventListener('click', function() {
1627
+ loadSession(session);
1628
+ });
1629
+
1630
+ sessionList.appendChild(item);
1631
+ });
1632
+ }
1633
+
1634
+ function deleteSession(sessionId, itemEl) {
1635
+ if (!confirm('确定要删除这个会话吗?')) return;
1636
+
1637
+ itemEl.style.opacity = '0.4';
1638
+ itemEl.style.pointerEvents = 'none';
1639
+
1640
+ fetch('/api/session/' + sessionId, { method: 'DELETE' })
1641
+ .then(function(r) { return r.json(); })
1642
+ .then(function(data) {
1643
+ if (data.ok) {
1644
+ itemEl.style.transition = 'all 0.2s';
1645
+ itemEl.style.maxHeight = '0';
1646
+ itemEl.style.overflow = 'hidden';
1647
+ itemEl.style.padding = '0 12px';
1648
+ itemEl.style.margin = '0';
1649
+ itemEl.style.opacity = '0';
1650
+ setTimeout(function() {
1651
+ sessions = sessions.filter(function(s) { return s.id !== sessionId; });
1652
+ renderSessions();
1653
+ }, 200);
1654
+ } else {
1655
+ itemEl.style.opacity = '';
1656
+ itemEl.style.pointerEvents = '';
1657
+ alert('删除失败: ' + (data.error || '未知错误'));
1658
+ }
1659
+ })
1660
+ .catch(function(err) {
1661
+ itemEl.style.opacity = '';
1662
+ itemEl.style.pointerEvents = '';
1663
+ alert('删除失败: ' + err.message);
1664
+ });
1665
+ }
1666
+
1667
+ function showSessionDetail() {
1668
+ document.getElementById('session-list-view').classList.add('hidden');
1669
+ document.getElementById('session-detail-view').classList.add('visible');
1670
+ }
1671
+
1672
+ function showSessionList() {
1673
+ document.getElementById('session-list-view').classList.remove('hidden');
1674
+ document.getElementById('session-detail-view').classList.remove('visible');
1675
+ }
1676
+
1677
+ function loadSession(session) {
1678
+ console.log('[session] 加载会话:', session.title);
1679
+
1680
+ // 保存当前会话数据
1681
+ currentSessionData = session;
1682
+
1683
+ // 切换到详情视图
1684
+ showSessionDetail();
1685
+
1686
+ // 更新详情页标题和元信息
1687
+ document.getElementById('session-detail-title').textContent = session.preview || session.title;
1688
+ document.getElementById('session-detail-time').textContent = formatTime(session.time_updated);
1689
+ document.getElementById('session-detail-dir').textContent = session.directory.replace(/^\/Users\/[^\/]+/, '~');
1690
+
1691
+ var contentDiv = document.getElementById('session-detail-content');
1692
+ contentDiv.innerHTML = '<div class="message-empty">加载消息中...</div>';
1693
+
1694
+ // 获取会话的所有消息
1695
+ fetch('/api/session/' + session.id)
1696
+ .then(function(response) { return response.json(); })
1697
+ .then(function(messages) {
1698
+ console.log('[session] 收到', messages.length, '条消息');
1699
+
1700
+ if (messages.length === 0) {
1701
+ contentDiv.innerHTML = '<div class="message-empty">该会话暂无消息</div>';
1702
+ return;
1703
+ }
1704
+
1705
+ // 渲染消息列表
1706
+ var html = '';
1707
+ messages.forEach(function(msg) {
1708
+ console.log('[render] 消息:', msg);
1709
+
1710
+ var roleClass = msg.role === 'assistant' ? 'message-assistant' : 'message-user';
1711
+ var avatar = msg.role === 'user' ? '👤' : '🤖';
1712
+ var roleName = msg.role === 'user' ? 'User' : 'Assistant';
1713
+
1714
+ html += '<div class="message-item ' + roleClass + '">';
1715
+ html += '<div class="message-avatar">' + avatar + '</div>';
1716
+ html += '<div class="message-content">';
1717
+ html += '<div class="message-header">';
1718
+ html += '<div class="message-role">' + roleName + '</div>';
1719
+ html += '</div>';
1720
+
1721
+ // 显示推理过程(仅 assistant)
1722
+ if (msg.reasoning) {
1723
+ html += '<div class="message-text" style="background: #1a1a0a; border-color: #333;">';
1724
+ html += '<div style="color: #f0ad4e; font-size: 11px; font-weight: 600; margin-bottom: 6px;">💭 思考过程</div>';
1725
+ html += '<div style="color: #bbb;">' + escapeHtml(msg.reasoning) + '</div>';
1726
+ html += '</div>';
1727
+ }
1728
+
1729
+ // 显示文本内容
1730
+ if (msg.text) {
1731
+ html += '<div class="message-text">' + escapeHtml(msg.text) + '</div>';
1732
+ } else if (!msg.reasoning && !msg.toolCalls && !msg.toolResults) {
1733
+ html += '<div class="message-text" style="color: #666; font-style: italic;">(无文本内容)</div>';
1734
+ }
1735
+
1736
+ // 显示工具调用
1737
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
1738
+ msg.toolCalls.forEach(function(tool) {
1739
+ html += '<div class="message-tool-call">';
1740
+ html += '<div class="message-tool-name">🔧 工具调用: ' + escapeHtml(tool.name || '未知工具') + '</div>';
1741
+ html += '</div>';
1742
+ });
1743
+ }
1744
+
1745
+ // 显示工具结果
1746
+ if (msg.toolResults && msg.toolResults.length > 0) {
1747
+ msg.toolResults.forEach(function(result) {
1748
+ if (result.text) {
1749
+ var resultText = result.text.length > 200 ? result.text.substring(0, 200) + '...' : result.text;
1750
+ html += '<div class="message-tool-call">';
1751
+ html += '<div class="message-tool-name">📝 工具结果</div>';
1752
+ html += '<div class="message-tool-result">' + escapeHtml(resultText) + '</div>';
1753
+ html += '</div>';
1754
+ }
1755
+ });
1756
+ }
1757
+
1758
+ html += '</div>';
1759
+ html += '</div>';
1760
+ });
1761
+
1762
+ contentDiv.innerHTML = html;
1763
+
1764
+ // 滚动到底部
1765
+ contentDiv.scrollTop = contentDiv.scrollHeight;
1766
+ })
1767
+ .catch(function(err) {
1768
+ console.error('[session] 加载消息失败:', err);
1769
+ contentDiv.innerHTML = '<div class="message-empty">加载失败: ' + escapeHtml(err.message) + '</div>';
1770
+ });
1771
+ }
1772
+
1773
+ function escapeHtml(text) {
1774
+ var div = document.createElement('div');
1775
+ div.textContent = text;
1776
+ return div.innerHTML;
1777
+ }
1778
+
1779
+ function toggleHistoryBar() {
1780
+ historyBarVisible = !historyBarVisible;
1781
+ var historyBar = document.getElementById('session-history-bar');
1782
+
1783
+ if (historyBarVisible) {
1784
+ historyBar.classList.add('visible');
1785
+ if (currentMode === 'opencode') {
1786
+ loadSessions();
1787
+ } else {
1788
+ // claude 模式暂无历史会话功能
1789
+ var sessionList = document.getElementById('session-list');
1790
+ sessionList.innerHTML = '<div class="session-empty">Claude Code 暂不支持历史会话</div>';
1791
+ }
1792
+ } else {
1793
+ historyBar.classList.remove('visible');
1794
+ }
1795
+ }
1796
+
1797
+ // 新会话按钮
1798
+ var isCreatingNewSession = false;
1799
+ document.getElementById('new-session-btn').addEventListener('click', function() {
1800
+ if (!ws || ws.readyState !== 1) return;
1801
+ isCreatingNewSession = true;
1802
+ term.clear();
1803
+ ws.send(JSON.stringify({ type: 'new-session' }));
1804
+ });
1805
+
1806
+ // 绑定历史按钮
1807
+ document.getElementById('history-toggle').addEventListener('click', function() {
1808
+ toggleHistoryBar();
1809
+ });
1810
+
1811
+ // 刷新会话列表
1812
+ document.getElementById('refresh-sessions').addEventListener('click', function(e) {
1813
+ e.stopPropagation();
1814
+ loadSessions();
1815
+ });
1816
+
1817
+ // 关闭历史栏
1818
+ document.getElementById('close-history').addEventListener('click', function(e) {
1819
+ e.stopPropagation();
1820
+ toggleHistoryBar();
1821
+ });
1822
+
1823
+ // 返回到会话列表
1824
+ document.getElementById('back-to-list').addEventListener('click', function(e) {
1825
+ e.stopPropagation();
1826
+ showSessionList();
1827
+ });
1828
+
1829
+ // 恢复会话
1830
+ document.getElementById('restore-session').addEventListener('click', function(e) {
1831
+ e.stopPropagation();
1832
+ if (!currentSessionData) {
1833
+ console.error('[restore] 没有当前会话数据');
1834
+ return;
1835
+ }
1836
+
1837
+ console.log('[restore] 恢复会话:', currentSessionData.id);
1838
+
1839
+ // 关闭历史栏
1840
+ toggleHistoryBar();
1841
+
1842
+ // 清空终端
1843
+ term.clear();
1844
+
1845
+ // 显示正在恢复的提示
1846
+ term.write('\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m\r\n');
1847
+ term.write('\x1b[1;36m║ \x1b[1;37m正在恢复会话... \x1b[1;36m║\x1b[0m\r\n');
1848
+ term.write('\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m\r\n');
1849
+ term.write('\r\n');
1850
+
1851
+ // 发送恢复会话的请求到服务端
1852
+ if (ws && ws.readyState === 1) {
1853
+ if (currentMode !== 'opencode') {
1854
+ term.write('\x1b[31m错误: 请先切换到 OpenCode 模式\x1b[0m\r\n');
1855
+ return;
1856
+ }
1857
+
1858
+ term.write('\x1b[33m正在重启 OpenCode 并恢复会话: ' + currentSessionData.id + '\x1b[0m\r\n');
1859
+ term.write('\r\n');
1860
+
1861
+ // 发送恢复请求
1862
+ ws.send(JSON.stringify({
1863
+ type: 'restore',
1864
+ sessionId: currentSessionData.id
1865
+ }));
1866
+ } else {
1867
+ term.write('\x1b[31m错误: WebSocket 未连接\x1b[0m\r\n');
1868
+ }
1869
+ });
1870
+
1871
+ // 提取终端缓冲区文本
1872
+ function getTerminalText() {
1873
+ var buf = term.buffer.active;
1874
+ var lines = [];
1875
+ for (var i = 0; i < buf.length; i++) {
1876
+ var line = buf.getLine(i);
1877
+ if (line) lines.push(line.translateToString(true));
1878
+ }
1879
+ // 去除尾部空行
1880
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
1881
+ lines.pop();
1882
+ }
1883
+ return lines.join('\n');
1884
+ }
1885
+
1886
+ // 复制到剪贴板
1887
+ function copyToClipboard(text) {
1888
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1889
+ navigator.clipboard.writeText(text).then(showCopyToast).catch(function() {
1890
+ fallbackCopy(text);
1891
+ });
1892
+ } else {
1893
+ fallbackCopy(text);
1894
+ }
1895
+ }
1896
+
1897
+ function fallbackCopy(text) {
1898
+ var ta = document.createElement('textarea');
1899
+ ta.value = text;
1900
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
1901
+ document.body.appendChild(ta);
1902
+ ta.select();
1903
+ document.execCommand('copy');
1904
+ document.body.removeChild(ta);
1905
+ showCopyToast();
1906
+ }
1907
+
1908
+ function showCopyToast() {
1909
+ var toast = document.getElementById('copy-toast');
1910
+ toast.classList.add('show');
1911
+ setTimeout(function() { toast.classList.remove('show'); }, 1200);
1912
+ }
1913
+
1914
+
1915
+ // 方案2: 长按进入选择模式 — 原位显示可选纯文本
1916
+ var selectTextLayer = document.getElementById('select-text-layer');
1917
+ var selectTextPre = document.getElementById('select-text-pre');
1918
+ var selectModeClose = document.getElementById('select-mode-close');
1919
+ var inSelectMode = false;
1920
+
1921
+ function openSelectMode() {
1922
+ if (inSelectMode) return;
1923
+ inSelectMode = true;
1924
+ // 收起键盘
1925
+ var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
1926
+ if (xtermTa) xtermTa.blur();
1927
+ document.activeElement && document.activeElement.blur();
1928
+ var text = getTerminalText();
1929
+ selectTextPre.textContent = text || '(终端内容为空)';
1930
+ terminalEl.classList.add('select-mode');
1931
+ selectTextLayer.classList.add('visible');
1932
+ selectModeClose.style.display = 'block';
1933
+ unbindTouchScroll();
1934
+ }
1935
+
1936
+ function closeSelectMode() {
1937
+ if (!inSelectMode) return;
1938
+ inSelectMode = false;
1939
+ terminalEl.classList.remove('select-mode');
1940
+ selectTextLayer.classList.remove('visible');
1941
+ selectModeClose.style.display = 'none';
1942
+ window.getSelection().removeAllRanges();
1943
+ rebindTouchScroll();
1944
+ }
1945
+
1946
+ selectModeClose.addEventListener('click', function(e) {
1947
+ e.preventDefault();
1948
+ e.stopPropagation();
1949
+ closeSelectMode();
1950
+ });
1951
+
1952
+ selectModeClose.addEventListener('touchend', function(e) {
1953
+ e.preventDefault();
1954
+ e.stopPropagation();
1955
+ closeSelectMode();
1956
+ });
1957
+
1958
+ // ======= Git Diff 功能 =======
1959
+ var diffBarVisible = false;
1960
+ var diffChanges = [];
1961
+ var diffSelectedFile = null;
1962
+
1963
+ var STATUS_COLORS = {
1964
+ 'M': '#e2c08d', 'A': '#73c991', 'D': '#f14c4c',
1965
+ 'R': '#73c991', 'C': '#73c991', 'U': '#e2c08d',
1966
+ '?': '#73c991', '??': '#73c991',
1967
+ };
1968
+
1969
+ function toggleDiffBar() {
1970
+ diffBarVisible = !diffBarVisible;
1971
+ var bar = document.getElementById('git-diff-bar');
1972
+ if (diffBarVisible) {
1973
+ bar.classList.add('visible');
1974
+ loadGitStatus();
1975
+ } else {
1976
+ bar.classList.remove('visible');
1977
+ diffSelectedFile = null;
1978
+ }
1979
+ }
1980
+
1981
+ function loadGitStatus() {
1982
+ var fileList = document.getElementById('git-diff-file-list');
1983
+ fileList.innerHTML = '<div class="git-diff-loading">加载中...</div>';
1984
+ document.getElementById('git-diff-count').textContent = '0';
1985
+
1986
+ fetch('/api/git-status')
1987
+ .then(function(r) { return r.json(); })
1988
+ .then(function(data) {
1989
+ diffChanges = data.changes || [];
1990
+ document.getElementById('git-diff-count').textContent = diffChanges.length;
1991
+ renderDiffFileList();
1992
+ })
1993
+ .catch(function() {
1994
+ fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
1995
+ });
1996
+ }
1997
+
1998
+ function renderDiffFileList() {
1999
+ var fileList = document.getElementById('git-diff-file-list');
2000
+ if (diffChanges.length === 0) {
2001
+ fileList.innerHTML = '<div class="git-diff-loading" style="color:#666;">没有变更文件</div>';
2002
+ return;
2003
+ }
2004
+ var html = '';
2005
+ diffChanges.forEach(function(c) {
2006
+ var color = STATUS_COLORS[c.status] || '#888';
2007
+ var label = c.status === '??' ? 'U' : c.status;
2008
+ var activeClass = diffSelectedFile === c.file ? ' active' : '';
2009
+ html += '<div class="git-diff-file-item' + activeClass + '" data-file="' + escapeHtml(c.file) + '">';
2010
+ html += '<span class="git-diff-file-status" style="color:' + color + '">' + label + '</span>';
2011
+ html += '<span class="git-diff-file-name">' + escapeHtml(c.file) + '</span>';
2012
+ html += '</div>';
2013
+ });
2014
+ fileList.innerHTML = html;
2015
+
2016
+ // 绑定点击事件
2017
+ fileList.querySelectorAll('.git-diff-file-item').forEach(function(item) {
2018
+ item.addEventListener('click', function() {
2019
+ var file = item.getAttribute('data-file');
2020
+ diffSelectedFile = file;
2021
+ // 更新选中状态
2022
+ fileList.querySelectorAll('.git-diff-file-item').forEach(function(el) {
2023
+ el.classList.toggle('active', el.getAttribute('data-file') === file);
2024
+ });
2025
+ loadDiffContent(file);
2026
+ });
2027
+ });
2028
+ }
2029
+
2030
+ function loadDiffContent(file) {
2031
+ var area = document.getElementById('git-diff-content-area');
2032
+ area.innerHTML = '<div class="git-diff-loading">加载 diff...</div>';
2033
+
2034
+ fetch('/api/git-diff?files=' + encodeURIComponent(file))
2035
+ .then(function(r) { return r.json(); })
2036
+ .then(function(data) {
2037
+ if (!data.diffs || !data.diffs[0]) {
2038
+ area.innerHTML = '<div class="git-diff-error">无 diff 数据</div>';
2039
+ return;
2040
+ }
2041
+ var d = data.diffs[0];
2042
+ var html = '<div class="git-diff-content-header">';
2043
+ html += '<span class="git-diff-content-path">' + escapeHtml(d.file) + '</span>';
2044
+ html += '<span class="git-diff-badge">' + (d.is_new ? 'NEW' : d.is_deleted ? 'DEL' : 'DIFF') + '</span>';
2045
+ html += '</div>';
2046
+ html += '<div class="git-diff-content-scroll">';
2047
+
2048
+ if (d.is_binary) {
2049
+ html += '<div class="git-diff-loading" style="font-style:italic;">二进制文件</div>';
2050
+ } else if (d.is_large) {
2051
+ html += '<div class="git-diff-loading" style="color:#e2c08d;">文件过大: ' + (d.size / (1024 * 1024)).toFixed(2) + ' MB</div>';
2052
+ } else if (d.unified_diff) {
2053
+ html += renderUnifiedDiff(d.unified_diff);
2054
+ } else {
2055
+ html += '<div class="git-diff-loading" style="color:#666;">无变更内容</div>';
2056
+ }
2057
+
2058
+ html += '</div>';
2059
+ area.innerHTML = html;
2060
+ })
2061
+ .catch(function(err) {
2062
+ area.innerHTML = '<div class="git-diff-error">加载失败: ' + escapeHtml(err.message) + '</div>';
2063
+ });
2064
+ }
2065
+
2066
+ function renderUnifiedDiff(diffText) {
2067
+ var lines = diffText.split('\n');
2068
+ var html = '<table class="diff-table">';
2069
+ var oldLine = 0, newLine = 0;
2070
+
2071
+ for (var i = 0; i < lines.length; i++) {
2072
+ var line = lines[i];
2073
+
2074
+ // 跳过 diff 头部信息
2075
+ if (line.startsWith('diff --git') || line.startsWith('index ') ||
2076
+ line.startsWith('---') || line.startsWith('+++') ||
2077
+ line.startsWith('new file') || line.startsWith('deleted file') ||
2078
+ line.startsWith('old mode') || line.startsWith('new mode')) {
2079
+ continue;
2080
+ }
2081
+
2082
+ if (line.startsWith('@@')) {
2083
+ // 解析 hunk header: @@ -old,count +new,count @@
2084
+ var match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
2085
+ if (match) {
2086
+ oldLine = parseInt(match[1], 10);
2087
+ newLine = parseInt(match[2], 10);
2088
+ }
2089
+ html += '<tr class="diff-line diff-line-hunk">';
2090
+ html += '<td class="diff-line-num"></td><td class="diff-line-num"></td>';
2091
+ html += '<td class="diff-line-content">' + escapeHtml(line) + '</td></tr>';
2092
+ } else if (line.startsWith('+')) {
2093
+ html += '<tr class="diff-line diff-line-add">';
2094
+ html += '<td class="diff-line-num"></td>';
2095
+ html += '<td class="diff-line-num">' + newLine + '</td>';
2096
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2097
+ newLine++;
2098
+ } else if (line.startsWith('-')) {
2099
+ html += '<tr class="diff-line diff-line-del">';
2100
+ html += '<td class="diff-line-num">' + oldLine + '</td>';
2101
+ html += '<td class="diff-line-num"></td>';
2102
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2103
+ oldLine++;
2104
+ } else if (line.startsWith(' ') || (line === '' && i < lines.length - 1)) {
2105
+ html += '<tr class="diff-line">';
2106
+ html += '<td class="diff-line-num">' + oldLine + '</td>';
2107
+ html += '<td class="diff-line-num">' + newLine + '</td>';
2108
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1) || '') + '</td></tr>';
2109
+ oldLine++;
2110
+ newLine++;
2111
+ }
2112
+ }
2113
+
2114
+ html += '</table>';
2115
+ return html;
2116
+ }
2117
+
2118
+ // Diff 按钮事件绑定
2119
+ document.getElementById('diff-toggle').addEventListener('click', toggleDiffBar);
2120
+ document.getElementById('close-diff').addEventListener('click', toggleDiffBar);
2121
+ document.getElementById('refresh-diff').addEventListener('click', function(e) {
2122
+ e.stopPropagation();
2123
+ loadGitStatus();
2124
+ // 重置 diff 内容区
2125
+ diffSelectedFile = null;
2126
+ document.getElementById('git-diff-content-area').innerHTML =
2127
+ '<div class="git-diff-placeholder">' +
2128
+ '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">' +
2129
+ '<line x1="6" y1="3" x2="6" y2="15"></line>' +
2130
+ '<circle cx="18" cy="6" r="3"></circle>' +
2131
+ '<circle cx="6" cy="18" r="3"></circle>' +
2132
+ '<path d="M18 9a9 9 0 0 1-9 9"></path>' +
2133
+ '</svg><span>点击文件查看 diff</span></div>';
2134
+ });
2135
+
2136
+ connect();
2137
+ setTimeout(resize, 100);
2138
+ })();
2139
+ </script>
2140
+ </body>
2141
+ </html>