claude-opencode-viewer 2.5.0 → 2.6.1

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 +469 -24
  2. package/package.json +1 -1
  3. package/server.js +95 -3
package/index.html CHANGED
@@ -574,6 +574,219 @@
574
574
  #copy-toast.show {
575
575
  display: block;
576
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
+ }
577
790
  </style>
578
791
  </head>
579
792
  <body>
@@ -581,20 +794,29 @@
581
794
  <div id="layout">
582
795
  <div id="header">
583
796
  <div style="display: flex; gap: 8px; align-items: center;">
584
- <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;">
585
798
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
586
799
  <line x1="12" y1="5" x2="12" y2="19"></line>
587
800
  <line x1="5" y1="12" x2="19" y2="12"></line>
588
801
  </svg>
589
802
  <span>新会话</span>
590
803
  </button>
591
- <button class="history-toggle-btn" id="history-toggle">
804
+ <button class="history-toggle-btn" id="history-toggle" style="color:#79c0ff; border-color:#2a4a7c;">
592
805
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
593
806
  <circle cx="12" cy="12" r="10"></circle>
594
807
  <polyline points="12 6 12 12 16 14"></polyline>
595
808
  </svg>
596
809
  <span>历史</span>
597
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>
598
820
  </div>
599
821
  <div id="mode-switcher">
600
822
  <span id="mode-label"></span>
@@ -669,6 +891,46 @@
669
891
  </div>
670
892
  </div>
671
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
+
672
934
  <div id="content">
673
935
  <div id="terminal-container">
674
936
  <div id="terminal">
@@ -687,8 +949,6 @@
687
949
  <div class="virtual-key" data-key="tab">Tab</div>
688
950
  <div class="virtual-key" data-key="esc">Esc</div>
689
951
  <div class="virtual-key" data-key="ctrlc">Ctrl+C</div>
690
- <div class="virtual-key scroll-key" data-scroll="-5">⇡ 滚动</div>
691
- <div class="virtual-key scroll-key" data-scroll="5">⇣ 滚动</div>
692
952
  <div class="virtual-key" id="btn-copy">复制</div>
693
953
  </div>
694
954
  </div>
@@ -891,14 +1151,32 @@
891
1151
  pixelAccum = 0;
892
1152
  }
893
1153
 
894
- // OpenCode 模式触摸滚动实现 - 使用 xterm.js scrollLines API
1154
+ // 触摸滚动实现 - 混合模式:alternate buffer 发鼠标滚轮,normal buffer 用 scrollLines
895
1155
  var touchScreen = null;
896
1156
  var touchEventsBound = false;
897
1157
 
1158
+ function isAlternateBuffer() {
1159
+ return term.buffer.active.type === 'alternate';
1160
+ }
1161
+
1162
+ function sendPageKey(direction) {
1163
+ if (!ws || ws.readyState !== 1) return;
1164
+ var seq = direction === 'up' ? '\x1b[5~' : '\x1b[6~';
1165
+ ws.send(JSON.stringify({ type: 'input', data: seq }));
1166
+ }
1167
+
1168
+ function doScroll(lines) {
1169
+ if (lines === 0) return;
1170
+ if (isAlternateBuffer()) {
1171
+ sendPageKey(lines < 0 ? 'up' : 'down');
1172
+ } else {
1173
+ term.scrollLines(lines);
1174
+ }
1175
+ }
1176
+
898
1177
  function getLineHeight() {
899
1178
  var cellDims = getCellDims();
900
1179
  var height = (cellDims && cellDims.height) || 15;
901
- console.log('[scroll] lineHeight:', height);
902
1180
  return height;
903
1181
  }
904
1182
 
@@ -926,8 +1204,7 @@
926
1204
  var lines = Math.trunc(pixelAccum / lh);
927
1205
 
928
1206
  if (lines !== 0) {
929
- console.log('[scroll] scrollLines:', lines, 'pixelAccum:', pixelAccum, 'lineHeight:', lh);
930
- term.scrollLines(lines);
1207
+ doScroll(lines);
931
1208
  pixelAccum -= lines * lh;
932
1209
  }
