claude-opencode-viewer 2.4.2 → 2.6.0

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.
Files changed (3) hide show
  1. package/index.html +670 -4
  2. package/package.json +1 -1
  3. package/server.js +95 -3
package/index.html CHANGED
@@ -487,6 +487,306 @@
487
487
  border-color: #555;
488
488
  color: #fff;
489
489
  }
490
+
491
+ /* 选择模式:原位文本层 */
492
+ #terminal.select-mode .xterm-screen {
493
+ visibility: hidden;
494
+ }
495
+
496
+ #select-text-layer {
497
+ display: none;
498
+ position: absolute;
499
+ top: 0; left: 0; right: 0; bottom: 0;
500
+ overflow-y: auto;
501
+ -webkit-overflow-scrolling: touch;
502
+ background: #0a0a0a;
503
+ padding: 4px 8px;
504
+ touch-action: auto;
505
+ z-index: 10;
506
+ }
507
+
508
+ #select-text-layer.visible {
509
+ display: block;
510
+ }
511
+
512
+ #select-hint {
513
+ position: sticky;
514
+ top: 0;
515
+ background: rgba(30,30,30,0.95);
516
+ color: #888;
517
+ font-size: 11px;
518
+ text-align: center;
519
+ padding: 6px 0;
520
+ border-bottom: 1px solid #333;
521
+ z-index: 1;
522
+ -webkit-user-select: none;
523
+ user-select: none;
524
+ }
525
+
526
+ #select-text-layer pre {
527
+ margin: 0;
528
+ color: #d4d4d4;
529
+ font-family: Menlo, Monaco, "Courier New", monospace;
530
+ font-size: 11px;
531
+ line-height: 1.4;
532
+ white-space: pre-wrap;
533
+ word-break: break-all;
534
+ -webkit-user-select: text;
535
+ user-select: text;
536
+ }
537
+
538
+ #select-mode-close {
539
+ position: absolute;
540
+ top: 6px;
541
+ right: 6px;
542
+ z-index: 20;
543
+ display: none;
544
+ background: rgba(50,50,50,0.9);
545
+ border: 1px solid #555;
546
+ color: #ccc;
547
+ width: 28px;
548
+ height: 28px;
549
+ border-radius: 50%;
550
+ font-size: 14px;
551
+ line-height: 26px;
552
+ text-align: center;
553
+ cursor: pointer;
554
+ -webkit-user-select: none;
555
+ user-select: none;
556
+ }
557
+
558
+ /* 复制成功提示 */
559
+ #copy-toast {
560
+ display: none;
561
+ position: fixed;
562
+ top: 50%;
563
+ left: 50%;
564
+ transform: translate(-50%, -50%);
565
+ background: rgba(40, 167, 69, 0.9);
566
+ color: #fff;
567
+ padding: 10px 24px;
568
+ border-radius: 8px;
569
+ font-size: 14px;
570
+ z-index: 9999;
571
+ pointer-events: none;
572
+ }
573
+
574
+ #copy-toast.show {
575
+ display: block;
576
+ }
577
+
578
+ /* Git Diff 面板 */
579
+ #git-diff-bar {
580
+ display: none;
581
+ position: absolute;
582
+ top: 0;
583
+ left: 0;
584
+ right: 0;
585
+ bottom: 0;
586
+ background: #0a0a0a;
587
+ z-index: 1000;
588
+ flex-direction: column;
589
+ }
590
+
591
+ #git-diff-bar.visible {
592
+ display: flex;
593
+ }
594
+
595
+ #git-diff-header {
596
+ display: flex;
597
+ align-items: center;
598
+ justify-content: space-between;
599
+ padding: 12px 16px;
600
+ background: #111;
601
+ border-bottom: 1px solid #222;
602
+ flex-shrink: 0;
603
+ }
604
+
605
+ #git-diff-title {
606
+ font-size: 14px;
607
+ color: #ddd;
608
+ font-weight: 600;
609
+ }
610
+
611
+ .git-diff-file-list {
612
+ height: 250px;
613
+ flex-shrink: 0;
614
+ overflow-y: auto;
615
+ border-bottom: 1px solid #2a2a2a;
616
+ -webkit-overflow-scrolling: touch;
617
+ }
618
+
619
+ .git-diff-file-item {
620
+ display: flex;
621
+ align-items: center;
622
+ padding: 6px 12px;
623
+ cursor: pointer;
624
+ color: #ccc;
625
+ font-size: 13px;
626
+ gap: 8px;
627
+ white-space: nowrap;
628
+ }
629
+
630
+ .git-diff-file-item:hover {
631
+ background: #1a1a1a;
632
+ }
633
+
634
+ .git-diff-file-item.active {
635
+ background: rgba(74, 158, 255, 0.12);
636
+ color: #fff;
637
+ }
638
+
639
+ .git-diff-file-status {
640
+ width: 18px;
641
+ flex-shrink: 0;
642
+ font-size: 11px;
643
+ font-weight: 700;
644
+ text-align: center;
645
+ }
646
+
647
+ .git-diff-file-name {
648
+ overflow: hidden;
649
+ text-overflow: ellipsis;
650
+ flex: 1;
651
+ font-family: Menlo, Monaco, monospace;
652
+ font-size: 12px;
653
+ }
654
+
655
+ .git-diff-content-area {
656
+ flex: 1;
657
+ display: flex;
658
+ flex-direction: column;
659
+ min-height: 0;
660
+ overflow: hidden;
661
+ }
662
+
663
+ .git-diff-content-header {
664
+ display: flex;
665
+ align-items: center;
666
+ gap: 10px;
667
+ padding: 8px 12px;
668
+ border-bottom: 1px solid #2a2a2a;
669
+ background: #111;
670
+ flex-shrink: 0;
671
+ }
672
+
673
+ .git-diff-content-path {
674
+ font-size: 12px;
675
+ color: #ccc;
676
+ font-family: Menlo, Monaco, monospace;
677
+ overflow: hidden;
678
+ text-overflow: ellipsis;
679
+ white-space: nowrap;
680
+ flex: 1;
681
+ }
682
+
683
+ .git-diff-badge {
684
+ padding: 2px 8px;
685
+ background: #2a2a2a;
686
+ border: 1px solid #444;
687
+ border-radius: 4px;
688
+ font-size: 10px;
689
+ font-weight: 600;
690
+ color: #e2c08d;
691
+ letter-spacing: 0.5px;
692
+ flex-shrink: 0;
693
+ }
694
+
695
+ .git-diff-content-scroll {
696
+ flex: 1;
697
+ overflow: auto;
698
+ -webkit-overflow-scrolling: touch;
699
+ }
700
+
701
+ .git-diff-placeholder {
702
+ flex: 1;
703
+ display: flex;
704
+ flex-direction: column;
705
+ align-items: center;
706
+ justify-content: center;
707
+ gap: 12px;
708
+ color: #333;
709
+ font-size: 13px;
710
+ }
711
+
712
+ .git-diff-loading {
713
+ text-align: center;
714
+ padding: 16px;
715
+ color: #888;
716
+ font-size: 12px;
717
+ }
718
+
719
+ .git-diff-error {
720
+ color: #ff6b6b;
721
+ font-size: 12px;
722
+ padding: 16px 12px;
723
+ text-align: center;
724
+ }
725
+
726
+ /* Unified diff 行 */
727
+ .diff-table {
728
+ width: 100%;
729
+ border-collapse: collapse;
730
+ font-family: Menlo, Monaco, 'Courier New', monospace;
731
+ font-size: 12px;
732
+ line-height: 1.5;
733
+ }
734
+
735
+ .diff-line {
736
+ border: none;
737
+ }
738
+
739
+ .diff-line-num {
740
+ width: 28px;
741
+ min-width: 28px;
742
+ padding: 0 3px;
743
+ text-align: right;
744
+ color: #555;
745
+ font-size: 11px;
746
+ user-select: none;
747
+ -webkit-user-select: none;
748
+ vertical-align: top;
749
+ }
750
+
751
+ .diff-line-content {
752
+ padding: 0 8px;
753
+ white-space: pre-wrap;
754
+ word-break: break-all;
755
+ color: #d4d4d4;
756
+ }
757
+
758
+ .diff-line-add {
759
+ background: rgba(35, 134, 54, 0.2);
760
+ }
761
+
762
+ .diff-line-add .diff-line-content {
763
+ color: #7ee787;
764
+ }
765
+
766
+ .diff-line-del {
767
+ background: rgba(248, 81, 73, 0.2);
768
+ }
769
+
770
+ .diff-line-del .diff-line-content {
771
+ color: #ffa198;
772
+ }
773
+
774
+ .diff-line-hunk {
775
+ background: rgba(56, 139, 253, 0.1);
776
+ }
777
+
778
+ .diff-line-hunk .diff-line-content {
779
+ color: #79c0ff;
780
+ font-style: italic;
781
+ }
782
+
783
+ .diff-file-count {
784
+ font-size: 10px;
785
+ color: #555;
786
+ background: #1a1a1a;
787
+ padding: 1px 6px;
788
+ border-radius: 8px;
789
+ }
490
790
  </style>
