sapper-iq 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/sapper-ui.mjs +507 -10
package/package.json
CHANGED
package/sapper-ui.mjs
CHANGED
|
@@ -245,6 +245,33 @@ function buildHTML() {
|
|
|
245
245
|
position: relative; }
|
|
246
246
|
.files-toolbar .ftb sup { font-size: 9px; color: var(--green); margin-left: 1px; }
|
|
247
247
|
.files-toolbar .ftb:hover { background: rgba(255,255,255,.05); color: var(--fg); border-color: var(--border); }
|
|
248
|
+
.files-toolbar .ftb.on { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,.08); }
|
|
249
|
+
|
|
250
|
+
/* Activity feed */
|
|
251
|
+
#activityPanel { display: none; border-bottom: 1px solid var(--border);
|
|
252
|
+
background: var(--panel2); max-height: 180px; overflow-y: auto;
|
|
253
|
+
font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px; }
|
|
254
|
+
#activityPanel.on { display: block; }
|
|
255
|
+
#activityPanel .ah { display: flex; align-items: center; padding: 5px 10px;
|
|
256
|
+
border-bottom: 1px solid var(--border); color: var(--dim); font-size: 10px;
|
|
257
|
+
text-transform: uppercase; letter-spacing: .5px; position: sticky; top: 0;
|
|
258
|
+
background: var(--panel2); z-index: 1; }
|
|
259
|
+
#activityPanel .ah .acl { margin-left: auto; color: var(--accent); cursor: pointer;
|
|
260
|
+
text-transform: none; letter-spacing: 0; font-size: 10px; }
|
|
261
|
+
#activityPanel .ai { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
|
|
262
|
+
color: var(--muted); cursor: pointer; border-left: 2px solid transparent; }
|
|
263
|
+
#activityPanel .ai:hover { background: rgba(255,255,255,.04); color: var(--fg); }
|
|
264
|
+
#activityPanel .ai .ak { font-size: 9px; text-transform: uppercase; letter-spacing: .5px;
|
|
265
|
+
width: 56px; flex-shrink: 0; font-weight: 600; }
|
|
266
|
+
#activityPanel .ai .ap { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
267
|
+
#activityPanel .ai .at { color: var(--dim); font-size: 9px; flex-shrink: 0; }
|
|
268
|
+
#activityPanel .ai.kind-created { border-left-color: var(--green); }
|
|
269
|
+
#activityPanel .ai.kind-modified { border-left-color: var(--yellow); }
|
|
270
|
+
#activityPanel .ai.kind-deleted { border-left-color: var(--red); }
|
|
271
|
+
#activityPanel .ai.kind-created .ak { color: var(--green); }
|
|
272
|
+
#activityPanel .ai.kind-modified .ak { color: var(--yellow); }
|
|
273
|
+
#activityPanel .ai.kind-deleted .ak { color: var(--red); }
|
|
274
|
+
#activityPanel .empty { padding: 12px; color: var(--dim); text-align: center; font-size: 11px; }
|
|
248
275
|
.tree { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; padding-bottom: 12px; }
|
|
249
276
|
.row { display: flex; align-items: center; gap: 4px; padding: 3px 8px; cursor: pointer; color: var(--muted);
|
|
250
277
|
white-space: nowrap; user-select: none; position: relative; }
|
|
@@ -255,6 +282,19 @@ function buildHTML() {
|
|
|
255
282
|
.row .name { overflow: hidden; text-overflow: ellipsis; }
|
|
256
283
|
.row .badge { margin-left: auto; font-size: 9px; color: var(--yellow); opacity: 0; transition: opacity .2s; }
|
|
257
284
|
.row.changed .badge { opacity: 1; }
|
|
285
|
+
.row .actdot { display: none; width: 7px; height: 7px; border-radius: 50%;
|
|
286
|
+
margin-left: 4px; flex-shrink: 0; box-shadow: 0 0 6px currentColor; }
|
|
287
|
+
.row.act-created .actdot { display: inline-block; background: var(--green); color: var(--green); }
|
|
288
|
+
.row.act-modified .actdot { display: inline-block; background: var(--yellow); color: var(--yellow); }
|
|
289
|
+
.row.act-deleted .actdot { display: inline-block; background: var(--red); color: var(--red); }
|
|
290
|
+
.row.act-fresh .actdot { animation: pulse 1.4s ease-out 2; }
|
|
291
|
+
.row.act-created .name { color: #56d364; }
|
|
292
|
+
.row.act-modified .name { color: #e3b341; }
|
|
293
|
+
.row.act-deleted .name { color: #ffa198; text-decoration: line-through; opacity: .7; }
|
|
294
|
+
@keyframes pulse { 0%{transform:scale(1);} 50%{transform:scale(1.6);} 100%{transform:scale(1);} }
|
|
295
|
+
.row .actcount { display: none; font-size: 9px; color: var(--dim);
|
|
296
|
+
font-family: ui-monospace, monospace; margin-left: 2px; }
|
|
297
|
+
.row.act-multi .actcount { display: inline-block; }
|
|
258
298
|
.row .rmenu { margin-left: auto; color: var(--dim); font-size: 14px; padding: 0 4px;
|
|
259
299
|
opacity: 0; flex-shrink: 0; line-height: 1; border-radius: 3px; }
|
|
260
300
|
.row.changed .rmenu { margin-left: 4px; }
|
|
@@ -344,7 +384,24 @@ function buildHTML() {
|
|
|
344
384
|
|
|
345
385
|
/* ─── Terminal area ─── */
|
|
346
386
|
#center { flex: 1; min-width: 0; min-height: 0; display: flex;
|
|
347
|
-
flex-direction: column; background: var(--bg); overflow: hidden; }
|
|
387
|
+
flex-direction: column; background: var(--bg); overflow: hidden; position: relative; }
|
|
388
|
+
#qa { display: flex; align-items: center; gap: 6px; padding: 6px 10px;
|
|
389
|
+
background: var(--panel2); border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
|
390
|
+
#qa .qabtn { background: transparent; color: var(--muted); border: 1px solid var(--border);
|
|
391
|
+
border-radius: 5px; padding: 4px 10px; font-size: 11px; cursor: pointer;
|
|
392
|
+
display: inline-flex; align-items: center; gap: 5px; font-family: inherit; line-height: 1; }
|
|
393
|
+
#qa .qabtn:hover { color: var(--accent); border-color: var(--accent); }
|
|
394
|
+
#qa .qabtn .qaico { font-size: 13px; }
|
|
395
|
+
#qa .qabtn.rec.on { color: var(--red); border-color: var(--red); background: rgba(248,81,73,.08); }
|
|
396
|
+
#qa .qa-sp { flex: 1; }
|
|
397
|
+
#qa .rec-dot { display: none; width: 8px; height: 8px; border-radius: 50%;
|
|
398
|
+
background: var(--red); animation: blink 1s infinite; }
|
|
399
|
+
#qa .rec-dot.on { display: inline-block; }
|
|
400
|
+
#qa .rec-time { display: none; font-family: ui-monospace, 'SF Mono', monospace;
|
|
401
|
+
font-size: 11px; color: var(--red); font-variant-numeric: tabular-nums; }
|
|
402
|
+
#qa .rec-time.on { display: inline-block; }
|
|
403
|
+
@keyframes blink { 0%,100%{opacity:1;} 50%{opacity:.3;} }
|
|
404
|
+
|
|
348
405
|
#term-wrap { flex: 1; min-height: 0; min-width: 0; padding: 6px 0 0 10px;
|
|
349
406
|
overflow: hidden; position: relative; }
|
|
350
407
|
#term-wrap .terminal, #term-wrap .xterm { height: 100% !important; width: 100% !important; }
|
|
@@ -354,6 +411,16 @@ function buildHTML() {
|
|
|
354
411
|
.xterm-viewport::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
|
|
355
412
|
.xterm-viewport::-webkit-scrollbar-track { background: transparent; }
|
|
356
413
|
|
|
414
|
+
/* drag-drop overlay */
|
|
415
|
+
#dropOverlay { position: absolute; inset: 0; display: none; z-index: 200;
|
|
416
|
+
background: rgba(10,14,20,.85); align-items: center; justify-content: center;
|
|
417
|
+
border: 2px dashed var(--accent); pointer-events: none; }
|
|
418
|
+
#dropOverlay.on { display: flex; }
|
|
419
|
+
#dropOverlay .drop-card { text-align: center; }
|
|
420
|
+
#dropOverlay .drop-icon { font-size: 48px; margin-bottom: 8px; }
|
|
421
|
+
#dropOverlay .drop-text { color: var(--accent); font-size: 16px; font-weight: 600; }
|
|
422
|
+
#dropOverlay .drop-text span { color: var(--muted); font-weight: 400; font-size: 12px; }
|
|
423
|
+
|
|
357
424
|
/* ─── Preview panel ─── */
|
|
358
425
|
#preview {
|
|
359
426
|
width: 480px; flex-shrink: 0; display: flex; flex-direction: column;
|
|
@@ -462,10 +529,16 @@ function buildHTML() {
|
|
|
462
529
|
<div class="files-toolbar">
|
|
463
530
|
<button class="ftb" title="New file" onclick="newItemPrompt('file','')">🗎<sup>+</sup></button>
|
|
464
531
|
<button class="ftb" title="New folder" onclick="newItemPrompt('folder','')">📁<sup>+</sup></button>
|
|
532
|
+
<button class="ftb" id="ftbAct" title="Show activity log" onclick="toggleActivity()">☉</button>
|
|
465
533
|
<span class="ftb-spacer"></span>
|
|
534
|
+
<button class="ftb" title="Clear change marks" onclick="clearAllMarks()">✕</button>
|
|
466
535
|
<button class="ftb" title="Refresh tree" onclick="loadTree()">↺</button>
|
|
467
536
|
<button class="ftb" title="Collapse all" onclick="collapseAll()">⇤</button>
|
|
468
537
|
</div>
|
|
538
|
+
<div id="activityPanel">
|
|
539
|
+
<div class="ah">Recent activity<span class="acl" onclick="clearActivity()">clear</span></div>
|
|
540
|
+
<div id="activityList"></div>
|
|
541
|
+
</div>
|
|
469
542
|
<div class="tree" id="tree"></div>
|
|
470
543
|
</div>
|
|
471
544
|
<div class="pane" id="pane-config">
|
|
@@ -501,7 +574,28 @@ function buildHTML() {
|
|
|
501
574
|
|
|
502
575
|
<!-- Center: terminal -->
|
|
503
576
|
<main id="center">
|
|
577
|
+
<div id="qa">
|
|
578
|
+
<button class="qabtn" title="Attach files (sends @path to Sapper)" onclick="pickAndUpload()">
|
|
579
|
+
<span class="qaico">📎</span><span class="qalbl">Attach</span>
|
|
580
|
+
</button>
|
|
581
|
+
<button class="qabtn rec" title="Record voice (auto-transcribed by Sapper)" onclick="toggleRecord()" id="qaRec">
|
|
582
|
+
<span class="qaico">🎤</span><span class="qalbl">Record</span>
|
|
583
|
+
</button>
|
|
584
|
+
<span id="recDot" class="rec-dot"></span>
|
|
585
|
+
<span id="recTime" class="rec-time"></span>
|
|
586
|
+
<span class="qa-sp"></span>
|
|
587
|
+
<button class="qabtn" title="Send /attach (interactive)" onclick="sendCmd('/attach')">/attach</button>
|
|
588
|
+
<button class="qabtn" title="Open file by path" onclick="sendOpenPrompt()">/open</button>
|
|
589
|
+
<button class="qabtn" title="Compact context" onclick="sendCmd('/summary')">/summary</button>
|
|
590
|
+
<input type="file" id="qaFile" multiple style="display:none">
|
|
591
|
+
</div>
|
|
504
592
|
<div id="term-wrap"></div>
|
|
593
|
+
<div id="dropOverlay">
|
|
594
|
+
<div class="drop-card">
|
|
595
|
+
<div class="drop-icon">📥</div>
|
|
596
|
+
<div class="drop-text">Drop files to upload<br><span>They will be attached to Sapper with <code>@path</code></span></div>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
505
599
|
</main>
|
|
506
600
|
|
|
507
601
|
<!-- Right: preview -->
|
|
@@ -543,6 +637,9 @@ var state = {
|
|
|
543
637
|
editing: false,
|
|
544
638
|
expanded: { '': true },
|
|
545
639
|
fsWS: null,
|
|
640
|
+
marks: {}, // path -> { kind, count, ts }
|
|
641
|
+
activity: [], // ordered list of {kind, path, isDir, ts}
|
|
642
|
+
activityOpen: false,
|
|
546
643
|
};
|
|
547
644
|
|
|
548
645
|
function esc(s) {
|
|
@@ -679,20 +776,21 @@ function handleStats(msg) {
|
|
|
679
776
|
}
|
|
680
777
|
|
|
681
778
|
function handleFsEvent(msg) {
|
|
682
|
-
if (!msg
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
row.classList.add('changed');
|
|
687
|
-
setTimeout(function(){ row.classList.remove('changed'); }, 4000);
|
|
779
|
+
if (!msg) return;
|
|
780
|
+
if (msg.type === 'activity-replay') {
|
|
781
|
+
if (Array.isArray(msg.items)) msg.items.forEach(applyActivityItem);
|
|
782
|
+
return;
|
|
688
783
|
}
|
|
689
|
-
|
|
690
|
-
|
|
784
|
+
if (!msg.path) return;
|
|
785
|
+
applyActivityItem(msg);
|
|
786
|
+
// Re-fetch tree (parent dir) for create/delete so the new/removed file appears
|
|
787
|
+
if (msg.kind === 'created' || msg.kind === 'deleted') {
|
|
691
788
|
var parent = msg.path.split('/').slice(0, -1).join('/');
|
|
692
789
|
refreshDir(parent);
|
|
693
790
|
}
|
|
694
791
|
// If the current preview file changed, auto-refresh (or show indicator if editing)
|
|
695
792
|
if (state.currentFile === msg.path) {
|
|
793
|
+
if (msg.kind === 'deleted') return; // file gone; leave preview state
|
|
696
794
|
if (state.editing) {
|
|
697
795
|
document.getElementById('pInd').classList.add('show');
|
|
698
796
|
} else {
|
|
@@ -701,6 +799,119 @@ function handleFsEvent(msg) {
|
|
|
701
799
|
}
|
|
702
800
|
}
|
|
703
801
|
|
|
802
|
+
function applyActivityItem(item) {
|
|
803
|
+
// bump persistent mark
|
|
804
|
+
var prev = state.marks[item.path];
|
|
805
|
+
var count = prev ? (prev.count + 1) : 1;
|
|
806
|
+
state.marks[item.path] = { kind: item.kind, count: count, ts: item.ts };
|
|
807
|
+
var row = document.querySelector('.row[data-path="' + cssEscape(item.path) + '"]');
|
|
808
|
+
if (row) applyMark(row, state.marks[item.path]);
|
|
809
|
+
// push into activity log (dedupe consecutive entries for same path)
|
|
810
|
+
var last = state.activity[state.activity.length - 1];
|
|
811
|
+
if (last && last.path === item.path && last.kind === item.kind && (item.ts - last.ts) < 1500) {
|
|
812
|
+
last.count = (last.count || 1) + 1;
|
|
813
|
+
last.ts = item.ts;
|
|
814
|
+
} else {
|
|
815
|
+
state.activity.push({ kind: item.kind, path: item.path, isDir: item.isDir, ts: item.ts, count: 1 });
|
|
816
|
+
if (state.activity.length > 100) state.activity.shift();
|
|
817
|
+
}
|
|
818
|
+
renderActivity();
|
|
819
|
+
// Highlight parent dirs subtly so user notices nested change even when collapsed
|
|
820
|
+
var parts = item.path.split('/');
|
|
821
|
+
for (var i = 1; i < parts.length; i++) {
|
|
822
|
+
var dirPath = parts.slice(0, i).join('/');
|
|
823
|
+
var dirRow = document.querySelector('.row[data-path="' + cssEscape(dirPath) + '"]');
|
|
824
|
+
if (dirRow && !dirRow.classList.contains('act-created') && !dirRow.classList.contains('act-modified') && !dirRow.classList.contains('act-deleted')) {
|
|
825
|
+
dirRow.classList.add('act-modified', 'act-fresh');
|
|
826
|
+
setTimeout((function(r){ return function(){ r.classList.remove('act-fresh'); }; })(dirRow), 1500);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function applyMark(row, mark) {
|
|
832
|
+
row.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
|
|
833
|
+
row.classList.add('act-' + mark.kind, 'act-fresh');
|
|
834
|
+
if (mark.count > 1) {
|
|
835
|
+
row.classList.add('act-multi');
|
|
836
|
+
var cnt = row.querySelector('.actcount');
|
|
837
|
+
if (cnt) cnt.textContent = String(mark.count);
|
|
838
|
+
}
|
|
839
|
+
setTimeout(function(){ row.classList.remove('act-fresh'); }, 1500);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function renderActivity() {
|
|
843
|
+
var host = document.getElementById('activityList');
|
|
844
|
+
if (!host) return;
|
|
845
|
+
if (!state.activity.length) {
|
|
846
|
+
host.innerHTML = '<div class="empty">No changes yet. Ask Sapper to edit something.</div>';
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
var items = state.activity.slice(-30).reverse();
|
|
850
|
+
host.innerHTML = items.map(function(a){
|
|
851
|
+
var rel = relTime(a.ts);
|
|
852
|
+
var ct = a.count > 1 ? ' ×' + a.count : '';
|
|
853
|
+
return '<div class="ai kind-' + a.kind + '" data-path="' + esc(a.path) + '">' +
|
|
854
|
+
'<span class="ak">' + a.kind + ct + '</span>' +
|
|
855
|
+
'<span class="ap">' + esc(a.path) + '</span>' +
|
|
856
|
+
'<span class="at">' + rel + '</span></div>';
|
|
857
|
+
}).join('');
|
|
858
|
+
Array.from(host.querySelectorAll('.ai')).forEach(function(el){
|
|
859
|
+
el.addEventListener('click', function(){
|
|
860
|
+
var p = el.dataset.path;
|
|
861
|
+
var mark = state.marks[p];
|
|
862
|
+
if (mark && mark.kind === 'deleted') { showToast(p + ' (deleted)'); return; }
|
|
863
|
+
// expand ancestor dirs then open
|
|
864
|
+
var parts = p.split('/');
|
|
865
|
+
var soFar = '';
|
|
866
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
867
|
+
soFar = soFar ? soFar + '/' + parts[i] : parts[i];
|
|
868
|
+
state.expanded[soFar] = true;
|
|
869
|
+
}
|
|
870
|
+
loadTree();
|
|
871
|
+
setTimeout(function(){ openFile(p); }, 80);
|
|
872
|
+
// clear that file's mark since the user has acknowledged it
|
|
873
|
+
clearMark(p);
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function relTime(ts) {
|
|
879
|
+
var s = Math.floor((Date.now() - ts) / 1000);
|
|
880
|
+
if (s < 5) return 'now';
|
|
881
|
+
if (s < 60) return s + 's';
|
|
882
|
+
if (s < 3600) return Math.floor(s / 60) + 'm';
|
|
883
|
+
return Math.floor(s / 3600) + 'h';
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function clearMark(path) {
|
|
887
|
+
delete state.marks[path];
|
|
888
|
+
var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
|
|
889
|
+
if (row) row.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
window.toggleActivity = function() {
|
|
893
|
+
state.activityOpen = !state.activityOpen;
|
|
894
|
+
document.getElementById('activityPanel').classList.toggle('on', state.activityOpen);
|
|
895
|
+
document.getElementById('ftbAct').classList.toggle('on', state.activityOpen);
|
|
896
|
+
if (state.activityOpen) renderActivity();
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
window.clearActivity = function() {
|
|
900
|
+
state.activity = [];
|
|
901
|
+
renderActivity();
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
window.clearAllMarks = function() {
|
|
905
|
+
state.marks = {};
|
|
906
|
+
document.querySelectorAll('.row').forEach(function(r){
|
|
907
|
+
r.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
|
|
908
|
+
});
|
|
909
|
+
showToast('Cleared change marks');
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
// Periodically refresh "rel time" labels in the activity panel
|
|
913
|
+
setInterval(function(){ if (state.activityOpen) renderActivity(); }, 30000);
|
|
914
|
+
|
|
704
915
|
function cssEscape(s) { return s.replace(/(["\\\\])/g, '\\\\$1'); }
|
|
705
916
|
|
|
706
917
|
// ─── Sidebar tabs ────────────────────────────────────────────
|
|
@@ -785,6 +996,8 @@ function renderEntries(container, basePath, entries, depth) {
|
|
|
785
996
|
'<span class="chev">' + chev + '</span>' +
|
|
786
997
|
'<span class="ico">' + fileIcon(entry.name, entry.isDir) + '</span>' +
|
|
787
998
|
'<span class="name">' + esc(entry.name) + '</span>' +
|
|
999
|
+
'<span class="actdot"></span>' +
|
|
1000
|
+
'<span class="actcount"></span>' +
|
|
788
1001
|
'<span class="badge">●</span>' +
|
|
789
1002
|
'<span class="rmenu" title="Options">⋯</span>';
|
|
790
1003
|
row.addEventListener('click', function(ev){
|
|
@@ -801,6 +1014,9 @@ function renderEntries(container, basePath, entries, depth) {
|
|
|
801
1014
|
openRowMenu({ getBoundingClientRect: function(){ return { left: ev.clientX, bottom: ev.clientY, right: ev.clientX, top: ev.clientY }; } }, path, entry.isDir);
|
|
802
1015
|
});
|
|
803
1016
|
container.appendChild(row);
|
|
1017
|
+
// Re-apply any persistent activity mark for this path
|
|
1018
|
+
var m = state.marks[path];
|
|
1019
|
+
if (m) applyMark(row, m);
|
|
804
1020
|
if (entry.isDir && state.expanded[path]) {
|
|
805
1021
|
// Load children if not already loaded
|
|
806
1022
|
fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
|
|
@@ -1055,6 +1271,8 @@ window.openFile = function(path, isReload) {
|
|
|
1055
1271
|
document.querySelectorAll('.row.active').forEach(function(r){ r.classList.remove('active'); });
|
|
1056
1272
|
var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
|
|
1057
1273
|
if (row) row.classList.add('active');
|
|
1274
|
+
// Clear any pending change-mark for this file since the user is acknowledging it
|
|
1275
|
+
if (!isReload && state.marks[path]) clearMark(path);
|
|
1058
1276
|
|
|
1059
1277
|
state.currentFile = path;
|
|
1060
1278
|
state.editing = false;
|
|
@@ -1298,6 +1516,199 @@ function loadSkills() {
|
|
|
1298
1516
|
});
|
|
1299
1517
|
}
|
|
1300
1518
|
|
|
1519
|
+
// ─── Quick actions: upload + voice record ────────────────────
|
|
1520
|
+
|
|
1521
|
+
function uploadBlob(blob, filename, targetDir) {
|
|
1522
|
+
return fetch('/api/upload', {
|
|
1523
|
+
method: 'POST',
|
|
1524
|
+
headers: {
|
|
1525
|
+
'Content-Type': blob.type || 'application/octet-stream',
|
|
1526
|
+
'X-Filename': encodeURIComponent(filename),
|
|
1527
|
+
'X-Target-Dir': encodeURIComponent(targetDir || 'uploads'),
|
|
1528
|
+
},
|
|
1529
|
+
body: blob,
|
|
1530
|
+
}).then(function(r){ return r.json(); }).then(function(d){
|
|
1531
|
+
if (d.error) throw new Error(d.error);
|
|
1532
|
+
return d.path;
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
window.pickAndUpload = function() {
|
|
1537
|
+
var inp = document.getElementById('qaFile');
|
|
1538
|
+
inp.value = '';
|
|
1539
|
+
inp.onchange = function() {
|
|
1540
|
+
var files = Array.from(inp.files || []);
|
|
1541
|
+
if (!files.length) return;
|
|
1542
|
+
uploadFileList(files, 'uploads');
|
|
1543
|
+
};
|
|
1544
|
+
inp.click();
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
async function uploadFileList(files, targetDir) {
|
|
1548
|
+
var paths = [];
|
|
1549
|
+
for (var i = 0; i < files.length; i++) {
|
|
1550
|
+
var f = files[i];
|
|
1551
|
+
showToast('Uploading ' + f.name + '…');
|
|
1552
|
+
try {
|
|
1553
|
+
var p = await uploadBlob(f, f.name, targetDir);
|
|
1554
|
+
paths.push(p);
|
|
1555
|
+
} catch (e) {
|
|
1556
|
+
showToast('Upload failed: ' + e.message, 'err');
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (paths.length) {
|
|
1560
|
+
loadTree();
|
|
1561
|
+
// Send "@path1 @path2 " to terminal so user can keep typing
|
|
1562
|
+
if (ws && ws.readyState === 1) {
|
|
1563
|
+
ws.send(paths.map(function(p){ return '@' + p; }).join(' ') + ' ');
|
|
1564
|
+
}
|
|
1565
|
+
showToast(paths.length + ' file' + (paths.length > 1 ? 's' : '') + ' attached');
|
|
1566
|
+
term.focus();
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// ─── Drag-drop on terminal area ──────────────────────────────
|
|
1571
|
+
(function setupDropZone(){
|
|
1572
|
+
var center = document.getElementById('center');
|
|
1573
|
+
var ov = document.getElementById('dropOverlay');
|
|
1574
|
+
var depth = 0;
|
|
1575
|
+
function show(){ ov.classList.add('on'); }
|
|
1576
|
+
function hide(){ ov.classList.remove('on'); depth = 0; }
|
|
1577
|
+
center.addEventListener('dragenter', function(e){
|
|
1578
|
+
if (!e.dataTransfer || !e.dataTransfer.types || e.dataTransfer.types.indexOf('Files') < 0) return;
|
|
1579
|
+
e.preventDefault(); depth++; show();
|
|
1580
|
+
});
|
|
1581
|
+
center.addEventListener('dragover', function(e){
|
|
1582
|
+
if (!e.dataTransfer || !e.dataTransfer.types || e.dataTransfer.types.indexOf('Files') < 0) return;
|
|
1583
|
+
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
|
|
1584
|
+
});
|
|
1585
|
+
center.addEventListener('dragleave', function(e){
|
|
1586
|
+
depth--; if (depth <= 0) hide();
|
|
1587
|
+
});
|
|
1588
|
+
center.addEventListener('drop', function(e){
|
|
1589
|
+
if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) { hide(); return; }
|
|
1590
|
+
e.preventDefault(); hide();
|
|
1591
|
+
uploadFileList(Array.from(e.dataTransfer.files), 'uploads');
|
|
1592
|
+
});
|
|
1593
|
+
})();
|
|
1594
|
+
|
|
1595
|
+
// ─── Audio recording (16 kHz mono WAV for Whisper) ───────────
|
|
1596
|
+
var recState = null;
|
|
1597
|
+
|
|
1598
|
+
window.toggleRecord = async function() {
|
|
1599
|
+
if (recState) return stopRecording();
|
|
1600
|
+
await startRecording();
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
async function startRecording() {
|
|
1604
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
1605
|
+
showToast('Microphone API not available (use HTTPS or localhost)', 'err');
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
try {
|
|
1609
|
+
var stream = await navigator.mediaDevices.getUserMedia({
|
|
1610
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
1611
|
+
});
|
|
1612
|
+
var Ctx = window.AudioContext || window.webkitAudioContext;
|
|
1613
|
+
var ctx = new Ctx({ sampleRate: 16000 });
|
|
1614
|
+
// Resume in case of autoplay policy
|
|
1615
|
+
if (ctx.state === 'suspended') { try { await ctx.resume(); } catch(_){} }
|
|
1616
|
+
var src = ctx.createMediaStreamSource(stream);
|
|
1617
|
+
var proc = ctx.createScriptProcessor(4096, 1, 1);
|
|
1618
|
+
var chunks = [];
|
|
1619
|
+
proc.onaudioprocess = function(e) {
|
|
1620
|
+
var d = e.inputBuffer.getChannelData(0);
|
|
1621
|
+
chunks.push(new Float32Array(d));
|
|
1622
|
+
};
|
|
1623
|
+
src.connect(proc);
|
|
1624
|
+
proc.connect(ctx.destination);
|
|
1625
|
+
var startedAt = Date.now();
|
|
1626
|
+
recState = { stream: stream, ctx: ctx, src: src, proc: proc, chunks: chunks, sr: ctx.sampleRate, startedAt: startedAt };
|
|
1627
|
+
document.getElementById('qaRec').classList.add('on');
|
|
1628
|
+
document.getElementById('recDot').classList.add('on');
|
|
1629
|
+
document.getElementById('recTime').classList.add('on');
|
|
1630
|
+
recState.timer = setInterval(function(){
|
|
1631
|
+
var sec = Math.floor((Date.now() - startedAt) / 1000);
|
|
1632
|
+
var m = Math.floor(sec / 60), s = sec % 60;
|
|
1633
|
+
document.getElementById('recTime').textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
|
1634
|
+
}, 250);
|
|
1635
|
+
showToast('Recording… click again to stop');
|
|
1636
|
+
} catch (e) {
|
|
1637
|
+
showToast('Mic permission: ' + e.message, 'err');
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
async function stopRecording() {
|
|
1642
|
+
var r = recState; if (!r) return;
|
|
1643
|
+
recState = null;
|
|
1644
|
+
document.getElementById('qaRec').classList.remove('on');
|
|
1645
|
+
document.getElementById('recDot').classList.remove('on');
|
|
1646
|
+
document.getElementById('recTime').classList.remove('on');
|
|
1647
|
+
document.getElementById('recTime').textContent = '';
|
|
1648
|
+
clearInterval(r.timer);
|
|
1649
|
+
try { r.proc.disconnect(); } catch(_){}
|
|
1650
|
+
try { r.src.disconnect(); } catch(_){}
|
|
1651
|
+
try { r.stream.getTracks().forEach(function(t){ t.stop(); }); } catch(_){}
|
|
1652
|
+
try { await r.ctx.close(); } catch(_){}
|
|
1653
|
+
|
|
1654
|
+
var len = 0; for (var i = 0; i < r.chunks.length; i++) len += r.chunks[i].length;
|
|
1655
|
+
if (len < r.sr / 4) { showToast('Too short (< 250 ms)', 'warn'); return; }
|
|
1656
|
+
var merged = new Float32Array(len);
|
|
1657
|
+
var off = 0;
|
|
1658
|
+
for (var j = 0; j < r.chunks.length; j++) { merged.set(r.chunks[j], off); off += r.chunks[j].length; }
|
|
1659
|
+
var wav = encodeWAV(merged, r.sr);
|
|
1660
|
+
var stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
|
1661
|
+
showToast('Uploading recording…');
|
|
1662
|
+
try {
|
|
1663
|
+
var rel = await uploadBlob(new Blob([wav], { type: 'audio/wav' }),
|
|
1664
|
+
'rec-' + stamp + '.wav',
|
|
1665
|
+
'.sapper/voice/incoming');
|
|
1666
|
+
loadTree();
|
|
1667
|
+
sendCmd('/voice file ' + rel);
|
|
1668
|
+
showToast('Sent to Sapper for transcription');
|
|
1669
|
+
} catch (e) {
|
|
1670
|
+
showToast('Upload failed: ' + e.message, 'err');
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function encodeWAV(samples, sampleRate) {
|
|
1675
|
+
var bytesPerSample = 2;
|
|
1676
|
+
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
|
|
1677
|
+
var view = new DataView(buffer);
|
|
1678
|
+
function writeStr(o, s) { for (var i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); }
|
|
1679
|
+
writeStr(0, 'RIFF');
|
|
1680
|
+
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
|
|
1681
|
+
writeStr(8, 'WAVE');
|
|
1682
|
+
writeStr(12, 'fmt ');
|
|
1683
|
+
view.setUint32(16, 16, true);
|
|
1684
|
+
view.setUint16(20, 1, true); // PCM
|
|
1685
|
+
view.setUint16(22, 1, true); // mono
|
|
1686
|
+
view.setUint32(24, sampleRate, true);
|
|
1687
|
+
view.setUint32(28, sampleRate * bytesPerSample, true);
|
|
1688
|
+
view.setUint16(32, bytesPerSample, true);
|
|
1689
|
+
view.setUint16(34, 16, true);
|
|
1690
|
+
writeStr(36, 'data');
|
|
1691
|
+
view.setUint32(40, samples.length * bytesPerSample, true);
|
|
1692
|
+
var o = 44;
|
|
1693
|
+
for (var i = 0; i < samples.length; i++) {
|
|
1694
|
+
var s = Math.max(-1, Math.min(1, samples[i]));
|
|
1695
|
+
view.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
|
1696
|
+
o += 2;
|
|
1697
|
+
}
|
|
1698
|
+
return buffer;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
window.sendOpenPrompt = async function() {
|
|
1702
|
+
var v = await showModal({
|
|
1703
|
+
title: 'Open file in Sapper',
|
|
1704
|
+
label: 'Path',
|
|
1705
|
+
placeholder: 'src/index.ts',
|
|
1706
|
+
okLabel: 'Open',
|
|
1707
|
+
});
|
|
1708
|
+
if (v == null || !v.trim()) return;
|
|
1709
|
+
sendCmd('/open ' + v.trim());
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1301
1712
|
// ─── Boot ────────────────────────────────────────────────────
|
|
1302
1713
|
connectPty();
|
|
1303
1714
|
connectEvents();
|
|
@@ -1498,6 +1909,53 @@ const server = http.createServer(async (req, res) => {
|
|
|
1498
1909
|
} catch (e) { return json(res, { error: e.message }, 500); }
|
|
1499
1910
|
}
|
|
1500
1911
|
|
|
1912
|
+
// ── Upload (raw body; headers carry filename + target dir)
|
|
1913
|
+
if (req.method === 'POST' && path === '/api/upload') {
|
|
1914
|
+
try {
|
|
1915
|
+
let name = decodeURIComponent(req.headers['x-filename'] || 'upload.bin');
|
|
1916
|
+
let dir = decodeURIComponent(req.headers['x-target-dir'] || 'uploads');
|
|
1917
|
+
// sanitize filename (strip slashes), keep extension
|
|
1918
|
+
name = name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 200) || 'upload.bin';
|
|
1919
|
+
dir = dir.replace(/^[\\/]+/, '');
|
|
1920
|
+
const absDir = safePath(dir);
|
|
1921
|
+
if (!absDir) return json(res, { error: 'invalid target dir' }, 400);
|
|
1922
|
+
ensureDir(absDir);
|
|
1923
|
+
let target = join(absDir, name);
|
|
1924
|
+
// de-dupe if exists
|
|
1925
|
+
if (fs.existsSync(target)) {
|
|
1926
|
+
const dot = name.lastIndexOf('.');
|
|
1927
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
1928
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
1929
|
+
for (let i = 1; i < 1000; i++) {
|
|
1930
|
+
const cand = join(absDir, stem + '-' + i + ext);
|
|
1931
|
+
if (!fs.existsSync(cand)) { target = cand; break; }
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
const ws = fs.createWriteStream(target);
|
|
1935
|
+
let size = 0; let aborted = false;
|
|
1936
|
+
const MAX = 50 * 1024 * 1024;
|
|
1937
|
+
req.on('data', (c) => {
|
|
1938
|
+
size += c.length;
|
|
1939
|
+
if (size > MAX && !aborted) {
|
|
1940
|
+
aborted = true;
|
|
1941
|
+
ws.destroy();
|
|
1942
|
+
try { fs.unlinkSync(target); } catch {}
|
|
1943
|
+
json(res, { error: 'upload too large (>50MB)' }, 413);
|
|
1944
|
+
req.destroy();
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
req.pipe(ws);
|
|
1948
|
+
await new Promise((resolve, reject) => {
|
|
1949
|
+
ws.on('finish', resolve);
|
|
1950
|
+
ws.on('error', reject);
|
|
1951
|
+
req.on('error', reject);
|
|
1952
|
+
});
|
|
1953
|
+
if (aborted) return;
|
|
1954
|
+
const rel = relative(workingDir, target).split(sep).join('/');
|
|
1955
|
+
return json(res, { ok: true, path: rel, size });
|
|
1956
|
+
} catch (e) { return json(res, { error: e.message }, 500); }
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1501
1959
|
// ── Config read/write
|
|
1502
1960
|
if (req.method === 'GET' && path === '/api/config') {
|
|
1503
1961
|
return json(res, { config: readJSON(CONFIG_FILE, {}), path: relative(workingDir, CONFIG_FILE) });
|
|
@@ -1589,8 +2047,33 @@ wssPty.on('connection', (ws) => {
|
|
|
1589
2047
|
let watcher = null;
|
|
1590
2048
|
const eventsClients = new Set();
|
|
1591
2049
|
const recentEvents = new Map(); // path -> timestamp (dedupe burst events)
|
|
2050
|
+
const knownPaths = new Set(); // paths we have seen exist (for create vs delete detection)
|
|
2051
|
+
const recentActivity = []; // last N classified events for late-joining clients
|
|
2052
|
+
|
|
2053
|
+
function classifyEvent(rawEvent, rel, abs) {
|
|
2054
|
+
// fs.watch only gives 'rename' or 'change'
|
|
2055
|
+
const exists = fs.existsSync(abs);
|
|
2056
|
+
if (rawEvent === 'change') return exists ? 'modified' : 'deleted';
|
|
2057
|
+
// 'rename' = created, deleted, or moved-in/out
|
|
2058
|
+
if (!exists) return 'deleted';
|
|
2059
|
+
return knownPaths.has(rel) ? 'modified' : 'created';
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function seedKnownPaths(dir, rel = '') {
|
|
2063
|
+
try {
|
|
2064
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2065
|
+
if (IGNORE_NAMES.has(ent.name)) continue;
|
|
2066
|
+
const sub = rel ? rel + '/' + ent.name : ent.name;
|
|
2067
|
+
knownPaths.add(sub);
|
|
2068
|
+
if (ent.isDirectory() && knownPaths.size < 20000) {
|
|
2069
|
+
seedKnownPaths(join(dir, ent.name), sub);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
} catch {}
|
|
2073
|
+
}
|
|
1592
2074
|
|
|
1593
2075
|
function startWatcher() {
|
|
2076
|
+
seedKnownPaths(workingDir);
|
|
1594
2077
|
try {
|
|
1595
2078
|
watcher = fs.watch(workingDir, { recursive: true }, (event, filename) => {
|
|
1596
2079
|
if (!filename) return;
|
|
@@ -1606,7 +2089,17 @@ function startWatcher() {
|
|
|
1606
2089
|
const cutoff = now - 10000;
|
|
1607
2090
|
for (const [k, t] of recentEvents) if (t < cutoff) recentEvents.delete(k);
|
|
1608
2091
|
}
|
|
1609
|
-
const
|
|
2092
|
+
const abs = pathResolve(workingDir, rel);
|
|
2093
|
+
const kind = classifyEvent(event, rel, abs);
|
|
2094
|
+
if (kind === 'deleted') knownPaths.delete(rel);
|
|
2095
|
+
else knownPaths.add(rel);
|
|
2096
|
+
let isDir = false;
|
|
2097
|
+
try { isDir = fs.statSync(abs).isDirectory(); } catch {}
|
|
2098
|
+
const enriched = { event, kind, path: rel, isDir, ts: now };
|
|
2099
|
+
// remember for new clients (cap at 50)
|
|
2100
|
+
recentActivity.push(enriched);
|
|
2101
|
+
if (recentActivity.length > 50) recentActivity.shift();
|
|
2102
|
+
const payload = JSON.stringify(enriched);
|
|
1610
2103
|
for (const c of eventsClients) {
|
|
1611
2104
|
if (c.readyState === c.OPEN) { try { c.send(payload); } catch {} }
|
|
1612
2105
|
}
|
|
@@ -1620,6 +2113,10 @@ function startWatcher() {
|
|
|
1620
2113
|
wssEvents.on('connection', (ws) => {
|
|
1621
2114
|
eventsClients.add(ws);
|
|
1622
2115
|
dbg('events client connected (total=' + eventsClients.size + ')');
|
|
2116
|
+
// Replay last activity so the new tab sees recent changes
|
|
2117
|
+
if (recentActivity.length) {
|
|
2118
|
+
try { ws.send(JSON.stringify({ type: 'activity-replay', items: recentActivity.slice(-25) })); } catch {}
|
|
2119
|
+
}
|
|
1623
2120
|
if (lastStats) { try { ws.send(lastStats); } catch {} }
|
|
1624
2121
|
ws.on('close', () => { eventsClients.delete(ws); });
|
|
1625
2122
|
});
|