933
1210
  }
@@ -1011,8 +1288,7 @@
1011
1288
  var lh = getLineHeight();
1012
1289
  var lines = Math.trunc(pixelAccum / lh);
1013
1290
  if (lines !== 0) {
1014
- console.log('[scroll] final scrollLines:', lines);
1015
- term.scrollLines(lines);
1291
+ doScroll(lines);
1016
1292
  }
1017
1293
  pixelAccum = 0;
1018
1294
  }
@@ -1042,8 +1318,7 @@
1042
1318
  var lh = getLineHeight();
1043
1319
  var rest = Math.round(mAccum / lh);
1044
1320
  if (rest !== 0) {
1045
- console.log('[scroll] momentum final:', rest);
1046
- term.scrollLines(rest);
1321
+ doScroll(rest);
1047
1322
  }
1048
1323
  momentumRaf = null;
1049
1324
  return;
@@ -1052,7 +1327,7 @@
1052
1327
  var lh = getLineHeight();
1053
1328
  var lines = Math.trunc(mAccum / lh);
1054
1329
  if (lines !== 0) {
1055
- term.scrollLines(lines);
1330
+ doScroll(lines);
1056
1331
  mAccum -= lines * lh;
1057
1332
  }
1058
1333
  velocity *= friction;
@@ -1076,12 +1351,6 @@
1076
1351
  // 先解绑旧的
1077
1352
  unbindTouchScroll();
1078
1353
 
1079
- // 只在 opencode 模式下绑定
1080
- if (currentMode !== 'opencode') {
1081
- console.log('[scroll] Not opencode mode, skip binding');
1082
- return;
1083
- }
1084
-
1085
1354
  var screen = terminalEl.querySelector('.xterm-screen');
1086
1355
  if (!screen) {
1087
1356
  console.log('[scroll] .xterm-screen not found, retrying...');
@@ -1094,7 +1363,7 @@
1094
1363
  screen.addEventListener('touchmove', handleTouchMove, { passive: true });
1095
1364
  screen.addEventListener('touchend', handleTouchEnd, { passive: true });
1096
1365
  touchEventsBound = true;
1097
- console.log('[scroll] Touch events bound to .xterm-screen (opencode mode)');
1366
+ console.log('[scroll] Touch events bound to .xterm-screen');
1098
1367
  }
1099
1368
 
1100
1369
  function rebindTouchScroll() {
@@ -1285,10 +1554,8 @@
1285
1554
  }
1286
1555
 
1287
1556
  function scrollTerminal(lines) {
1288
- if (term) {
1289
- term.scrollLines(lines);
1290
- console.log('[scroll] scrolled by:', lines);
1291
- }
1557
+ if (!term) return;
1558
+ doScroll(lines);
1292
1559
  }
1293
1560
 
1294
1561
  // 参考 cc-viewer 的 TerminalPanel.jsx 行 519-546: 虚拟按键触摸处理
@@ -1789,6 +2056,184 @@
1789
2056
  closeSelectMode();
1790
2057
  });
1791
2058
 