491
791
  </head>
492
792
  <body>
@@ -494,23 +794,32 @@
494
794
  <div id="layout">
495
795
  <div id="header">
496
796
  <div style="display: flex; gap: 8px; align-items: center;">
497
- <button class="history-toggle-btn" id="new-session-btn">
797
+ <button class="history-toggle-btn" id="new-session-btn" style="color:#73c991; border-color:#2a5a3a;">
498
798
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
499
799
  <line x1="12" y1="5" x2="12" y2="19"></line>
500
800
  <line x1="5" y1="12" x2="19" y2="12"></line>
501
801
  </svg>
502
802
  <span>新会话</span>
503
803
  </button>
504
- <button class="history-toggle-btn" id="history-toggle">
804
+ <button class="history-toggle-btn" id="history-toggle" style="color:#79c0ff; border-color:#2a4a7c;">
505
805
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
506
806
  <circle cx="12" cy="12" r="10"></circle>
507
807
  <polyline points="12 6 12 12 16 14"></polyline>
508
808
  </svg>
509
809
  <span>历史</span>
510
810
  </button>
811
+ <button class="history-toggle-btn" id="diff-toggle" style="color:#e2c08d; border-color:#5a4a2a;">
812
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
813
+ <line x1="6" y1="3" x2="6" y2="15"></line>
814
+ <circle cx="18" cy="6" r="3"></circle>
815
+ <circle cx="6" cy="18" r="3"></circle>
816
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
817
+ </svg>
818
+ <span>Diff</span>
819
+ </button>
511
820
  </div>
