sapper-iq 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sapper-ui.mjs +644 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "AI-powered development assistant that executes commands and builds projects",
5
5
  "main": "sapper.mjs",
6
6
  "bin": {
package/sapper-ui.mjs CHANGED
@@ -142,6 +142,9 @@ function buildHTML() {
142
142
  <title>Sapper</title>
143
143
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
144
144
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css" />
145
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.css" />
146
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/theme/material-darker.min.css" />
147
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/dialog/dialog.min.css" />
145
148
  <style>
146
149
  :root {
147
150
  --bg: #0a0e14;
@@ -271,6 +274,16 @@ function buildHTML() {
271
274
  #activityPanel .ai.kind-created .ak { color: var(--green); }
272
275
  #activityPanel .ai.kind-modified .ak { color: var(--yellow); }
273
276
  #activityPanel .ai.kind-deleted .ak { color: var(--red); }
277
+ #activityPanel .ai .acts { display: none; gap: 4px; flex-shrink: 0; }
278
+ #activityPanel .ai:hover .acts { display: inline-flex; }
279
+ #activityPanel .ai .ab { background: transparent; border: 1px solid var(--border2);
280
+ color: var(--muted); border-radius: 3px; padding: 1px 5px; font-size: 10px; cursor: pointer;
281
+ line-height: 1.2; font-family: inherit; }
282
+ #activityPanel .ai .ab:hover { color: var(--accent); border-color: var(--accent); }
283
+ #activityPanel .ai .ab.danger:hover { color: var(--red); border-color: var(--red); }
284
+ #activityPanel .note { padding: 2px 10px 6px 76px; color: var(--accent2);
285
+ font-style: italic; font-size: 11px; white-space: pre-wrap; word-break: break-word; }
286
+ #activityPanel .note:before { content: '💬 '; margin-right: 2px; font-style: normal; }
274
287
  #activityPanel .empty { padding: 12px; color: var(--dim); text-align: center; font-size: 11px; }
275
288
  .tree { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; padding-bottom: 12px; }
276
289
  .row { display: flex; align-items: center; gap: 4px; padding: 3px 8px; cursor: pointer; color: var(--muted);
@@ -472,6 +485,31 @@ function buildHTML() {
472
485
  #pview.code { padding: 0; }
473
486
  #pview.code pre { margin: 0; border: none; border-radius: 0; min-height: 100%; }
474
487
 
488
+ /* Diff view */
489
+ #pview.diff { padding: 0; font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; line-height: 1.45; }
490
+ #pview.diff .dh { padding: 8px 14px; background: var(--panel2); color: var(--dim);
491
+ border-bottom: 1px solid var(--border); font-size: 10px; text-transform: uppercase;
492
+ letter-spacing: .5px; display: flex; gap: 14px; }
493
+ #pview.diff .dh .add { color: var(--green); } #pview.diff .dh .del { color: var(--red); }
494
+ #pview.diff .hunk { border-bottom: 1px solid var(--border); }
495
+ #pview.diff .hunk-h { padding: 4px 14px; background: rgba(88,166,255,.08);
496
+ color: var(--accent); font-size: 10px; }
497
+ #pview.diff .ln { display: flex; }
498
+ #pview.diff .ln .gut { flex-shrink: 0; width: 70px; padding: 0 6px 0 10px; text-align: right;
499
+ color: var(--dim); border-right: 1px solid var(--border); user-select: none;
500
+ font-variant-numeric: tabular-nums; font-size: 10px; line-height: 18px; white-space: pre; }
501
+ #pview.diff .ln .txt { flex: 1; padding: 0 10px; white-space: pre; overflow-x: auto;
502
+ line-height: 18px; }
503
+ #pview.diff .ln.add { background: rgba(63,185,80,.10); }
504
+ #pview.diff .ln.add .txt { color: #56d364; }
505
+ #pview.diff .ln.add .txt::before { content: '+ '; color: var(--green); }
506
+ #pview.diff .ln.del { background: rgba(248,81,73,.10); }
507
+ #pview.diff .ln.del .txt { color: #ffa198; }
508
+ #pview.diff .ln.del .txt::before { content: '- '; color: var(--red); }
509
+ #pview.diff .ln.ctx .txt { color: var(--muted); }
510
+ #pview.diff .ln.ctx .txt::before { content: ' '; }
511
+ #pview.diff .empty-diff { padding: 20px; color: var(--dim); text-align: center; }
512
+
475
513
  #pedit {
476
514
  flex: 1; min-height: 0; width: 100%; padding: 12px 14px;
477
515
  background: var(--bg); border: none; color: var(--fg);
@@ -480,6 +518,30 @@ function buildHTML() {
480
518
  }
481
519
  #pedit.show { display: block; }
482
520
  #pview.hide { display: none; }
521
+ /* CodeMirror editor inside #preview */
522
+ #editorWrap { flex: 1; min-height: 0; display: none; position: relative; }
523
+ #editorWrap.show { display: flex; flex-direction: column; }
524
+ #editorWrap .CodeMirror { flex: 1; min-height: 0; height: 100% !important; width: 100%;
525
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 12.5px; line-height: 1.5; }
526
+ #editorWrap .editorbar { display: flex; align-items: center; gap: 10px; padding: 4px 10px;
527
+ background: var(--panel2); border-bottom: 1px solid var(--border); font-size: 10px;
528
+ color: var(--dim); font-family: ui-monospace, 'SF Mono', monospace;
529
+ text-transform: uppercase; letter-spacing: .5px; flex-shrink: 0; }
530
+ #editorWrap .editorbar .lang { color: var(--accent); }
531
+ #editorWrap .editorbar .ln-toggle { margin-left: auto; cursor: pointer; color: var(--muted); }
532
+ #editorWrap .editorbar .ln-toggle:hover { color: var(--accent); }
533
+ .CodeMirror-linenumber { color: var(--dim) !important; }
534
+ .cm-s-material-darker.CodeMirror, .cm-s-material-darker .CodeMirror-gutters { background: var(--bg) !important; }
535
+ .cm-s-material-darker .CodeMirror-gutters { border-right: 1px solid var(--border) !important; }
536
+ .cm-s-material-darker .CodeMirror-activeline-background { background: rgba(88,166,255,.05) !important; }
537
+
538
+ /* Resizable splitters between panes */
539
+ .resizer { width: 5px; background: transparent; cursor: col-resize; flex-shrink: 0;
540
+ transition: background .15s; position: relative; z-index: 5; }
541
+ .resizer:hover, .resizer.active { background: var(--accent); }
542
+ .resizer.hidden { display: none; }
543
+ body.resizing { cursor: col-resize !important; user-select: none; }
544
+ body.resizing iframe { pointer-events: none; }
483
545
 