2059
+ // ======= Git Diff 功能 =======
2060
+ var diffBarVisible = false;
2061
+ var diffChanges = [];
2062
+ var diffSelectedFile = null;
2063
+
2064
+ var STATUS_COLORS = {
2065
+ 'M': '#e2c08d', 'A': '#73c991', 'D': '#f14c4c',
2066
+ 'R': '#73c991', 'C': '#73c991', 'U': '#e2c08d',
2067
+ '?': '#73c991', '??': '#73c991',
2068
+ };
2069
+
2070
+ function toggleDiffBar() {
2071
+ diffBarVisible = !diffBarVisible;
2072
+ var bar = document.getElementById('git-diff-bar');
2073
+ if (diffBarVisible) {
2074
+ bar.classList.add('visible');
2075
+ loadGitStatus();
2076
+ } else {
2077
+ bar.classList.remove('visible');
2078
+ diffSelectedFile = null;
2079
+ }
2080
+ }
2081
+
2082
+ function loadGitStatus() {
2083
+ var fileList = document.getElementById('git-diff-file-list');
2084
+ fileList.innerHTML = '<div class="git-diff-loading">加载中...</div>';
2085
+ document.getElementById('git-diff-count').textContent = '0';
2086
+
2087
+ fetch('/api/git-status')
2088
+ .then(function(r) { return r.json(); })
2089
+ .then(function(data) {
2090
+ diffChanges = data.changes || [];
2091
+ document.getElementById('git-diff-count').textContent = diffChanges.length;
2092
+ renderDiffFileList();
2093
+ })
2094
+ .catch(function() {
2095
+ fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2096
+ });
2097
+ }
2098
+
2099
+ function renderDiffFileList() {
2100
+ var fileList = document.getElementById('git-diff-file-list');
2101
+ if (diffChanges.length === 0) {
2102
+ fileList.innerHTML = '<div class="git-diff-loading" style="color:#666;">没有变更文件</div>';
2103
+ return;
2104
+ }
2105
+ var html = '';
2106
+ diffChanges.forEach(function(c) {
2107
+ var color = STATUS_COLORS[c.status] || '#888';
2108
+ var label = c.status === '??' ? 'U' : c.status;
2109
+ var activeClass = diffSelectedFile === c.file ? ' active' : '';
2110
+ html += '<div class="git-diff-file-item' + activeClass + '" data-file="' + escapeHtml(c.file) + '">';
2111
+ html += '<span class="git-diff-file-status" style="color:' + color + '">' + label + '</span>';
2112
+ html += '<span class="git-diff-file-name">' + escapeHtml(c.file) + '</span>';
2113
+ html += '</div>';
2114
+ });
2115
+ fileList.innerHTML = html;
2116
+
2117
+ // 绑定点击事件
2118
+ fileList.querySelectorAll('.git-diff-file-item').forEach(function(item) {
2119
+ item.addEventListener('click', function() {
2120
+ var file = item.getAttribute('data-file');
2121
+ diffSelectedFile = file;
2122
+ // 更新选中状态
2123
+ fileList.querySelectorAll('.git-diff-file-item').forEach(function(el) {
2124
+ el.classList.toggle('active', el.getAttribute('data-file') === file);
2125
+ });
2126
+ loadDiffContent(file);
2127
+ });
2128
+ });
2129
+ }
2130
+
2131
+ function loadDiffContent(file) {
2132
+ var area = document.getElementById('git-diff-content-area');
2133
+ area.innerHTML = '<div class="git-diff-loading">加载 diff...</div>';
2134
+
2135
+ fetch('/api/git-diff?files=' + encodeURIComponent(file))
2136
+ .then(function(r) { return r.json(); })
2137
+ .then(function(data) {
2138
+ if (!data.diffs || !data.diffs[0]) {
2139
+ area.innerHTML = '<div class="git-diff-error">无 diff 数据</div>';
2140
+ return;
2141
+ }
2142
+ var d = data.diffs[0];
2143
+ var html = '<div class="git-diff-content-header">';
2144
+ html += '<span class="git-diff-content-path">' + escapeHtml(d.file) + '</span>';
2145
+ html += '<span class="git-diff-badge">' + (d.is_new ? 'NEW' : d.is_deleted ? 'DEL' : 'DIFF') + '</span>';
2146
+ html += '</div>';
2147
+ html += '<div class="git-diff-content-scroll">';
2148
+
2149
+ if (d.is_binary) {
2150
+ html += '<div class="git-diff-loading" style="font-style:italic;">二进制文件</div>';
2151
+ } else if (d.is_large) {
2152
+ html += '<div class="git-diff-loading" style="color:#e2c08d;">文件过大: ' + (d.size / (1024 * 1024)).toFixed(2) + ' MB</div>';
2153
+ } else if (d.unified_diff) {
2154
+ html += renderUnifiedDiff(d.unified_diff);
2155
+ } else {
2156
+ html += '<div class="git-diff-loading" style="color:#666;">无变更内容</div>';
2157
+ }
2158
+
2159
+ html += '</div>';
2160
+ area.innerHTML = html;
2161
+ })
2162
+ .catch(function(err) {
2163
+ area.innerHTML = '<div class="git-diff-error">加载失败: ' + escapeHtml(err.message) + '</div>';
2164
+ });
2165
+ }
2166
+
2167
+ function renderUnifiedDiff(diffText) {
2168
+ var lines = diffText.split('\n');
2169
+ var html = '<table class="diff-table">';
2170
+ var oldLine = 0, newLine = 0;
2171
+
2172
+ for (var i = 0; i < lines.length; i++) {
2173
+ var line = lines[i];
2174
+
2175
+ // 跳过 diff 头部信息
2176
+ if (line.startsWith('diff --git') || line.startsWith('index ') ||
2177
+ line.startsWith('---') || line.startsWith('+++') ||
2178
+ line.startsWith('new file') || line.startsWith('deleted file') ||
2179
+ line.startsWith('old mode') || line.startsWith('new mode')) {
2180
+ continue;
2181
+ }
2182
+
2183
+ if (line.startsWith('@@')) {
2184
+ // 解析 hunk header: @@ -old,count +new,count @@
2185
+ var match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
2186
+ if (match) {
2187
+ oldLine = parseInt(match[1], 10);
2188
+ newLine = parseInt(match[2], 10);
2189
+ }
2190
+ html += '<tr class="diff-line diff-line-hunk">';
2191
+ html += '<td class="diff-line-num"></td><td class="diff-line-num"></td>';
2192
+ html += '<td class="diff-line-content">' + escapeHtml(line) + '</td></tr>';
2193
+ } else if (line.startsWith('+')) {
2194
+ html += '<tr class="diff-line diff-line-add">';
2195
+ html += '<td class="diff-line-num"></td>';
2196
+ html += '<td class="diff-line-num">' + newLine + '</td>';
2197
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2198
+ newLine++;
2199
+ } else if (line.startsWith('-')) {
2200
+ html += '<tr class="diff-line diff-line-del">';
2201
+ html += '<td class="diff-line-num">' + oldLine + '</td>';
2202
+ html += '<td class="diff-line-num"></td>';
2203
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1)) + '</td></tr>';
2204
+ oldLine++;
2205
+ } else if (line.startsWith(' ') || (line === '' && i < lines.length - 1)) {
2206
+ html += '<tr class="diff-line">';
2207
+ html += '<td class="diff-line-num">' + oldLine + '</td>';
2208
+ html += '<td class="diff-line-num">' + newLine + '</td>';
2209
+ html += '<td class="diff-line-content">' + escapeHtml(line.substring(1) || '') + '</td></tr>';
2210
+ oldLine++;
2211
+ newLine++;
2212
+ }
2213
+ }
2214
+
2215
+ html += '</table>';
2216
+ return html;
2217
+ }
2218
+
2219
+ // Diff 按钮事件绑定
2220
+ document.getElementById('diff-toggle').addEventListener('click', toggleDiffBar);
2221
+ document.getElementById('close-diff').addEventListener('click', toggleDiffBar);
2222
+ document.getElementById('refresh-diff').addEventListener('click', function(e) {
2223
+ e.stopPropagation();
2224
+ loadGitStatus();
2225
+ // 重置 diff 内容区
2226
+ diffSelectedFile = null;
2227
+ document.getElementById('git-diff-content-area').innerHTML =
2228
+ '<div class="git-diff-placeholder">' +
2229
+ '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="1.5">' +
2230
+ '<line x1="6" y1="3" x2="6" y2="15"></line>' +
2231
+ '<circle cx="18" cy="6" r="3"></circle>' +
2232
+ '<circle cx="6" cy="18" r="3"></circle>' +
2233
+ '<path d="M18 9a9 9 0 0 1-9 9"></path>' +
2234
+ '</svg><span>点击文件查看 diff</span></div>';
2235
+ });
2236
+
1792
2237
  // 初始化虚拟按键事件
1793
2238
  setupVirtualKeyEvents();
1794
2239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
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();