512
821
  <div id="mode-switcher">
513
- <span id="mode-label">Mode:</span>
822
+ <span id="mode-label"></span>
514
823
  <select id="mode-select">
515
824
  <option value="opencode">OpenCode</option>
516
825
  <option value="claude">Claude</option>
@@ -582,9 +891,55 @@
582
891
  </div>
583
892
  </div>
584
893
 
894
+ <!-- Git Diff 面板 -->
895
+ <div id="git-diff-bar">
896
+ <div id="git-diff-header">
897
+ <div style="display: flex; align-items: center; gap: 8px;">
898
+ <span id="git-diff-title">Git Changes</span>
899
+ <span class="diff-file-count" id="git-diff-count">0</span>
900
+ </div>
901
+ <div style="display: flex; gap: 8px;">
902
+ <button class="history-toggle-btn" id="refresh-diff">
903
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
904
+ <polyline points="23 4 23 10 17 10"></polyline>
905
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
906
+ </svg>
907
+ <span>刷新</span>
908
+ </button>
909
+ <button class="history-toggle-btn" id="close-diff">
910
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
911
+ <line x1="18" y1="6" x2="6" y2="18"></line>
912
+ <line x1="6" y1="6" x2="18" y2="18"></line>
913
+ </svg>
914
+ <span>返回</span>
915
+ </button>
916
+ </div>
917
+ </div>
918
+ <div class="git-diff-file-list" id="git-diff-file-list">
919
+ <div class="git-diff-loading">加载中...</div>
920
+ </div>
921
+ <div class="git-diff-content-area" id="git-diff-content-area">
922
+ <div class="git-diff-placeholder">
923
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">
924
+ <line x1="6" y1="3" x2="6" y2="15"></line>
925
+ <circle cx="18" cy="6" r="3"></circle>
926
+ <circle cx="6" cy="18" r="3"></circle>
927
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
928
+ </svg>
929
+ <span>点击文件查看 diff</span>
930
+ </div>
931
+ </div>
932
+ </div>
933
+
585
934
  <div id="content">
586
935
  <div id="terminal-container">