484
546
  #empty { padding: 40px 20px; text-align: center; color: var(--dim); font-size: 13px; }
485
547
  #empty .lg { font-size: 36px; margin-bottom: 8px; }
@@ -572,6 +634,8 @@ function buildHTML() {
572
634
  </div>
573
635
  </aside>
574
636
 
637
+ <div class="resizer" id="sideRes"></div>
638
+
575
639
  <!-- Center: terminal -->
576
640
  <main id="center">
577
641
  <div id="qa">
@@ -598,11 +662,15 @@ function buildHTML() {
598
662
  </div>
599
663
  </main>
600
664
 
665
+ <div class="resizer" id="prevRes"></div>
666
+
601
667
  <!-- Right: preview -->
602
668
  <aside id="preview" class="hidden">
603
669
  <div class="ph">
604
670
  <span class="pp" id="pPath">No file open</span>
605
671
  <button id="pEdit" onclick="startEdit()" style="display:none">Edit</button>
672
+ <button id="pDiff" onclick="showDiff()" style="display:none" title="Show what changed">Diff</button>
673
+ <button id="pAsk" onclick="askAboutSelection()" style="display:none" title="Send the selection (or whole file) to Sapper with a comment">&#128172; Ask AI</button>
606
674
  <button id="pSave" onclick="saveEdit()" class="primary" style="display:none">Save</button>
607
675
  <button id="pCancel" onclick="cancelEdit()" style="display:none">Cancel</button>
608
676
  <button id="pSrc" onclick="toggleSource()" style="display:none">Source</button>
@@ -611,7 +679,10 @@ function buildHTML() {
611
679
  </div>
612
680
  <div class="ind" id="pInd">File changed on disk — reload to view latest.</div>
613
681
  <div id="pview"><div id="empty"><div class="lg">&#128196;</div>Open a file from the sidebar.</div></div>
614
- <textarea id="pedit" spellcheck="false"></textarea>
682
+ <div id="editorWrap">
683
+ <div class="editorbar"><span class="lang" id="edLang">text</span><span id="edPos"></span><span class="ln-toggle" id="edWrap" title="Toggle word wrap">wrap</span><span class="ln-toggle" id="edLines" title="Toggle line numbers">lines</span></div>
684
+ <textarea id="pedit" spellcheck="false"></textarea>
685
+ </div>
615
686
  </aside>
616
687
  </div>
617
688
  </div>
@@ -622,6 +693,16 @@ function buildHTML() {
622
693
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
623
694
  <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
624
695
  <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
696
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.js"></script>
697
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/meta.min.js"></script>
698
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/mode/loadmode.min.js"></script>
699
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/matchbrackets.min.js"></script>
700
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/closebrackets.min.js"></script>
701
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/selection/active-line.min.js"></script>
702
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/search/searchcursor.min.js"></script>
703
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/search/search.min.js"></script>
704
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/dialog/dialog.min.js"></script>
705
+ <script src="https://cdn.jsdelivr.net/npm/diff@5.2.0/dist/diff.min.js"></script>
625
706
  <script>
626
707
  /* ─────────────────────────────────────────────────────────────── */
627
708
  /* Sapper Web — frontend */
@@ -642,6 +723,16 @@ var state = {
642
723
  activityOpen: false,
643
724
  };
644
725
 
726
+ var cm = null; // CodeMirror instance (lazy)
727
+
728
+ // Notes persisted across reloads: { "path|ts": "note text" }
729
+ var savedNotes = {};
730
+ try { savedNotes = JSON.parse(localStorage.getItem('sapperNotes') || '{}') || {}; } catch(e) {}
731
+ function saveNotes() {
732
+ try { localStorage.setItem('sapperNotes', JSON.stringify(savedNotes)); } catch(e) {}
733
+ }
734
+ function noteKey(a) { return a.path + '|' + a.ts; }
735
+
645
736
  function esc(s) {
646
737
  if (s == null) return '';
647
738
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
@@ -812,7 +903,10 @@ function applyActivityItem(item) {
812
903
  last.count = (last.count || 1) + 1;
813
904
  last.ts = item.ts;
814
905
  } else {
815
- state.activity.push({ kind: item.kind, path: item.path, isDir: item.isDir, ts: item.ts, count: 1 });
906
+ var entry = { kind: item.kind, path: item.path, isDir: item.isDir, ts: item.ts, count: 1 };
907
+ // restore any saved note for this exact timestamp (rarely matches but safe)
908
+ if (savedNotes[noteKey(entry)]) entry.note = savedNotes[noteKey(entry)];
909
+ state.activity.push(entry);
816
910
  if (state.activity.length > 100) state.activity.shift();
817
911
  }
818
912
  renderActivity();
@@ -846,33 +940,116 @@ function renderActivity() {
846
940
  host.innerHTML = '<div class="empty">No changes yet. Ask Sapper to edit something.</div>';
847
941
  return;
848
942
  }
849
- var items = state.activity.slice(-30).reverse();
850
- host.innerHTML = items.map(function(a){
943
+ // Render newest-first; track original index via data-idx
944
+ var html = '';
945
+ for (var i = state.activity.length - 1; i >= 0; i--) {
946
+ var a = state.activity[i];
851
947
  var rel = relTime(a.ts);
852
948
  var ct = a.count > 1 ? ' &times;' + a.count : '';
853
- return '<div class="ai kind-' + a.kind + '" data-path="' + esc(a.path) + '">' +
949
+ html += '<div class="ai kind-' + a.kind + '" data-idx="' + i + '" data-path="' + esc(a.path) + '">' +
854
950
  '<span class="ak">' + a.kind + ct + '</span>' +
855
951
  '<span class="ap">' + esc(a.path) + '</span>' +
856
- '<span class="at">' + rel + '</span></div>';
857
- }).join('');
952
+ '<span class="at">' + rel + '</span>' +
953
+ '<span class="acts">' +
954
+ '<button class="ab" data-act="note" title="' + (a.note ? 'Edit note' : 'Add note') + '">' + (a.note ? '&#9998;' : '&#128172;') + '</button>' +
955
+ '<button class="ab danger" data-act="dismiss" title="Dismiss this change">&times;</button>' +
956
+ '</span></div>';
957
+ if (a.note) {
958
+ html += '<div class="note" data-idx="' + i + '">' + esc(a.note) + '</div>';
959
+ }
960
+ }
961
+ host.innerHTML = html;
858
962
  Array.from(host.querySelectorAll('.ai')).forEach(function(el){
859
- el.addEventListener('click', function(){
860
- var p = el.dataset.path;
963
+ el.addEventListener('click', function(ev){
964
+ var btn = ev.target.closest('button.ab');
965
+ var idx = parseInt(el.dataset.idx, 10);
966
+ var entry = state.activity[idx];
967
+ if (!entry) return;
968
+ if (btn) {
969
+ ev.stopPropagation();
970
+ if (btn.dataset.act === 'dismiss') {
971
+ dismissActivity(idx);
972
+ } else if (btn.dataset.act === 'note') {
973
+ promptNote(idx);
974
+ }
975
+ return;
976
+ }
977
+ var p = entry.path;
861
978
  var mark = state.marks[p];
862
979
  if (mark && mark.kind === 'deleted') { showToast(p + ' (deleted)'); return; }
863
- // expand ancestor dirs then open
864
980
  var parts = p.split('/');
865
981
  var soFar = '';
866
- for (var i = 0; i < parts.length - 1; i++) {
867
- soFar = soFar ? soFar + '/' + parts[i] : parts[i];
982
+ for (var j = 0; j < parts.length - 1; j++) {
983
+ soFar = soFar ? soFar + '/' + parts[j] : parts[j];
868
984
  state.expanded[soFar] = true;
869
985
  }
870
986
  loadTree();
871
987
  setTimeout(function(){ openFile(p); }, 80);
872
- // clear that file's mark since the user has acknowledged it
873
988
  clearMark(p);
874
989
  });
875
990
  });
991
+ // Click on a note line lets you edit it too
992
+ Array.from(host.querySelectorAll('.note')).forEach(function(el){
993
+ el.addEventListener('click', function(){
994
+ promptNote(parseInt(el.dataset.idx, 10));
995
+ });
996
+ });
997
+ }
998
+
999
+ function dismissActivity(idx) {
1000
+ var entry = state.activity[idx];
1001
+ if (!entry) return;
1002
+ state.activity.splice(idx, 1);
1003
+ // If this was the only outstanding mark for that path, clear the row mark
1004
+ var stillHas = state.activity.some(function(x){ return x.path === entry.path; });
1005
+ if (!stillHas) clearMark(entry.path);
1006
+ if (savedNotes[noteKey(entry)]) { delete savedNotes[noteKey(entry)]; saveNotes(); }
1007
+ renderActivity();
1008
+ }
1009
+
1010
+ async function promptNote(idx) {
1011
+ var entry = state.activity[idx];
1012
+ if (!entry) return;
1013
+ var val = await showModal({
1014
+ title: 'Note for change',
1015
+ label: entry.kind + ' ' + entry.path,
1016
+ placeholder: 'e.g. reviewed, intentional, needs revert',
1017
+ value: entry.note || '',
1018
+ okLabel: 'Save note',
1019
+ });
1020
+ if (val == null) return; // cancelled
1021
+ var trimmed = val.trim();
1022
+ if (!trimmed) {
1023
+ delete entry.note;
1024
+ delete savedNotes[noteKey(entry)];
1025
+ } else {
1026
+ entry.note = trimmed;
1027
+ savedNotes[noteKey(entry)] = trimmed;
1028
+ }
1029
+ saveNotes();
1030
+ renderActivity();
1031
+ }
1032
+
1033
+ function dismissPathMarks(path) {
1034
+ // Remove every activity entry for this path
1035
+ for (var i = state.activity.length - 1; i >= 0; i--) {
1036
+ if (state.activity[i].path === path) {
1037
+ var k = noteKey(state.activity[i]);
1038
+ if (savedNotes[k]) { delete savedNotes[k]; }
1039
+ state.activity.splice(i, 1);
1040
+ }
1041
+ }
1042
+ saveNotes();
1043
+ clearMark(path);
1044
+ renderActivity();
1045
+ }
1046
+
1047
+ function noteForPath(path) {
1048
+ // Note attaches to the most recent activity entry for this path
1049
+ for (var i = state.activity.length - 1; i >= 0; i--) {
1050
+ if (state.activity[i].path === path) { promptNote(i); return; }
1051
+ }
1052
+ showToast('No tracked change for ' + path);
876
1053
  }
877
1054
 
878
1055
  function relTime(ts) {
@@ -930,13 +1107,16 @@ window.toggleSide = function() {
930
1107
  var s = document.getElementById('side');
931
1108
  s.classList.toggle('hidden');
932
1109
  document.getElementById('btnSide').classList.toggle('on', !s.classList.contains('hidden'));
1110
+ if (typeof updateResizerVisibility === 'function') updateResizerVisibility();
933
1111
  setTimeout(doFit, 50);
934
1112
  };
935
1113
  window.togglePreview = function() {
936
1114
  var p = document.getElementById('preview');
937
1115
  p.classList.toggle('hidden');
938
1116
  document.getElementById('btnPrev').classList.toggle('on', !p.classList.contains('hidden'));
1117
+ if (typeof updateResizerVisibility === 'function') updateResizerVisibility();
939
1118
  setTimeout(doFit, 50);
1119
+ if (cm && !p.classList.contains('hidden')) setTimeout(function(){ cm.refresh(); }, 80);
940
1120
  };
941
1121
 
942
1122
  // ─── File tree ───────────────────────────────────────────────
@@ -1089,6 +1269,12 @@ function openRowMenu(anchor, path, isDir) {
1089
1269
  } else {
1090
1270
  items.push({ label: 'Open', fn: function(){ openFile(path); } });
1091
1271
  }
1272
+ // Change-mark actions, only shown when the row has a mark
1273
+ if (state.marks[path]) {
1274
+ items.push({ sep: true });
1275
+ items.push({ label: '&#10005; Dismiss change mark', fn: function(){ dismissPathMarks(path); } });
1276
+ items.push({ label: '&#128172; Add note to last change', fn: function(){ noteForPath(path); } });
1277
+ }
1092
1278
  items.push({ sep: true });
1093
1279
  items.push({ label: 'Rename\u2026', fn: function(){ renamePrompt(path); } });
1094
1280
  items.push({ label: 'Duplicate', fn: function(){ duplicateItem(path); } });
@@ -1271,7 +1457,8 @@ window.openFile = function(path, isReload) {
1271
1457
  document.querySelectorAll('.row.active').forEach(function(r){ r.classList.remove('active'); });
1272
1458
  var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
1273
1459
  if (row) row.classList.add('active');
1274
- // Clear any pending change-mark for this file since the user is acknowledging it
1460
+ // Capture mark BEFORE clearing so we know whether to show the Diff button
1461
+ var hadModification = !isReload && state.marks[path] && state.marks[path].kind === 'modified';
1275
1462
  if (!isReload && state.marks[path]) clearMark(path);
1276
1463
 
1277
1464
  state.currentFile = path;
@@ -1280,12 +1467,16 @@ window.openFile = function(path, isReload) {
1280
1467
  document.getElementById('pPath').textContent = path;
1281
1468
  document.getElementById('pInd').classList.remove('show');
1282
1469
  document.getElementById('pEdit').style.display = 'none';
1470
+ document.getElementById('pDiff').style.display = hadModification ? 'inline-block' : 'none';
1471
+ document.getElementById('pAsk').style.display = 'inline-block';
1283
1472
  document.getElementById('pSave').style.display = 'none';
1284
1473
  document.getElementById('pCancel').style.display = 'none';
1285
1474
  document.getElementById('pSrc').style.display = 'none';
1286
1475
  document.getElementById('pReload').style.display = 'inline-block';
1287
- document.getElementById('pedit').classList.remove('show');
1476
+ document.getElementById('editorWrap').classList.remove('show');
1288
1477
  document.getElementById('pview').classList.remove('hide');
1478
+ // auto-open the diff if we just navigated to a modified file
1479
+ if (hadModification) setTimeout(function(){ if (state.currentFile === path) window.showDiff(); }, 250);
1289
1480
 
1290
1481
  fetch('/api/file?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
1291
1482
  if (d.error) {
@@ -1372,26 +1563,33 @@ window.closePreview = function() {
1372
1563
  document.getElementById('pview').className = '';
1373
1564
  document.getElementById('pPath').textContent = 'No file open';
1374
1565
  document.getElementById('pEdit').style.display = 'none';
1566
+ document.getElementById('pDiff').style.display = 'none';
1567
+ document.getElementById('pAsk').style.display = 'none';
1375
1568
  document.getElementById('pSave').style.display = 'none';
1376
1569
  document.getElementById('pCancel').style.display = 'none';
1377
1570
  document.getElementById('pSrc').style.display = 'none';
1378
1571
  document.getElementById('pReload').style.display = 'none';
1379
- document.getElementById('pedit').classList.remove('show');
1572
+ document.getElementById('editorWrap').classList.remove('show');
1380
1573
  document.getElementById('pview').classList.remove('hide');
1381
1574
  };
1382
1575
  window.startEdit = function() {
1383
1576
  if (!state.currentFile) return;
1384
1577
  state.editing = true;
1385
- document.getElementById('pedit').value = state.fileOnDisk;
1386
- document.getElementById('pedit').classList.add('show');
1387
1578
  document.getElementById('pview').classList.add('hide');
1579
+ document.getElementById('editorWrap').classList.add('show');
1580
+ ensureEditor();
1581
+ setEditorMode(state.currentFile);
1582
+ cm.setValue(state.fileOnDisk || '');
1583
+ cm.clearHistory();
1584
+ setTimeout(function(){ cm.refresh(); cm.focus(); }, 30);
1388
1585
  document.getElementById('pEdit').style.display = 'none';
1586
+ document.getElementById('pDiff').style.display = 'none';
1389
1587
  document.getElementById('pSave').style.display = 'inline-block';
1390
1588
  document.getElementById('pCancel').style.display = 'inline-block';
1391
1589
  };
1392
1590
  window.cancelEdit = function() {
1393
1591
  state.editing = false;
1394
- document.getElementById('pedit').classList.remove('show');
1592
+ document.getElementById('editorWrap').classList.remove('show');
1395
1593
  document.getElementById('pview').classList.remove('hide');
1396
1594
  document.getElementById('pEdit').style.display = 'inline-block';
1397
1595
  document.getElementById('pSave').style.display = 'none';
@@ -1400,7 +1598,7 @@ window.cancelEdit = function() {
1400
1598
  };
1401
1599
  window.saveEdit = function() {
1402
1600
  if (!state.currentFile) return;
1403
- var content = document.getElementById('pedit').value;
1601
+ var content = cm ? cm.getValue() : document.getElementById('pedit').value;
1404
1602
  fetch('/api/file', {
1405
1603
  method: 'POST', headers: { 'Content-Type': 'application/json' },
1406
1604
  body: JSON.stringify({ path: state.currentFile, content: content })
@@ -1412,6 +1610,302 @@ window.saveEdit = function() {
1412
1610
  }).catch(function(e){ showToast('Save failed: ' + e.message, 'err'); });
1413
1611
  };
1414
1612
 
1613
+ window.showDiff = function() {
1614
+ if (!state.currentFile) return;
1615
+ var view = document.getElementById('pview');
1616
+ document.getElementById('editorWrap').classList.remove('show');
1617
+ view.classList.remove('hide');
1618
+ view.className = 'diff';
1619
+ view.innerHTML = '<div class="empty-diff">Loading diff…</div>';
1620
+ document.getElementById('pDiff').style.display = 'none';
1621
+ document.getElementById('pEdit').style.display = 'inline-block';
1622
+ fetch('/api/diff?path=' + encodeURIComponent(state.currentFile))
1623
+ .then(function(r){return r.json();})
1624
+ .then(function(d){
1625
+ if (d.error) { view.innerHTML = '<div class="empty-diff">' + esc(d.error) + '</div>'; return; }
1626
+ if (d.prev == null) {
1627
+ view.innerHTML = '<div class="empty-diff">' + esc(d.message || 'No prior snapshot available.') +
1628
+ '<br><br>Sapper started tracking this file from now on \u2014 the next change will show a diff.</div>';
1629
+ return;
1630
+ }
1631
+ renderUnifiedDiff(view, d.prev, d.curr);
1632
+ })
1633
+ .catch(function(e){ view.innerHTML = '<div class="empty-diff">Diff failed: ' + esc(e.message) + '</div>'; });
1634
+ };
1635
+
1636
+ function renderUnifiedDiff(host, prev, curr) {
1637
+ if (!window.Diff) {
1638
+ host.innerHTML = '<div class="empty-diff">Diff library failed to load.</div>';
1639
+ return;
1640
+ }
1641
+ var patch = Diff.structuredPatch('a', 'b', prev || '', curr || '', '', '', { context: 3 });
1642
+ if (!patch.hunks.length) {
1643
+ host.innerHTML = '<div class="empty-diff">No textual differences \u2014 file content is identical.</div>';
1644
+ return;
1645
+ }
1646
+ var added = 0, removed = 0;
1647
+ patch.hunks.forEach(function(h){
1648
+ h.lines.forEach(function(l){
1649
+ if (l[0] === '+') added++;
1650
+ else if (l[0] === '-') removed++;
1651
+ });
1652
+ });
1653
+ var html = '<div class="dh"><span class="add">+' + added + ' added</span>' +
1654
+ '<span class="del">-' + removed + ' removed</span>' +
1655
+ '<span>' + patch.hunks.length + ' hunk' + (patch.hunks.length>1?'s':'') + '</span></div>';
1656
+ patch.hunks.forEach(function(h){
1657
+ html += '<div class="hunk">';
1658
+ html += '<div class="hunk-h">@@ -' + h.oldStart + ',' + h.oldLines +
1659
+ ' +' + h.newStart + ',' + h.newLines + ' @@</div>';
1660
+ var oldNo = h.oldStart, newNo = h.newStart;
1661
+ h.lines.forEach(function(l){
1662
+ var sign = l[0], body = l.slice(1);
1663
+ if (sign === '\\\\') return; // "\"
1664
+ var cls = sign === '+' ? 'add' : (sign === '-' ? 'del' : 'ctx');
1665
+ var lo = sign === '+' ? '' : String(oldNo);
1666
+ var ln = sign === '-' ? '' : String(newNo);
1667
+ var gut = lo.padStart(4, ' ') + ' ' + ln.padStart(4, ' ');
1668
+ html += '<div class="ln ' + cls + '"><span class="gut">' + gut + '</span>' +
1669
+ '<span class="txt">' + esc(body || ' ') + '</span></div>';
1670
+ if (sign !== '+') oldNo++;
1671
+ if (sign !== '-') newNo++;
1672
+ });
1673
+ html += '</div>';
1674
+ });
1675
+ host.innerHTML = html;
1676
+ }
1677
+
1678
+ // ─── Ask AI: send selection + comment to terminal ─────────────
1679
+ function detectLang(path) {
1680
+ var ext = ((path || '').split('.').pop() || '').toLowerCase();
1681
+ var map = { js:'js', mjs:'js', cjs:'js', jsx:'jsx', ts:'ts', tsx:'tsx', py:'python',
1682
+ rb:'ruby', go:'go', rs:'rust', java:'java', c:'c', h:'c', cpp:'cpp', hpp:'cpp',
1683
+ cs:'csharp', kt:'kotlin', swift:'swift', php:'php', sh:'bash', bash:'bash',
1684
+ zsh:'zsh', md:'markdown', json:'json', yml:'yaml', yaml:'yaml', toml:'toml',
1685
+ xml:'xml', html:'html', css:'css', scss:'scss', sql:'sql', lua:'lua', pl:'perl',
1686
+ r:'r', erl:'erlang', ex:'elixir', dart:'dart', vue:'vue', svelte:'svelte' };
1687
+ return map[ext] || '';
1688
+ }
1689
+
1690
+ // Collect what the user has selected in the preview/editor/diff
1691
+ function getCurrentSelection() {
1692
+ var out = { text: '', startLine: null, endLine: null, source: '' };
1693
+ // Editor (CodeMirror) selection wins if visible
1694
+ var edWrap = document.getElementById('editorWrap');
1695
+ if (cm && edWrap && edWrap.classList.contains('show') && cm.somethingSelected()) {
1696
+ out.text = cm.getSelection();
1697
+ var sel = cm.listSelections()[0];
1698
+ var a = sel.anchor, h = sel.head;
1699
+ var startL = Math.min(a.line, h.line), endL = Math.max(a.line, h.line);
1700
+ out.startLine = startL + 1; out.endLine = endL + 1;
1701
+ out.source = 'editor';
1702
+ return out;
1703
+ }
1704
+ // DOM selection (preview / diff)
1705
+ var sel = window.getSelection ? window.getSelection() : null;
1706
+ var pview = document.getElementById('pview');
1707
+ if (sel && sel.rangeCount && !sel.isCollapsed && pview.contains(sel.anchorNode)) {
1708
+ out.text = sel.toString();
1709
+ out.source = pview.classList.contains('diff') ? 'diff' : 'preview';
1710
+ // Try to recover line range from the diff gutter (.gut spans contain old/new line nums)
1711
+ if (out.source === 'diff') {
1712
+ var range = sel.getRangeAt(0);
1713
+ var node = range.startContainer;
1714
+ while (node && node.nodeType !== 1) node = node.parentNode;
1715
+ var startLn = node && node.closest ? node.closest('.ln') : null;
1716
+ node = range.endContainer;
1717
+ while (node && node.nodeType !== 1) node = node.parentNode;
1718
+ var endLn = node && node.closest ? node.closest('.ln') : null;
1719
+ function rightNum(ln) {
1720
+ if (!ln) return null;
1721
+ var g = ln.querySelector('.gut');
1722
+ if (!g) return null;
1723
+ var parts = g.textContent.trim().split(/\\s+/);
1724
+ var n = parseInt(parts[parts.length - 1], 10);
1725
+ return isNaN(n) ? null : n;
1726
+ }
1727
+ var s = rightNum(startLn), e = rightNum(endLn);
1728
+ if (s && e) { out.startLine = Math.min(s,e); out.endLine = Math.max(s,e); }
1729
+ }
1730
+ return out;
1731
+ }
1732
+ return out;
1733
+ }
1734
+
1735
+ function sendPasteToTerm(text) {
1736
+ if (!ws || ws.readyState !== 1) {
1737
+ showToast('Terminal not connected', 'err');
1738
+ return false;
1739
+ }
1740
+ // xterm bracketed-paste sequence + Enter
1741
+ var BEGIN = '\\u001b[200~';
1742
+ var END = '\\u001b[201~';
1743
+ // Decode escape literals; pty sees raw bytes
1744
+ ws.send('\\u001b[200~'); // ESC [ 200 ~ (start paste)
1745
+ ws.send(text);
1746
+ ws.send('\\u001b[201~'); // end paste
1747
+ ws.send('\\r');
1748
+ return true;
1749
+ }
1750
+
1751
+ window.askAboutSelection = async function() {
1752
+ if (!state.currentFile) return;
1753
+ var sel = getCurrentSelection();
1754
+ var snippet = sel.text;
1755
+ var lineNote = '';
1756
+ if (snippet) {
1757
+ if (sel.startLine && sel.endLine) {
1758
+ lineNote = ' (lines ' + sel.startLine +
1759
+ (sel.endLine !== sel.startLine ? '-' + sel.endLine : '') + ')';
1760
+ }
1761
+ } else {
1762
+ // No selection: offer the whole file but warn if huge
1763
+ snippet = state.fileOnDisk || '';
1764
+ if (snippet.length > 8000) {
1765
+ showToast('No selection; file is large \u2014 select a region first.', 'warn');
1766
+ return;
1767
+ }
1768
+ lineNote = ' (entire file, ' + snippet.split('\\n').length + ' lines)';
1769
+ }
1770
+ // Trim trailing whitespace per line to keep prompt tidy; keep leading indentation
1771
+ var trimmed = snippet.replace(/[ \\t]+$/gm, '');
1772
+ showAskModal({
1773
+ file: state.currentFile,
1774
+ lineNote: lineNote,
1775
+ snippet: trimmed,
1776
+ lang: detectLang(state.currentFile),
1777
+ });
1778
+ };
1779
+
1780
+ function showAskModal(opts) {
1781
+ // Build a richer modal with two textareas
1782
+ var bd = document.createElement('div'); bd.className = 'modal-bd';
1783
+ var html = '<div class="modal" style="width:600px">' +
1784
+ '<h3>Ask Sapper about this</h3>' +
1785
+ '<label>File</label>' +
1786
+ '<div class="hint" style="font-family:ui-monospace,monospace;color:var(--muted);font-size:11px">' +
1787
+ esc(opts.file) + esc(opts.lineNote || '') + '</div>' +
1788
+ '<label>Your comment / question</label>' +
1789
+ '<textarea id="askComment" placeholder="What should Sapper do with this? (e.g. \\'explain\\', \\'refactor\\', \\'why did this change?\\')" ' +
1790
+ 'style="width:100%;box-sizing:border-box;height:60px;background:var(--bg);color:var(--fg);' +
1791
+ 'border:1px solid var(--border);border-radius:4px;padding:7px 9px;' +
1792
+ 'font-family:inherit;font-size:12px;outline:none;resize:vertical"></textarea>' +
1793
+ '<label>Snippet (editable)</label>' +
1794
+ '<textarea id="askSnippet" spellcheck="false" ' +
1795
+ 'style="width:100%;box-sizing:border-box;height:240px;background:var(--bg);color:var(--fg);' +
1796
+ 'border:1px solid var(--border);border-radius:4px;padding:7px 9px;' +
1797
+ 'font-family:ui-monospace,\\'SF Mono\\',monospace;font-size:11.5px;line-height:1.5;' +
1798
+ 'outline:none;white-space:pre;overflow:auto;resize:vertical"></textarea>' +
1799
+ '<div class="actions">' +
1800
+ '<button id="askCancel">Cancel</button>' +
1801
+ '<button id="askSend" class="primary">Send to Sapper</button>' +
1802
+ '</div></div>';
1803
+ bd.innerHTML = html;
1804
+ document.body.appendChild(bd);
1805
+ var ta = bd.querySelector('#askSnippet');
1806
+ ta.value = opts.snippet;
1807
+ var ca = bd.querySelector('#askComment');
1808
+ ca.focus();
1809
+ var cancel = function(){ bd.remove(); };
1810
+ bd.querySelector('#askCancel').addEventListener('click', cancel);
1811
+ bd.addEventListener('click', function(e){ if (e.target === bd) cancel(); });
1812
+ bd.querySelector('#askSend').addEventListener('click', function(){
1813
+ var comment = ca.value.trim();
1814
+ var code = ta.value;
1815
+ if (!comment && !code) { cancel(); return; }
1816
+ var lang = opts.lang || '';
1817
+ var fence = BT + BT + BT;
1818
+ var msg = '';
1819
+ if (comment) msg += comment + '\\n\\n';
1820
+ msg += 'From ' + BT + opts.file + BT + (opts.lineNote || '') + ':\\n';
1821
+ msg += fence + lang + '\\n' + code + '\\n' + fence;
1822
+ var ok = sendPasteToTerm(msg);
1823
+ if (ok) { showToast('Sent to Sapper'); cancel(); term && term.focus(); }
1824
+ });
1825
+ // Cmd/Ctrl+Enter = send
1826
+ ca.addEventListener('keydown', function(e){
1827
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { bd.querySelector('#askSend').click(); }
1828
+ if (e.key === 'Escape') cancel();
1829
+ });
1830
+ }
1831
+ function ensureEditor() {
1832
+ if (cm || !window.CodeMirror) return cm;
1833
+ CodeMirror.modeURL = 'https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/%N/%N.min.js';
1834
+ var ta = document.getElementById('pedit');
1835
+ cm = CodeMirror.fromTextArea(ta, {
1836
+ lineNumbers: true,
1837
+ theme: 'material-darker',
1838
+ matchBrackets: true,
1839
+ autoCloseBrackets: true,
1840
+ styleActiveLine: true,
1841
+ indentUnit: 2,
1842
+ tabSize: 2,
1843
+ smartIndent: true,
1844
+ lineWrapping: false,
1845
+ extraKeys: {
1846
+ 'Cmd-S': function(){ window.saveEdit(); },
1847
+ 'Ctrl-S': function(){ window.saveEdit(); },
1848
+ 'Cmd-F': 'findPersistent',
1849
+ 'Ctrl-F': 'findPersistent',
1850
+ 'Esc': function(){ window.cancelEdit(); },
1851
+ 'Tab': function(c){ if (c.somethingSelected()) c.indentSelection('add'); else c.replaceSelection(Array(c.getOption('indentUnit')+1).join(' ')); }
1852
+ }
1853
+ });
1854
+ cm.on('cursorActivity', function(){
1855
+ var p = cm.getCursor();
1856
+ var el = document.getElementById('edPos');
1857
+ if (el) el.textContent = 'L' + (p.line+1) + ':' + (p.ch+1);
1858
+ });
1859
+ var lnBtn = document.getElementById('edLines');
1860
+ if (lnBtn) lnBtn.onclick = function(){ cm.setOption('lineNumbers', !cm.getOption('lineNumbers')); };
1861
+ var wrBtn = document.getElementById('edWrap');
1862
+ if (wrBtn) wrBtn.onclick = function(){ cm.setOption('lineWrapping', !cm.getOption('lineWrapping')); cm.refresh(); };
1863
+ return cm;
1864
+ }
1865
+
1866
+ // Extras CodeMirror's meta.js doesn't always cover well
1867
+ var EXTRA_MODES = {
1868
+ erl: { mime: 'text/x-erlang', mode: 'erlang' },
1869
+ hrl: { mime: 'text/x-erlang', mode: 'erlang' },
1870
+ ex: { mime: 'text/x-elixir', mode: 'elixir' }, // not bundled in CM5; falls back gracefully
1871
+ exs: { mime: 'text/x-elixir', mode: 'elixir' },
1872
+ rs: { mime: 'text/x-rustsrc', mode: 'rust' },
1873
+ kt: { mime: 'text/x-kotlin', mode: 'clike' },
1874
+ kts: { mime: 'text/x-kotlin', mode: 'clike' },
1875
+ swift:{ mime: 'text/x-swift', mode: 'swift' },
1876
+ dart:{ mime: 'application/dart', mode: 'dart' },
1877
+ zig: { mime: 'text/x-csrc', mode: 'clike' },
1878
+ toml:{ mime: 'text/x-toml', mode: 'toml' },
1879
+ vue: { mime: 'text/html', mode: 'htmlmixed' },
1880
+ svelte:{ mime: 'text/html', mode: 'htmlmixed' },
1881
+ mjs: { mime: 'application/javascript', mode: 'javascript' },
1882
+ cjs: { mime: 'application/javascript', mode: 'javascript' },
1883
+ jsx: { mime: 'text/jsx', mode: 'jsx' },
1884
+ tsx: { mime: 'text/typescript-jsx', mode: 'jsx' },
1885
+ ts: { mime: 'application/typescript', mode: 'javascript' },
1886
+ ipynb:{ mime: 'application/json', mode: 'javascript' },
1887
+ log: { mime: 'text/plain', mode: 'null' },
1888
+ env: { mime: 'text/x-sh', mode: 'shell' },
1889
+ dockerfile:{ mime: 'text/x-dockerfile', mode: 'dockerfile' }
1890
+ };
1891
+
1892
+ function setEditorMode(path) {
1893
+ if (!cm || !window.CodeMirror) return;
1894
+ var name = (path || '').split('/').pop() || '';
1895
+ var ext = (name.split('.').pop() || '').toLowerCase();
1896
+ var info = null;
1897
+ if (CodeMirror.findModeByFileName) info = CodeMirror.findModeByFileName(name);
1898
+ if (!info && EXTRA_MODES[ext]) info = EXTRA_MODES[ext];
1899
+ if (!info && CodeMirror.findModeByExtension) info = CodeMirror.findModeByExtension(ext);
1900
+ if (!info) info = { mime: 'text/plain', mode: 'null' };
1901
+ cm.setOption('mode', info.mime || info.mode);
1902
+ if (info.mode && info.mode !== 'null' && CodeMirror.autoLoadMode) {
1903
+ try { CodeMirror.autoLoadMode(cm, info.mode); } catch(e){}
1904
+ }
1905
+ var langEl = document.getElementById('edLang');
1906
+ if (langEl) langEl.textContent = (info.name || info.mode || 'text');
1907
+ }
1908
+
1415
1909
  // ─── Config tab ──────────────────────────────────────────────
1416
1910
  window.reloadConfig = function() {
1417
1911
  fetch('/api/config').then(function(r){return r.json();}).then(function(d){
@@ -1713,6 +2207,57 @@ window.sendOpenPrompt = async function() {
1713
2207
  connectPty();
1714
2208
  connectEvents();
1715
2209
  loadTree();
2210
+ setupResizers();
2211
+
2212
+ function setupResizers() {
2213
+ initResizer('sideRes', 'side', 'right'); // drag adjusts #side width
2214
+ initResizer('prevRes', 'preview', 'left'); // drag adjusts #preview width
2215
+ // Hide preview resizer while preview is hidden
2216
+ updateResizerVisibility();
2217
+ }
2218
+ function updateResizerVisibility() {
2219
+ var prev = document.getElementById('preview');
2220
+ var pr = document.getElementById('prevRes');
2221
+ if (pr) pr.classList.toggle('hidden', prev.classList.contains('hidden'));
2222
+ var side = document.getElementById('side');
2223
+ var sr = document.getElementById('sideRes');
2224
+ if (sr) sr.classList.toggle('hidden', side.classList.contains('hidden'));
2225
+ }
2226
+ function initResizer(barId, paneId, edge) {
2227
+ var bar = document.getElementById(barId);
2228
+ var pane = document.getElementById(paneId);
2229
+ if (!bar || !pane) return;
2230
+ bar.addEventListener('mousedown', function(ev){
2231
+ if (pane.classList.contains('hidden')) return;
2232
+ ev.preventDefault();
2233
+ bar.classList.add('active');
2234
+ document.body.classList.add('resizing');
2235
+ var startX = ev.clientX;
2236
+ var startW = pane.getBoundingClientRect().width;
2237
+ function move(e){
2238
+ var dx = e.clientX - startX;
2239
+ var w = edge === 'right' ? startW + dx : startW - dx;
2240
+ w = Math.max(180, Math.min(window.innerWidth - 320, w));
2241
+ pane.style.width = w + 'px';
2242
+ try { fit.fit(); } catch(e){}
2243
+ if (cm) try { cm.refresh(); } catch(e){}
2244
+ }
2245
+ function up(){
2246
+ document.removeEventListener('mousemove', move);
2247
+ document.removeEventListener('mouseup', up);
2248
+ bar.classList.remove('active');
2249
+ document.body.classList.remove('resizing');
2250
+ }
2251
+ document.addEventListener('mousemove', move);
2252
+ document.addEventListener('mouseup', up);
2253
+ });
2254
+ // double-click to reset
2255
+ bar.addEventListener('dblclick', function(){
2256
+ pane.style.width = '';
2257
+ try { fit.fit(); } catch(e){}
2258
+ if (cm) try { cm.refresh(); } catch(e){}
2259
+ });
2260
+ }
1716
2261
  </script>
1717
2262
  </body>
1718
2263
  </html>`;
@@ -1787,8 +2332,38 @@ const server = http.createServer(async (req, res) => {
1787
2332
  if (stat.size > 2 * 1024 * 1024) return json(res, { error: 'file too large (>2MB)', size: stat.size, binary: true }, 200);
1788
2333
  const buf = fs.readFileSync(abs);
1789
2334
  if (looksBinary(buf)) return json(res, { binary: true, size: stat.size });
1790
- return json(res, { content: buf.toString('utf8'), size: stat.size });
2335
+ const text = buf.toString('utf8');
2336
+ // seed snapshot so a subsequent edit can produce a diff
2337
+ if (!snapshots.has(rel) && stat.size <= SNAP_MAX_BYTES) {
2338
+ snapshots.set(rel, { prev: null, curr: text });
2339
+ }
2340
+ return json(res, { content: text, size: stat.size });
2341
+ } catch (e) { return json(res, { error: e.message }, 500); }
2342
+ }
2343
+
2344
+ // ── Diff: compare last-known snapshot vs current content
2345
+ if (req.method === 'GET' && path === '/api/diff') {
2346
+ const rel = url.searchParams.get('path') || '';
2347
+ const abs = safePath(rel);
2348
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
2349
+ const snap = snapshots.get(rel);
2350
+ let curr = '';
2351
+ try {
2352
+ if (fs.existsSync(abs)) {
2353
+ const st = fs.statSync(abs);
2354
+ if (st.isFile() && st.size <= SNAP_MAX_BYTES) {
2355
+ const buf = fs.readFileSync(abs);
2356
+ if (!looksBinary(buf)) curr = buf.toString('utf8');
2357
+ else return json(res, { error: 'binary file' }, 200);
2358
+ } else if (st.size > SNAP_MAX_BYTES) {
2359
+ return json(res, { error: 'file too large for diff (>' + Math.round(SNAP_MAX_BYTES/1024) + 'KB)' }, 200);
2360
+ }
2361
+ }
1791
2362
  } catch (e) { return json(res, { error: e.message }, 500); }
2363
+ if (!snap || snap.prev == null) {
2364
+ return json(res, { prev: null, curr, message: 'No prior snapshot — open the file again before the next change to enable diff.' });
2365
+ }
2366
+ return json(res, { prev: snap.prev, curr });
1792
2367
  }
1793
2368
 
1794
2369
  // ── File raw (images)
@@ -2049,6 +2624,52 @@ const eventsClients = new Set();
2049
2624
  const recentEvents = new Map(); // path -> timestamp (dedupe burst events)
2050
2625
  const knownPaths = new Set(); // paths we have seen exist (for create vs delete detection)
2051
2626
  const recentActivity = []; // last N classified events for late-joining clients
2627
+ const SNAP_MAX_BYTES = 512 * 1024; // per-file snapshot cap (512KB)
2628
+ const SNAP_MAX_FILES = 200;
2629
+ const snapshots = new Map(); // path -> { prev: string|null, curr: string }
2630
+
2631
+ function isSnapshottable(abs) {
2632
+ try {
2633
+ const st = fs.statSync(abs);
2634
+ if (!st.isFile()) return false;
2635
+ if (st.size > SNAP_MAX_BYTES) return false;
2636
+ return true;
2637
+ } catch { return false; }
2638
+ }
2639
+
2640
+ function readTextMaybe(abs) {
2641
+ try {
2642
+ const buf = fs.readFileSync(abs);
2643
+ // Quick binary probe: count NULs in first 4KB
2644
+ const slice = buf.subarray(0, Math.min(buf.length, 4096));
2645
+ for (let i = 0; i < slice.length; i++) if (slice[i] === 0) return null;
2646
+ return buf.toString('utf8');
2647
+ } catch { return null; }
2648
+ }
2649
+
2650
+ function bumpSnapshot(rel, abs, kind) {
2651
+ if (kind === 'deleted') {
2652
+ const prev = snapshots.get(rel);
2653
+ if (prev) snapshots.set(rel, { prev: prev.curr, curr: '' });
2654
+ return;
2655
+ }
2656
+ if (!isSnapshottable(abs)) return;
2657
+ const text = readTextMaybe(abs);
2658
+ if (text == null) return;
2659
+ const existing = snapshots.get(rel);
2660
+ if (existing) {
2661
+ if (existing.curr === text) return; // no actual change
2662
+ snapshots.set(rel, { prev: existing.curr, curr: text });
2663
+ } else {
2664
+ // first time we see this file — no prior version available
2665
+ snapshots.set(rel, { prev: null, curr: text });
2666
+ }
2667
+ // simple LRU-ish cap
2668
+ if (snapshots.size > SNAP_MAX_FILES) {
2669
+ const firstKey = snapshots.keys().next().value;
2670
+ if (firstKey) snapshots.delete(firstKey);
2671
+ }
2672
+ }
2052
2673
 
2053
2674
  function classifyEvent(rawEvent, rel, abs) {
2054
2675
  // fs.watch only gives 'rename' or 'change'
@@ -2093,6 +2714,8 @@ function startWatcher() {
2093
2714
  const kind = classifyEvent(event, rel, abs);
2094
2715
  if (kind === 'deleted') knownPaths.delete(rel);
2095
2716
  else knownPaths.add(rel);
2717
+ // capture old/new content snapshot for diff (text files only, async-safe)
2718
+ try { bumpSnapshot(rel, abs, kind); } catch {}
2096
2719
  let isDir = false;
2097
2720
  try { isDir = fs.statSync(abs).isDirectory(); } catch {}
2098
2721
  const enriched = { event, kind, path: rel, isDir, ts: now };