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.
- package/package.json +1 -1
- package/sapper-ui.mjs +644 -21
package/package.json
CHANGED
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">💬 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">📄</div>Open a file from the sidebar.</div></div>
|
|
614
|
-
<
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
@@ -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
|
-
|
|
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
|
-
|
|
850
|
-
|
|
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 ? ' ×' + a.count : '';
|
|
853
|
-
|
|
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
|
|
857
|
-
|
|
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 ? '✎' : '💬') + '</button>' +
|
|
955
|
+
'<button class="ab danger" data-act="dismiss" title="Dismiss this change">×</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
|
|
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
|
|
867
|
-
soFar = soFar ? soFar + '/' + parts[
|
|
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: '✕ Dismiss change mark', fn: function(){ dismissPathMarks(path); } });
|
|
1276
|
+
items.push({ label: '💬 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
|
-
//
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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 };
|