587
- <div id="terminal"></div>
936
+ <div id="terminal">
937
+ <div id="select-text-layer">
938
+ <div id="select-hint">长按选择文本 · 点右上角 ✕ 返回终端</div>
939
+ <pre id="select-text-pre"></pre>
940
+ </div>
941
+ <button id="select-mode-close">✕</button>
942
+ </div>
588
943
  <div id="virtual-keybar">
589
944
  <div class="virtual-key" data-key="up">↑</div>
590
945
  <div class="virtual-key" data-key="down">↓</div>
@@ -596,11 +951,13 @@
596
951
  <div class="virtual-key" data-key="ctrlc">Ctrl+C</div>
597
952
  <div class="virtual-key scroll-key" data-scroll="-5">⇡ 滚动</div>
598
953
  <div class="virtual-key scroll-key" data-scroll="5">⇣ 滚动</div>
954
+ <div class="virtual-key" id="btn-copy">复制</div>
599
955
  </div>
600
956
  </div>
601
957
  </div>
602
958
  </div>
603
959
 
960
+ <div id="copy-toast">已复制</div>
604
961
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
605
962
  <script>
606
963
  (function() {
@@ -837,17 +1194,40 @@
837
1194
  }
838
1195
  }
839
1196
 
1197
+ // 长按检测
1198
+ var longPressTimer = null;
1199
+ var longPressTriggered = false;
1200
+ var LONG_PRESS_DELAY = 500; // ms
1201
+
1202
+ function clearLongPress() {
1203
+ if (longPressTimer) {
1204
+ clearTimeout(longPressTimer);
1205
+ longPressTimer = null;
1206
+ }
1207
+ }
1208
+
840
1209
  function handleTouchStart(e) {
841
1210
  console.log('[scroll] touchstart');
842
1211
  stopMomentum();
1212
+ longPressTriggered = false;
1213
+ clearLongPress();
843
1214
  if (e.touches.length !== 1) return;
844
1215
  lastY = e.touches[0].clientY;
845
1216
  lastTime = performance.now();
846
1217
  velocitySamples = [];
1218
+
1219
+ // 启动长按计时器
1220
+ longPressTimer = setTimeout(function() {
1221
+ longPressTriggered = true;
1222
+ longPressTimer = null;
1223
+ openSelectMode();
1224
+ }, LONG_PRESS_DELAY);
847
1225
  }
848
1226
 
849
1227
  function handleTouchMove(e) {
850
1228
  if (e.touches.length !== 1) return;
1229
+ // 有移动则取消长按
1230
+ clearLongPress();
851
1231
  var y = e.touches[0].clientY;
852
1232
  var now = performance.now();
853
1233
  var dt = now - lastTime;
@@ -871,6 +1251,16 @@
871
1251
 
872
1252
  function handleTouchEnd() {
873
1253
  console.log('[scroll] touchend');
1254
+ clearLongPress();
1255
+
1256
+ // 长按已触发,不执行滚动惯性
1257
+ if (longPressTriggered) {
1258
+ longPressTriggered = false;
1259
+ pendingDy = 0;
1260
+ pixelAccum = 0;
1261
+ velocitySamples = [];
1262
+ return;
1263
+ }
874
1264
 
875
1265
  if (scrollRaf) {
876
1266
  cancelAnimationFrame(scrollRaf);
@@ -1563,6 +1953,282 @@
1563
1953
  }
1564
1954
  });
1565
1955
 
1956
+ // 提取终端缓冲区文本
1957
+ function getTerminalText() {
1958
+ var buf = term.buffer.active;
1959
+ var lines = [];
1960
+ for (var i = 0; i < buf.length; i++) {
1961
+ var line = buf.getLine(i);
1962
+ if (line) lines.push(line.translateToString(true));
1963
+ }
1964
+ // 去除尾部空行
1965
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
1966
+ lines.pop();
1967
+ }
1968
+ return lines.join('\n');
1969
+ }
1970
+
1971
+ // 复制到剪贴板
1972
+ function copyToClipboard(text) {
1973
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1974
+ navigator.clipboard.writeText(text).then(showCopyToast).catch(function() {
1975
+ fallbackCopy(text);
1976
+ });
1977
+ } else {
1978
+ fallbackCopy(text);
1979
+ }
1980
+ }
1981
+
1982
+ function fallbackCopy(text) {
1983
+ var ta = document.createElement('textarea');
1984
+ ta.value = text;
1985
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
1986
+ document.body.appendChild(ta);
1987
+ ta.select();
1988
+ document.execCommand('copy');
1989
+ document.body.removeChild(ta);
1990
+ showCopyToast();
1991
+ }
1992
+
1993
+ function showCopyToast() {
1994
+ var toast = document.getElementById('copy-toast');
1995
+ toast.classList.add('show');
1996
+ setTimeout(function() { toast.classList.remove('show'); }, 1200);
1997
+ }
1998
+
1999
+ // "复制" 按钮
2000
+ document.getElementById('btn-copy').addEventListener('touchend', function(e) {
2001
+ e.preventDefault();
2002
+ var text = getTerminalText();
2003
+ if (text) copyToClipboard(text);
2004
+ });
2005
+ document.getElementById('btn-copy').addEventListener('click', function(e) {
2006
+ e.preventDefault();
2007
+ var text = getTerminalText();
2008
+ if (text) copyToClipboard(text);
2009
+ });
2010
+
2011
+ // 方案2: 长按进入选择模式 — 原位显示可选纯文本
2012
+ var selectTextLayer = document.getElementById('select-text-layer');
2013
+ var selectTextPre = document.getElementById('select-text-pre');
2014
+ var selectModeClose = document.getElementById('select-mode-close');
2015
+ var inSelectMode = false;
2016
+
2017
+ function openSelectMode() {
2018
+ if (inSelectMode) return;
2019
+ inSelectMode = true;
2020
+ // 收起键盘
2021
+ var xtermTa = terminalEl.querySelector('.xterm-helper-textarea');
2022
+ if (xtermTa) xtermTa.blur();
2023
+ document.activeElement && document.activeElement.blur();
2024
+ var text = getTerminalText();
2025
+ selectTextPre.textContent = text || '(终端内容为空)';
2026
+ terminalEl.classList.add('select-mode');
2027
+ selectTextLayer.classList.add('visible');
2028
+ selectModeClose.style.display = 'block';
2029
+ unbindTouchScroll();
2030
+ }
2031
+
2032
+ function closeSelectMode() {
2033
+ if (!inSelectMode) return;
2034
+ inSelectMode = false;
2035
+ terminalEl.classList.remove('select-mode');
2036
+ selectTextLayer.classList.remove('visible');
2037
+ selectModeClose.style.display = 'none';
2038
+ window.getSelection().removeAllRanges();
2039
+ rebindTouchScroll();
2040
+ }
2041
+
2042
+ selectModeClose.addEventListener('click', function(e) {
2043
+ e.preventDefault();
2044
+ e.stopPropagation();
2045
+ closeSelectMode();
2046
+ });
2047
+
2048
+ selectModeClose.addEventListener('touchend', function(e) {
2049
+ e.preventDefault();
2050
+ e.stopPropagation();
2051
+ closeSelectMode();
2052
+ });
2053
+
2054
+ // ======= Git Diff 功能 =======
2055
+ var diffBarVisible = false;
2056
+ var diffChanges = [];
2057
+ var diffSelectedFile = null;
2058
+
2059
+ var STATUS_COLORS = {
2060
+ 'M': '#e2c08d', 'A': '#73c991', 'D': '#f14c4c',
2061
+ 'R': '#73c991', 'C': '#73c991', 'U': '#e2c08d',
2062
+ '?': '#73c991', '??': '#73c991',
2063
+ };
2064
+
2065
+ function toggleDiffBar() {
2066
+ diffBarVisible = !diffBarVisible;
2067
+ var bar = document.getElementById('git-diff-bar');
2068
+ if (diffBarVisible) {
2069
+ bar.classList.add('visible');
2070
+ loadGitStatus();
2071
+ } else {
2072
+ bar.classList.remove('visible');
2073
+ diffSelectedFile = null;
2074
+ }
2075
+ }
2076
+
2077
+ function loadGitStatus() {
2078
+ var fileList = document.getElementById('git-diff-file-list');
2079
+ fileList.innerHTML = '<div class="git-diff-loading">加载中...</div>';
2080
+ document.getElementById('git-diff-count').textContent = '0';
2081
+
2082
+ fetch('/api/git-status')
2083
+ .then(function(r) { return r.json(); })
2084
+ .then(function(data) {
2085
+ diffChanges = data.changes || [];
2086
+ document.getElementById('git-diff-count').textContent = diffChanges.length;
2087
+ renderDiffFileList();
2088
+ })
2089
+ .catch(function() {
2090
+ fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2091
+ });
2092
+ }
2093
+
2094
+ function renderDiffFileList() {
2095
+ var fileList = document.getElementById('git-diff-file-list');
2096
+ if (diffChanges.length === 0) {
2097
+ fileList.innerHTML = '<div class="git-diff-loading" style="color:#666;">没有变更文件</div>';
2098
+ return;
2099
+ }
2100
+ var html = '';
2101
+ diffChanges.forEach(function(c) {
2102
+ var color = STATUS_COLORS[c.status] || '#888';
2103
+ var label = c.status === '??' ? 'U' : c.status;
2104
+ var activeClass = diffSelectedFile === c.file ? ' active' : '';
2105
+ html += '<div class="git-diff-file-item' + activeClass + '" data-file="' + escapeHtml(c.file) + '">';
2106
+ html += '<span class="git-diff-file-status" style="color:' + color + '">' + label + '</span>';
2107
+ html += '<span class="git-diff-file-name">' + escapeHtml(c.file) + '</span>';
2108
+ html += '</div>';
2109
+ });
2110
+ fileList.innerHTML = html;
2111
+
2112
+ // 绑定点击事件
2113
+ fileList.querySelectorAll('.git-diff-file-item').forEach(function(item) {
2114
+ item.addEventListener('click', function() {
2115
+ var file = item.getAttribute('data-file');
2116
+ diffSelectedFile = file;
2117
+ // 更新选中状态
2118
+ fileList.querySelectorAll('.git-diff-file-item').forEach(function(el) {
2119
+ el.classList.toggle('active', el.getAttribute('data-file') === file);
2120
+ });
2121
+ loadDiffContent(file);
2122
+ });
2123
+ });
2124
+ }
2125
+
2126
+ function loadDiffContent(file) {
2127
+ var area = document.getElementById('git-diff-content-area');
2128
+ area.innerHTML = '<div class="git-diff-loading">加载 diff...</div>';
2129
+
2130
+ fetch('/api/git-diff?files=' + encodeURIComponent(file))
2131
+ .then(function(r) { return r.json(); })
2132
+ .then(function(data) {
2133
+ if (!data.diffs || !data.diffs[0]) {
2134
+ area.innerHTML = '<div class="git-diff-error">无 diff 数据</div>';
2135
+ return;
2136
+ }
2137
+ var d = data.diffs[0];
2138
+ var html = '<div class="git-diff-content-header">';
2139
+ html += '<span class="git-diff-content-path">' + escapeHtml(d.file) + '</span>';
2140
+ html += '<span class="git-diff-badge">' + (d.is_new ? 'NEW' : d.is_deleted ? 'DEL' : 'DIFF') + '</span>';
2141
+ html += '</div>';
2142
+ html += '<div class="git-diff-content-scroll">';
2143
+
2144
+ if (d.is_binary) {
2145
+ html += '<div class="git-diff-loading" style="font-style:italic;">二进制文件</div>';
2146
+ } else if (d.is_large) {
2147
+ html += '<div class="git-diff-loading" style="color:#e2c08d;">文件过大: ' + (d.size / (1024 * 1024)).toFixed(2) + ' MB</div>';
2148
+ } else if (d.unified_diff) {
2149
+ html += renderUnifiedDiff(d.unified_diff);
2150
+ } else {
2151
+ html += '<div class="git-diff-loading" style="color:#666;">无变更内容</div>';
2152
+ }
2153
+
2154
+ html += '</div>';
2155
+ area.innerHTML = html;
2156
+ })
2157
+ .catch(function(err) {
2158
+ area.innerHTML = '<div class="git-diff-error">加载失败: ' + escapeHtml(err.message) + '</div>';
2159
+ });
2160
+ }
2161
+
2162
+ function renderUnifiedDiff(diffText) {
2163
+ var lines = diffText.split('\n');
2164
+ var html = '<table class="diff-table">';
2165
+ var oldLine = 0, newLine = 0;
2166
+
2167
+ for (var i = 0; i < lines.length; i++) {
2168
+ var line = lines[i];
2169
+
2170
+ // 跳过 diff 头部信息
2171
+ if (line.startsWith('diff --git') || line.startsWith('index ') ||
2172
+ line.startsWith('---') || line.startsWith('+++') ||
2173
+ line.startsWith('new file') || line.startsWith('deleted file') ||
2174
+ line.startsWith('old mode') || line.startsWith('new mode')) {
2175
+ continue;
2176
+ }
2177
+
2178
+ if (line.startsWith('@@')) {
2179
+ // 解析 hunk header: @@ -old,count +new,count @@
2180
+ var match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
2181
+ if (match) {
2182
+ oldLine = parseInt(match[1], 10);
2183
+ newLine = parseInt(match[2], 10);
2184
+ }
2185
+ html += '<tr class="diff-line diff-line-hunk">';
2186
+ html += '<td class="diff-line-num"></td><td class="diff-line-num"></td>';
2187
+ html += '<td class="diff-line-content">' + escapeHtml(line) + '</td></tr>';
2188
+ } else if (line.startsWith('+')) {
2189
+ html += '<tr class="diff-line diff-line-add">';
2190
+ html += '<td class="diff-line-num"></td>';
2191
+ html += '<td class="diff-line-num">' + newLine + '</td>';
2192
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2193
+ newLine++;
2194
+ } else if (line.startsWith('-')) {
2195
+ html += '<tr class="diff-line diff-line-del">';
2196
+ html += '<td class="diff-line-num">' + oldLine + '</td>';
2197
+ html += '<td class="diff-line-num"></td>';
2198
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2199
+ oldLine++;
2200
+ } else if (line.startsWith(' ') || (line === '' && i < lines.length - 1)) {
2201
+ html += '<tr class="diff-line">';
2202
+ html += '<td class="diff-line-num">' + oldLine + '</td>';
2203
+ html += '<td class="diff-line-num">' + newLine + '</td>';
2204
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1) || '') + '</td></tr>';
2205
+ oldLine++;
2206
+ newLine++;
2207
+ }
2208
+ }
2209
+
2210
+ html += '</table>';
2211
+ return html;
2212
+ }
2213
+
2214
+ // Diff 按钮事件绑定
2215
+ document.getElementById('diff-toggle').addEventListener('click', toggleDiffBar);
2216
+ document.getElementById('close-diff').addEventListener('click', toggleDiffBar);
2217
+ document.getElementById('refresh-diff').addEventListener('click', function(e) {
2218
+ e.stopPropagation();
2219
+ loadGitStatus();
2220
+ // 重置 diff 内容区
2221
+ diffSelectedFile = null;
2222
+ document.getElementById('git-diff-content-area').innerHTML =
2223
+ '<div class="git-diff-placeholder">' +
2224
+ '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">' +
2225
+ '<line x1="6" y1="3" x2="6" y2="15"></line>' +
2226
+ '<circle cx="18" cy="6" r="3"></circle>' +
2227
+ '<circle cx="6" cy="18" r="3"></circle>' +
2228
+ '<path d="M18 9a9 9 0 0 1-9 9"></path>' +
2229
+ '</svg><span>点击文件查看 diff</span></div>';
2230
+ });
2231
+
1566
2232
  // 初始化虚拟按键事件
1567
2233
  setupVirtualKeyEvents();
1568
2234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.4.2",
3
+ "version": "2.6.0",
4
4
  "description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createServer } from 'node:http';
3
- import { existsSync, createReadStream } from 'node:fs';
3
+ import { existsSync, createReadStream, readFileSync } from 'node:fs';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { networkInterfaces, platform, arch, homedir } from 'node:os';
7
7
  import { chmodSync, statSync } from 'node:fs';
8
- import { execSync } from 'child_process';
8
+ import { execSync, execFile } from 'child_process';
9
+ import { promisify } from 'node:util';
9
10
  import { WebSocketServer } from 'ws';
10
11
  import Database from 'better-sqlite3';
11
12
 
@@ -22,6 +23,7 @@ const OPENCODE_DB_PATH = process.env.OPENCODE_DB_PATH || join(
22
23
  );
23
24
 
24
25
  const MAX_BUFFER = 200000;
26
+ const execFileAsync = promisify(execFile);
25
27
 
26
28
  let ptyModule = null;
27
29
  let claudeProcess = null;
@@ -388,7 +390,7 @@ function getSessionMessages(sessionId) {
388
390
  }
389
391
  }
390
392
 
391
- const server = createServer((req, res) => {
393
+ const server = createServer(async (req, res) => {
392
394
  if (req.url === '/' || req.url === '/index.html') {
393
395
  res.writeHead(200, {
394
396
  'Content-Type': 'text/html; charset=utf-8',
@@ -409,6 +411,96 @@ const server = createServer((req, res) => {
409
411
  return;
410
412
  }
411
413
 
414
+ // API: 获取 git status
415
+ if (req.url === '/api/git-status') {
416
+ res.writeHead(200, {
417
+ 'Content-Type': 'application/json',
418
+ 'Access-Control-Allow-Origin': '*',
419
+ });
420
+ try {
421
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
422
+ cwd: process.cwd(), encoding: 'utf-8', timeout: 5000,
423
+ });
424
+ const changes = stdout.split('\n').filter(Boolean).map(line => ({
425
+ status: line.substring(0, 2).trim(),
426
+ file: line.substring(3),
427
+ }));
428
+ res.end(JSON.stringify({ changes }));
429
+ } catch (err) {
430
+ res.end(JSON.stringify({ changes: [], error: err.message }));
431
+ }
432
+ return;
433
+ }
434
+
435
+ // API: 获取 git diff
436
+ if (req.url?.startsWith('/api/git-diff')) {
437
+ const url = new URL(req.url, `http://${req.headers.host}`);
438
+ const files = url.searchParams.get('files');
439
+ res.writeHead(200, {
440
+ 'Content-Type': 'application/json',
441
+ 'Access-Control-Allow-Origin': '*',
442
+ });
443
+ if (!files) {
444
+ res.end(JSON.stringify({ diffs: [] }));
445
+ return;
446
+ }
447
+ const fileList = files.split(',').filter(Boolean);
448
+ const diffs = [];
449
+ const cwd = process.cwd();
450
+ for (const file of fileList) {
451
+ if (file.includes('..') || file.startsWith('/')) continue;
452
+ try {
453
+ const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd, encoding: 'utf-8', timeout: 3000 });
454
+ if (!statusOut.trim()) continue;
455
+ const status = statusOut.substring(0, 2).trim();
456
+ const is_new = status === 'A' || status === '??';
457
+ const is_deleted = status === 'D';
458
+ let is_binary = false;
459
+ if (!is_deleted) {
460
+ try {
461
+ const { stdout: dc } = await execFileAsync('git', ['diff', '--numstat', 'HEAD', '--', file], { cwd, encoding: 'utf-8', timeout: 3000 });
462
+ if (dc.includes('-\t-\t')) is_binary = true;
463
+ } catch {}
464
+ }
465
+ let old_content = '', new_content = '', unified_diff = '';
466
+ if (!is_binary) {
467
+ if (!is_new) {
468
+ try {
469
+ const { stdout } = await execFileAsync('git', ['show', `HEAD:${file}`], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
470
+ old_content = stdout;
471
+ } catch {}
472
+ }
473
+ if (!is_deleted) {
474
+ try {
475
+ const filePath = join(cwd, file);
476
+ if (existsSync(filePath)) {
477
+ const stat = statSync(filePath);
478
+ if (stat.size > 5 * 1024 * 1024) {
479
+ diffs.push({ file, status, is_large: true, size: stat.size });
480
+ continue;
481
+ }
482
+ new_content = readFileSync(filePath, 'utf-8');
483
+ }
484
+ } catch {}
485
+ }
486
+ // 获取 unified diff
487
+ try {
488
+ const diffArgs = is_new
489
+ ? ['diff', '--no-color', '-U3', '--no-index', '/dev/null', file]
490
+ : ['diff', '--no-color', '-U3', 'HEAD', '--', file];
491
+ const { stdout } = await execFileAsync('git', diffArgs, { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 5 * 1024 * 1024 });
492
+ unified_diff = stdout;
493
+ } catch (e) {
494
+ if (e.stdout) unified_diff = e.stdout;
495
+ }
496
+ }
497
+ diffs.push({ file, status, old_content, new_content, unified_diff, is_binary, is_new, is_deleted });
498
+ } catch { continue; }
499
+ }
500
+ res.end(JSON.stringify({ diffs }));
501
+ return;
502
+ }
503
+
412
504
  // API: 获取会话消息
413
505
  if (req.url?.startsWith('/api/session/')) {
414
506
  const sessionId = req.url.split('/').pop();