sapper-iq 1.2.0 → 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 +1139 -19
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;
|
|
@@ -245,6 +248,43 @@ function buildHTML() {
|
|
|
245
248
|
position: relative; }
|
|
246
249
|
.files-toolbar .ftb sup { font-size: 9px; color: var(--green); margin-left: 1px; }
|
|
247
250
|
.files-toolbar .ftb:hover { background: rgba(255,255,255,.05); color: var(--fg); border-color: var(--border); }
|
|
251
|
+
.files-toolbar .ftb.on { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,.08); }
|
|
252
|
+
|
|
253
|
+
/* Activity feed */
|
|
254
|
+
#activityPanel { display: none; border-bottom: 1px solid var(--border);
|
|
255
|
+
background: var(--panel2); max-height: 180px; overflow-y: auto;
|
|
256
|
+
font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px; }
|
|
257
|
+
#activityPanel.on { display: block; }
|
|
258
|
+
#activityPanel .ah { display: flex; align-items: center; padding: 5px 10px;
|
|
259
|
+
border-bottom: 1px solid var(--border); color: var(--dim); font-size: 10px;
|
|
260
|
+
text-transform: uppercase; letter-spacing: .5px; position: sticky; top: 0;
|
|
261
|
+
background: var(--panel2); z-index: 1; }
|
|
262
|
+
#activityPanel .ah .acl { margin-left: auto; color: var(--accent); cursor: pointer;
|
|
263
|
+
text-transform: none; letter-spacing: 0; font-size: 10px; }
|
|
264
|
+
#activityPanel .ai { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
|
|
265
|
+
color: var(--muted); cursor: pointer; border-left: 2px solid transparent; }
|
|
266
|
+
#activityPanel .ai:hover { background: rgba(255,255,255,.04); color: var(--fg); }
|
|
267
|
+
#activityPanel .ai .ak { font-size: 9px; text-transform: uppercase; letter-spacing: .5px;
|
|
268
|
+
width: 56px; flex-shrink: 0; font-weight: 600; }
|
|
269
|
+
#activityPanel .ai .ap { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
270
|
+
#activityPanel .ai .at { color: var(--dim); font-size: 9px; flex-shrink: 0; }
|
|
271
|
+
#activityPanel .ai.kind-created { border-left-color: var(--green); }
|
|
272
|
+
#activityPanel .ai.kind-modified { border-left-color: var(--yellow); }
|
|
273
|
+
#activityPanel .ai.kind-deleted { border-left-color: var(--red); }
|
|
274
|
+
#activityPanel .ai.kind-created .ak { color: var(--green); }
|
|
275
|
+
#activityPanel .ai.kind-modified .ak { color: var(--yellow); }
|
|
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; }
|
|
287
|
+
#activityPanel .empty { padding: 12px; color: var(--dim); text-align: center; font-size: 11px; }
|
|
248
288
|
.tree { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; padding-bottom: 12px; }
|
|
249
289
|
.row { display: flex; align-items: center; gap: 4px; padding: 3px 8px; cursor: pointer; color: var(--muted);
|
|
250
290
|
white-space: nowrap; user-select: none; position: relative; }
|
|
@@ -255,6 +295,19 @@ function buildHTML() {
|
|
|
255
295
|
.row .name { overflow: hidden; text-overflow: ellipsis; }
|
|
256
296
|
.row .badge { margin-left: auto; font-size: 9px; color: var(--yellow); opacity: 0; transition: opacity .2s; }
|
|
257
297
|
.row.changed .badge { opacity: 1; }
|
|
298
|
+
.row .actdot { display: none; width: 7px; height: 7px; border-radius: 50%;
|
|
299
|
+
margin-left: 4px; flex-shrink: 0; box-shadow: 0 0 6px currentColor; }
|
|
300
|
+
.row.act-created .actdot { display: inline-block; background: var(--green); color: var(--green); }
|
|
301
|
+
.row.act-modified .actdot { display: inline-block; background: var(--yellow); color: var(--yellow); }
|
|
302
|
+
.row.act-deleted .actdot { display: inline-block; background: var(--red); color: var(--red); }
|
|
303
|
+
.row.act-fresh .actdot { animation: pulse 1.4s ease-out 2; }
|
|
304
|
+
.row.act-created .name { color: #56d364; }
|
|
305
|
+
.row.act-modified .name { color: #e3b341; }
|
|
306
|
+
.row.act-deleted .name { color: #ffa198; text-decoration: line-through; opacity: .7; }
|
|
307
|
+
@keyframes pulse { 0%{transform:scale(1);} 50%{transform:scale(1.6);} 100%{transform:scale(1);} }
|
|
308
|
+
.row .actcount { display: none; font-size: 9px; color: var(--dim);
|
|
309
|
+
font-family: ui-monospace, monospace; margin-left: 2px; }
|
|
310
|
+
.row.act-multi .actcount { display: inline-block; }
|
|
258
311
|
.row .rmenu { margin-left: auto; color: var(--dim); font-size: 14px; padding: 0 4px;
|
|
259
312
|
opacity: 0; flex-shrink: 0; line-height: 1; border-radius: 3px; }
|
|
260
313
|
.row.changed .rmenu { margin-left: 4px; }
|
|
@@ -344,7 +397,24 @@ function buildHTML() {
|
|
|
344
397
|
|
|
345
398
|
/* ─── Terminal area ─── */
|
|
346
399
|
#center { flex: 1; min-width: 0; min-height: 0; display: flex;
|
|
347
|
-
flex-direction: column; background: var(--bg); overflow: hidden; }
|
|
400
|
+
flex-direction: column; background: var(--bg); overflow: hidden; position: relative; }
|
|
401
|
+
#qa { display: flex; align-items: center; gap: 6px; padding: 6px 10px;
|
|
402
|
+
background: var(--panel2); border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
|
403
|
+
#qa .qabtn { background: transparent; color: var(--muted); border: 1px solid var(--border);
|
|
404
|
+
border-radius: 5px; padding: 4px 10px; font-size: 11px; cursor: pointer;
|
|
405
|
+
display: inline-flex; align-items: center; gap: 5px; font-family: inherit; line-height: 1; }
|
|
406
|
+
#qa .qabtn:hover { color: var(--accent); border-color: var(--accent); }
|
|
407
|
+
#qa .qabtn .qaico { font-size: 13px; }
|
|
408
|
+
#qa .qabtn.rec.on { color: var(--red); border-color: var(--red); background: rgba(248,81,73,.08); }
|
|
409
|
+
#qa .qa-sp { flex: 1; }
|
|
410
|
+
#qa .rec-dot { display: none; width: 8px; height: 8px; border-radius: 50%;
|
|
411
|
+
background: var(--red); animation: blink 1s infinite; }
|
|
412
|
+
#qa .rec-dot.on { display: inline-block; }
|
|
413
|
+
#qa .rec-time { display: none; font-family: ui-monospace, 'SF Mono', monospace;
|
|
414
|
+
font-size: 11px; color: var(--red); font-variant-numeric: tabular-nums; }
|
|
415
|
+
#qa .rec-time.on { display: inline-block; }
|
|
416
|
+
@keyframes blink { 0%,100%{opacity:1;} 50%{opacity:.3;} }
|
|
417
|
+
|
|
348
418
|
#term-wrap { flex: 1; min-height: 0; min-width: 0; padding: 6px 0 0 10px;
|
|
349
419
|
overflow: hidden; position: relative; }
|
|
350
420
|
#term-wrap .terminal, #term-wrap .xterm { height: 100% !important; width: 100% !important; }
|
|
@@ -354,6 +424,16 @@ function buildHTML() {
|
|
|
354
424
|
.xterm-viewport::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
|
|
355
425
|
.xterm-viewport::-webkit-scrollbar-track { background: transparent; }
|
|
356
426
|
|
|
427
|
+
/* drag-drop overlay */
|
|
428
|
+
#dropOverlay { position: absolute; inset: 0; display: none; z-index: 200;
|
|
429
|
+
background: rgba(10,14,20,.85); align-items: center; justify-content: center;
|
|
430
|
+
border: 2px dashed var(--accent); pointer-events: none; }
|
|
431
|
+
#dropOverlay.on { display: flex; }
|
|
432
|
+
#dropOverlay .drop-card { text-align: center; }
|
|
433
|
+
#dropOverlay .drop-icon { font-size: 48px; margin-bottom: 8px; }
|
|
434
|
+
#dropOverlay .drop-text { color: var(--accent); font-size: 16px; font-weight: 600; }
|
|
435
|
+
#dropOverlay .drop-text span { color: var(--muted); font-weight: 400; font-size: 12px; }
|
|
436
|
+
|
|
357
437
|
/* ─── Preview panel ─── */
|
|
358
438
|
#preview {
|
|
359
439
|
width: 480px; flex-shrink: 0; display: flex; flex-direction: column;
|
|
@@ -405,6 +485,31 @@ function buildHTML() {
|
|
|
405
485
|
#pview.code { padding: 0; }
|
|
406
486
|
#pview.code pre { margin: 0; border: none; border-radius: 0; min-height: 100%; }
|
|
407
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
|
+
|
|
408
513
|
#pedit {
|
|
409
514
|
flex: 1; min-height: 0; width: 100%; padding: 12px 14px;
|
|
410
515
|
background: var(--bg); border: none; color: var(--fg);
|
|
@@ -413,6 +518,30 @@ function buildHTML() {
|
|
|
413
518
|
}
|
|
414
519
|
#pedit.show { display: block; }
|
|
415
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; }
|
|
416
545
|
|
|
417
546
|
#empty { padding: 40px 20px; text-align: center; color: var(--dim); font-size: 13px; }
|
|
418
547
|
#empty .lg { font-size: 36px; margin-bottom: 8px; }
|
|
@@ -462,10 +591,16 @@ function buildHTML() {
|
|
|
462
591
|
<div class="files-toolbar">
|
|
463
592
|
<button class="ftb" title="New file" onclick="newItemPrompt('file','')">🗎<sup>+</sup></button>
|
|
464
593
|
<button class="ftb" title="New folder" onclick="newItemPrompt('folder','')">📁<sup>+</sup></button>
|
|
594
|
+
<button class="ftb" id="ftbAct" title="Show activity log" onclick="toggleActivity()">☉</button>
|
|
465
595
|
<span class="ftb-spacer"></span>
|
|
596
|
+
<button class="ftb" title="Clear change marks" onclick="clearAllMarks()">✕</button>
|
|
466
597
|
<button class="ftb" title="Refresh tree" onclick="loadTree()">↺</button>
|
|
467
598
|
<button class="ftb" title="Collapse all" onclick="collapseAll()">⇤</button>
|
|
468
599
|
</div>
|
|
600
|
+
<div id="activityPanel">
|
|
601
|
+
<div class="ah">Recent activity<span class="acl" onclick="clearActivity()">clear</span></div>
|
|
602
|
+
<div id="activityList"></div>
|
|
603
|
+
</div>
|
|
469
604
|
<div class="tree" id="tree"></div>
|
|
470
605
|
</div>
|
|
471
606
|
<div class="pane" id="pane-config">
|
|
@@ -499,16 +634,43 @@ function buildHTML() {
|
|
|
499
634
|
</div>
|
|
500
635
|
</aside>
|
|
501
636
|
|
|
637
|
+
<div class="resizer" id="sideRes"></div>
|
|
638
|
+
|
|
502
639
|
<!-- Center: terminal -->
|
|
503
640
|
<main id="center">
|
|
641
|
+
<div id="qa">
|
|
642
|
+
<button class="qabtn" title="Attach files (sends @path to Sapper)" onclick="pickAndUpload()">
|
|
643
|
+
<span class="qaico">📎</span><span class="qalbl">Attach</span>
|
|
644
|
+
</button>
|
|
645
|
+
<button class="qabtn rec" title="Record voice (auto-transcribed by Sapper)" onclick="toggleRecord()" id="qaRec">
|
|
646
|
+
<span class="qaico">🎤</span><span class="qalbl">Record</span>
|
|
647
|
+
</button>
|
|
648
|
+
<span id="recDot" class="rec-dot"></span>
|
|
649
|
+
<span id="recTime" class="rec-time"></span>
|
|
650
|
+
<span class="qa-sp"></span>
|
|
651
|
+
<button class="qabtn" title="Send /attach (interactive)" onclick="sendCmd('/attach')">/attach</button>
|
|
652
|
+
<button class="qabtn" title="Open file by path" onclick="sendOpenPrompt()">/open</button>
|
|
653
|
+
<button class="qabtn" title="Compact context" onclick="sendCmd('/summary')">/summary</button>
|
|
654
|
+
<input type="file" id="qaFile" multiple style="display:none">
|
|
655
|
+
</div>
|
|
504
656
|
<div id="term-wrap"></div>
|
|
657
|
+
<div id="dropOverlay">
|
|
658
|
+
<div class="drop-card">
|
|
659
|
+
<div class="drop-icon">📥</div>
|
|
660
|
+
<div class="drop-text">Drop files to upload<br><span>They will be attached to Sapper with <code>@path</code></span></div>
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
505
663
|
</main>
|
|
506
664
|
|
|
665
|
+
<div class="resizer" id="prevRes"></div>
|
|
666
|
+
|
|
507
667
|
<!-- Right: preview -->
|
|
508
668
|
<aside id="preview" class="hidden">
|
|
509
669
|
<div class="ph">
|
|
510
670
|
<span class="pp" id="pPath">No file open</span>
|
|
511
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>
|
|
512
674
|
<button id="pSave" onclick="saveEdit()" class="primary" style="display:none">Save</button>
|
|
513
675
|
<button id="pCancel" onclick="cancelEdit()" style="display:none">Cancel</button>
|
|
514
676
|
<button id="pSrc" onclick="toggleSource()" style="display:none">Source</button>
|
|
@@ -517,7 +679,10 @@ function buildHTML() {
|
|
|
517
679
|
</div>
|
|
518
680
|
<div class="ind" id="pInd">File changed on disk — reload to view latest.</div>
|
|
519
681
|
<div id="pview"><div id="empty"><div class="lg">📄</div>Open a file from the sidebar.</div></div>
|
|
520
|
-
<
|
|
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>
|
|
521
686
|
</aside>
|
|
522
687
|
</div>
|
|
523
688
|
</div>
|
|
@@ -528,6 +693,16 @@ function buildHTML() {
|
|
|
528
693
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
|
|
529
694
|
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
|
|
530
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>
|
|
531
706
|
<script>
|
|
532
707
|
/* ─────────────────────────────────────────────────────────────── */
|
|
533
708
|
/* Sapper Web — frontend */
|
|
@@ -543,8 +718,21 @@ var state = {
|
|
|
543
718
|
editing: false,
|
|
544
719
|
expanded: { '': true },
|
|
545
720
|
fsWS: null,
|
|
721
|
+
marks: {}, // path -> { kind, count, ts }
|
|
722
|
+
activity: [], // ordered list of {kind, path, isDir, ts}
|
|
723
|
+
activityOpen: false,
|
|
546
724
|
};
|
|
547
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
|
+
|
|
548
736
|
function esc(s) {
|
|
549
737
|
if (s == null) return '';
|
|
550
738
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
@@ -679,20 +867,21 @@ function handleStats(msg) {
|
|
|
679
867
|
}
|
|
680
868
|
|
|
681
869
|
function handleFsEvent(msg) {
|
|
682
|
-
if (!msg
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
//
|
|
690
|
-
if (msg.
|
|
870
|
+
if (!msg) return;
|
|
871
|
+
if (msg.type === 'activity-replay') {
|
|
872
|
+
if (Array.isArray(msg.items)) msg.items.forEach(applyActivityItem);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (!msg.path) return;
|
|
876
|
+
applyActivityItem(msg);
|
|
877
|
+
// Re-fetch tree (parent dir) for create/delete so the new/removed file appears
|
|
878
|
+
if (msg.kind === 'created' || msg.kind === 'deleted') {
|
|
691
879
|
var parent = msg.path.split('/').slice(0, -1).join('/');
|
|
692
880
|
refreshDir(parent);
|
|
693
881
|
}
|
|
694
882
|
// If the current preview file changed, auto-refresh (or show indicator if editing)
|
|
695
883
|
if (state.currentFile === msg.path) {
|
|
884
|
+
if (msg.kind === 'deleted') return; // file gone; leave preview state
|
|
696
885
|
if (state.editing) {
|
|
697
886
|
document.getElementById('pInd').classList.add('show');
|
|
698
887
|
} else {
|
|
@@ -701,6 +890,205 @@ function handleFsEvent(msg) {
|
|
|
701
890
|
}
|
|
702
891
|
}
|
|
703
892
|
|
|
893
|
+
function applyActivityItem(item) {
|
|
894
|
+
// bump persistent mark
|
|
895
|
+
var prev = state.marks[item.path];
|
|
896
|
+
var count = prev ? (prev.count + 1) : 1;
|
|
897
|
+
state.marks[item.path] = { kind: item.kind, count: count, ts: item.ts };
|
|
898
|
+
var row = document.querySelector('.row[data-path="' + cssEscape(item.path) + '"]');
|
|
899
|
+
if (row) applyMark(row, state.marks[item.path]);
|
|
900
|
+
// push into activity log (dedupe consecutive entries for same path)
|
|
901
|
+
var last = state.activity[state.activity.length - 1];
|
|
902
|
+
if (last && last.path === item.path && last.kind === item.kind && (item.ts - last.ts) < 1500) {
|
|
903
|
+
last.count = (last.count || 1) + 1;
|
|
904
|
+
last.ts = item.ts;
|
|
905
|
+
} else {
|
|
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);
|
|
910
|
+
if (state.activity.length > 100) state.activity.shift();
|
|
911
|
+
}
|
|
912
|
+
renderActivity();
|
|
913
|
+
// Highlight parent dirs subtly so user notices nested change even when collapsed
|
|
914
|
+
var parts = item.path.split('/');
|
|
915
|
+
for (var i = 1; i < parts.length; i++) {
|
|
916
|
+
var dirPath = parts.slice(0, i).join('/');
|
|
917
|
+
var dirRow = document.querySelector('.row[data-path="' + cssEscape(dirPath) + '"]');
|
|
918
|
+
if (dirRow && !dirRow.classList.contains('act-created') && !dirRow.classList.contains('act-modified') && !dirRow.classList.contains('act-deleted')) {
|
|
919
|
+
dirRow.classList.add('act-modified', 'act-fresh');
|
|
920
|
+
setTimeout((function(r){ return function(){ r.classList.remove('act-fresh'); }; })(dirRow), 1500);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function applyMark(row, mark) {
|
|
926
|
+
row.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
|
|
927
|
+
row.classList.add('act-' + mark.kind, 'act-fresh');
|
|
928
|
+
if (mark.count > 1) {
|
|
929
|
+
row.classList.add('act-multi');
|
|
930
|
+
var cnt = row.querySelector('.actcount');
|
|
931
|
+
if (cnt) cnt.textContent = String(mark.count);
|
|
932
|
+
}
|
|
933
|
+
setTimeout(function(){ row.classList.remove('act-fresh'); }, 1500);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function renderActivity() {
|
|
937
|
+
var host = document.getElementById('activityList');
|
|
938
|
+
if (!host) return;
|
|
939
|
+
if (!state.activity.length) {
|
|
940
|
+
host.innerHTML = '<div class="empty">No changes yet. Ask Sapper to edit something.</div>';
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
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];
|
|
947
|
+
var rel = relTime(a.ts);
|
|
948
|
+
var ct = a.count > 1 ? ' ×' + a.count : '';
|
|
949
|
+
html += '<div class="ai kind-' + a.kind + '" data-idx="' + i + '" data-path="' + esc(a.path) + '">' +
|
|
950
|
+
'<span class="ak">' + a.kind + ct + '</span>' +
|
|
951
|
+
'<span class="ap">' + esc(a.path) + '</span>' +
|
|
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;
|
|
962
|
+
Array.from(host.querySelectorAll('.ai')).forEach(function(el){
|
|
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;
|
|
978
|
+
var mark = state.marks[p];
|
|
979
|
+
if (mark && mark.kind === 'deleted') { showToast(p + ' (deleted)'); return; }
|
|
980
|
+
var parts = p.split('/');
|
|
981
|
+
var soFar = '';
|
|
982
|
+
for (var j = 0; j < parts.length - 1; j++) {
|
|
983
|
+
soFar = soFar ? soFar + '/' + parts[j] : parts[j];
|
|
984
|
+
state.expanded[soFar] = true;
|
|
985
|
+
}
|
|
986
|
+
loadTree();
|
|
987
|
+
setTimeout(function(){ openFile(p); }, 80);
|
|
988
|
+
clearMark(p);
|
|
989
|
+
});
|
|
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);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function relTime(ts) {
|
|
1056
|
+
var s = Math.floor((Date.now() - ts) / 1000);
|
|
1057
|
+
if (s < 5) return 'now';
|
|
1058
|
+
if (s < 60) return s + 's';
|
|
1059
|
+
if (s < 3600) return Math.floor(s / 60) + 'm';
|
|
1060
|
+
return Math.floor(s / 3600) + 'h';
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function clearMark(path) {
|
|
1064
|
+
delete state.marks[path];
|
|
1065
|
+
var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
|
|
1066
|
+
if (row) row.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
window.toggleActivity = function() {
|
|
1070
|
+
state.activityOpen = !state.activityOpen;
|
|
1071
|
+
document.getElementById('activityPanel').classList.toggle('on', state.activityOpen);
|
|
1072
|
+
document.getElementById('ftbAct').classList.toggle('on', state.activityOpen);
|
|
1073
|
+
if (state.activityOpen) renderActivity();
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
window.clearActivity = function() {
|
|
1077
|
+
state.activity = [];
|
|
1078
|
+
renderActivity();
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
window.clearAllMarks = function() {
|
|
1082
|
+
state.marks = {};
|
|
1083
|
+
document.querySelectorAll('.row').forEach(function(r){
|
|
1084
|
+
r.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
|
|
1085
|
+
});
|
|
1086
|
+
showToast('Cleared change marks');
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// Periodically refresh "rel time" labels in the activity panel
|
|
1090
|
+
setInterval(function(){ if (state.activityOpen) renderActivity(); }, 30000);
|
|
1091
|
+
|
|
704
1092
|
function cssEscape(s) { return s.replace(/(["\\\\])/g, '\\\\$1'); }
|
|
705
1093
|
|
|
706
1094
|
// ─── Sidebar tabs ────────────────────────────────────────────
|
|
@@ -719,13 +1107,16 @@ window.toggleSide = function() {
|
|
|
719
1107
|
var s = document.getElementById('side');
|
|
720
1108
|
s.classList.toggle('hidden');
|
|
721
1109
|
document.getElementById('btnSide').classList.toggle('on', !s.classList.contains('hidden'));
|
|
1110
|
+
if (typeof updateResizerVisibility === 'function') updateResizerVisibility();
|
|
722
1111
|
setTimeout(doFit, 50);
|
|
723
1112
|
};
|
|
724
1113
|
window.togglePreview = function() {
|
|
725
1114
|
var p = document.getElementById('preview');
|
|
726
1115
|
p.classList.toggle('hidden');
|
|
727
1116
|
document.getElementById('btnPrev').classList.toggle('on', !p.classList.contains('hidden'));
|
|
1117
|
+
if (typeof updateResizerVisibility === 'function') updateResizerVisibility();
|
|
728
1118
|
setTimeout(doFit, 50);
|
|
1119
|
+
if (cm && !p.classList.contains('hidden')) setTimeout(function(){ cm.refresh(); }, 80);
|
|
729
1120
|
};
|
|
730
1121
|
|
|
731
1122
|
// ─── File tree ───────────────────────────────────────────────
|
|
@@ -785,6 +1176,8 @@ function renderEntries(container, basePath, entries, depth) {
|
|
|
785
1176
|
'<span class="chev">' + chev + '</span>' +
|
|
786
1177
|
'<span class="ico">' + fileIcon(entry.name, entry.isDir) + '</span>' +
|
|
787
1178
|
'<span class="name">' + esc(entry.name) + '</span>' +
|
|
1179
|
+
'<span class="actdot"></span>' +
|
|
1180
|
+
'<span class="actcount"></span>' +
|
|
788
1181
|
'<span class="badge">●</span>' +
|
|
789
1182
|
'<span class="rmenu" title="Options">⋯</span>';
|
|
790
1183
|
row.addEventListener('click', function(ev){
|
|
@@ -801,6 +1194,9 @@ function renderEntries(container, basePath, entries, depth) {
|
|
|
801
1194
|
openRowMenu({ getBoundingClientRect: function(){ return { left: ev.clientX, bottom: ev.clientY, right: ev.clientX, top: ev.clientY }; } }, path, entry.isDir);
|
|
802
1195
|
});
|
|
803
1196
|
container.appendChild(row);
|
|
1197
|
+
// Re-apply any persistent activity mark for this path
|
|
1198
|
+
var m = state.marks[path];
|
|
1199
|
+
if (m) applyMark(row, m);
|
|
804
1200
|
if (entry.isDir && state.expanded[path]) {
|
|
805
1201
|
// Load children if not already loaded
|
|
806
1202
|
fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
|
|
@@ -873,6 +1269,12 @@ function openRowMenu(anchor, path, isDir) {
|
|
|
873
1269
|
} else {
|
|
874
1270
|
items.push({ label: 'Open', fn: function(){ openFile(path); } });
|
|
875
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
|
+
}
|
|
876
1278
|
items.push({ sep: true });
|
|
877
1279
|
items.push({ label: 'Rename\u2026', fn: function(){ renamePrompt(path); } });
|
|
878
1280
|
items.push({ label: 'Duplicate', fn: function(){ duplicateItem(path); } });
|
|
@@ -1055,6 +1457,9 @@ window.openFile = function(path, isReload) {
|
|
|
1055
1457
|
document.querySelectorAll('.row.active').forEach(function(r){ r.classList.remove('active'); });
|
|
1056
1458
|
var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
|
|
1057
1459
|
if (row) row.classList.add('active');
|
|
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';
|
|
1462
|
+
if (!isReload && state.marks[path]) clearMark(path);
|
|
1058
1463
|
|
|
1059
1464
|
state.currentFile = path;
|
|
1060
1465
|
state.editing = false;
|
|
@@ -1062,12 +1467,16 @@ window.openFile = function(path, isReload) {
|
|
|
1062
1467
|
document.getElementById('pPath').textContent = path;
|
|
1063
1468
|
document.getElementById('pInd').classList.remove('show');
|
|
1064
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';
|
|
1065
1472
|
document.getElementById('pSave').style.display = 'none';
|
|
1066
1473
|
document.getElementById('pCancel').style.display = 'none';
|
|
1067
1474
|
document.getElementById('pSrc').style.display = 'none';
|
|
1068
1475
|
document.getElementById('pReload').style.display = 'inline-block';
|
|
1069
|
-
document.getElementById('
|
|
1476
|
+
document.getElementById('editorWrap').classList.remove('show');
|
|
1070
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);
|
|
1071
1480
|
|
|
1072
1481
|
fetch('/api/file?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
|
|
1073
1482
|
if (d.error) {
|
|
@@ -1154,26 +1563,33 @@ window.closePreview = function() {
|
|
|
1154
1563
|
document.getElementById('pview').className = '';
|
|
1155
1564
|
document.getElementById('pPath').textContent = 'No file open';
|
|
1156
1565
|
document.getElementById('pEdit').style.display = 'none';
|
|
1566
|
+
document.getElementById('pDiff').style.display = 'none';
|
|
1567
|
+
document.getElementById('pAsk').style.display = 'none';
|
|
1157
1568
|
document.getElementById('pSave').style.display = 'none';
|
|
1158
1569
|
document.getElementById('pCancel').style.display = 'none';
|
|
1159
1570
|
document.getElementById('pSrc').style.display = 'none';
|
|
1160
1571
|
document.getElementById('pReload').style.display = 'none';
|
|
1161
|
-
document.getElementById('
|
|
1572
|
+
document.getElementById('editorWrap').classList.remove('show');
|
|
1162
1573
|
document.getElementById('pview').classList.remove('hide');
|
|
1163
1574
|
};
|
|
1164
1575
|
window.startEdit = function() {
|
|
1165
1576
|
if (!state.currentFile) return;
|
|
1166
1577
|
state.editing = true;
|
|
1167
|
-
document.getElementById('pedit').value = state.fileOnDisk;
|
|
1168
|
-
document.getElementById('pedit').classList.add('show');
|
|
1169
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);
|
|
1170
1585
|
document.getElementById('pEdit').style.display = 'none';
|
|
1586
|
+
document.getElementById('pDiff').style.display = 'none';
|
|
1171
1587
|
document.getElementById('pSave').style.display = 'inline-block';
|
|
1172
1588
|
document.getElementById('pCancel').style.display = 'inline-block';
|
|
1173
1589
|
};
|
|
1174
1590
|
window.cancelEdit = function() {
|
|
1175
1591
|
state.editing = false;
|
|
1176
|
-
document.getElementById('
|
|
1592
|
+
document.getElementById('editorWrap').classList.remove('show');
|
|
1177
1593
|
document.getElementById('pview').classList.remove('hide');
|
|
1178
1594
|
document.getElementById('pEdit').style.display = 'inline-block';
|
|
1179
1595
|
document.getElementById('pSave').style.display = 'none';
|
|
@@ -1182,7 +1598,7 @@ window.cancelEdit = function() {
|
|
|
1182
1598
|
};
|
|
1183
1599
|
window.saveEdit = function() {
|
|
1184
1600
|
if (!state.currentFile) return;
|
|
1185
|
-
var content = document.getElementById('pedit').value;
|
|
1601
|
+
var content = cm ? cm.getValue() : document.getElementById('pedit').value;
|
|
1186
1602
|
fetch('/api/file', {
|
|
1187
1603
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1188
1604
|
body: JSON.stringify({ path: state.currentFile, content: content })
|
|
@@ -1194,6 +1610,302 @@ window.saveEdit = function() {
|
|
|
1194
1610
|
}).catch(function(e){ showToast('Save failed: ' + e.message, 'err'); });
|
|
1195
1611
|
};
|
|
1196
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
|
+
|
|
1197
1909
|
// ─── Config tab ──────────────────────────────────────────────
|
|
1198
1910
|
window.reloadConfig = function() {
|
|
1199
1911
|
fetch('/api/config').then(function(r){return r.json();}).then(function(d){
|
|
@@ -1298,10 +2010,254 @@ function loadSkills() {
|
|
|
1298
2010
|
});
|
|
1299
2011
|
}
|
|
1300
2012
|
|
|
2013
|
+
// ─── Quick actions: upload + voice record ────────────────────
|
|
2014
|
+
|
|
2015
|
+
function uploadBlob(blob, filename, targetDir) {
|
|
2016
|
+
return fetch('/api/upload', {
|
|
2017
|
+
method: 'POST',
|
|
2018
|
+
headers: {
|
|
2019
|
+
'Content-Type': blob.type || 'application/octet-stream',
|
|
2020
|
+
'X-Filename': encodeURIComponent(filename),
|
|
2021
|
+
'X-Target-Dir': encodeURIComponent(targetDir || 'uploads'),
|
|
2022
|
+
},
|
|
2023
|
+
body: blob,
|
|
2024
|
+
}).then(function(r){ return r.json(); }).then(function(d){
|
|
2025
|
+
if (d.error) throw new Error(d.error);
|
|
2026
|
+
return d.path;
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
window.pickAndUpload = function() {
|
|
2031
|
+
var inp = document.getElementById('qaFile');
|
|
2032
|
+
inp.value = '';
|
|
2033
|
+
inp.onchange = function() {
|
|
2034
|
+
var files = Array.from(inp.files || []);
|
|
2035
|
+
if (!files.length) return;
|
|
2036
|
+
uploadFileList(files, 'uploads');
|
|
2037
|
+
};
|
|
2038
|
+
inp.click();
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
async function uploadFileList(files, targetDir) {
|
|
2042
|
+
var paths = [];
|
|
2043
|
+
for (var i = 0; i < files.length; i++) {
|
|
2044
|
+
var f = files[i];
|
|
2045
|
+
showToast('Uploading ' + f.name + '…');
|
|
2046
|
+
try {
|
|
2047
|
+
var p = await uploadBlob(f, f.name, targetDir);
|
|
2048
|
+
paths.push(p);
|
|
2049
|
+
} catch (e) {
|
|
2050
|
+
showToast('Upload failed: ' + e.message, 'err');
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
if (paths.length) {
|
|
2054
|
+
loadTree();
|
|
2055
|
+
// Send "@path1 @path2 " to terminal so user can keep typing
|
|
2056
|
+
if (ws && ws.readyState === 1) {
|
|
2057
|
+
ws.send(paths.map(function(p){ return '@' + p; }).join(' ') + ' ');
|
|
2058
|
+
}
|
|
2059
|
+
showToast(paths.length + ' file' + (paths.length > 1 ? 's' : '') + ' attached');
|
|
2060
|
+
term.focus();
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// ─── Drag-drop on terminal area ──────────────────────────────
|
|
2065
|
+
(function setupDropZone(){
|
|
2066
|
+
var center = document.getElementById('center');
|
|
2067
|
+
var ov = document.getElementById('dropOverlay');
|
|
2068
|
+
var depth = 0;
|
|
2069
|
+
function show(){ ov.classList.add('on'); }
|
|
2070
|
+
function hide(){ ov.classList.remove('on'); depth = 0; }
|
|
2071
|
+
center.addEventListener('dragenter', function(e){
|
|
2072
|
+
if (!e.dataTransfer || !e.dataTransfer.types || e.dataTransfer.types.indexOf('Files') < 0) return;
|
|
2073
|
+
e.preventDefault(); depth++; show();
|
|
2074
|
+
});
|
|
2075
|
+
center.addEventListener('dragover', function(e){
|
|
2076
|
+
if (!e.dataTransfer || !e.dataTransfer.types || e.dataTransfer.types.indexOf('Files') < 0) return;
|
|
2077
|
+
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
|
|
2078
|
+
});
|
|
2079
|
+
center.addEventListener('dragleave', function(e){
|
|
2080
|
+
depth--; if (depth <= 0) hide();
|
|
2081
|
+
});
|
|
2082
|
+
center.addEventListener('drop', function(e){
|
|
2083
|
+
if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) { hide(); return; }
|
|
2084
|
+
e.preventDefault(); hide();
|
|
2085
|
+
uploadFileList(Array.from(e.dataTransfer.files), 'uploads');
|
|
2086
|
+
});
|
|
2087
|
+
})();
|
|
2088
|
+
|
|
2089
|
+
// ─── Audio recording (16 kHz mono WAV for Whisper) ───────────
|
|
2090
|
+
var recState = null;
|
|
2091
|
+
|
|
2092
|
+
window.toggleRecord = async function() {
|
|
2093
|
+
if (recState) return stopRecording();
|
|
2094
|
+
await startRecording();
|
|
2095
|
+
};
|
|
2096
|
+
|
|
2097
|
+
async function startRecording() {
|
|
2098
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
2099
|
+
showToast('Microphone API not available (use HTTPS or localhost)', 'err');
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
try {
|
|
2103
|
+
var stream = await navigator.mediaDevices.getUserMedia({
|
|
2104
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
2105
|
+
});
|
|
2106
|
+
var Ctx = window.AudioContext || window.webkitAudioContext;
|
|
2107
|
+
var ctx = new Ctx({ sampleRate: 16000 });
|
|
2108
|
+
// Resume in case of autoplay policy
|
|
2109
|
+
if (ctx.state === 'suspended') { try { await ctx.resume(); } catch(_){} }
|
|
2110
|
+
var src = ctx.createMediaStreamSource(stream);
|
|
2111
|
+
var proc = ctx.createScriptProcessor(4096, 1, 1);
|
|
2112
|
+
var chunks = [];
|
|
2113
|
+
proc.onaudioprocess = function(e) {
|
|
2114
|
+
var d = e.inputBuffer.getChannelData(0);
|
|
2115
|
+
chunks.push(new Float32Array(d));
|
|
2116
|
+
};
|
|
2117
|
+
src.connect(proc);
|
|
2118
|
+
proc.connect(ctx.destination);
|
|
2119
|
+
var startedAt = Date.now();
|
|
2120
|
+
recState = { stream: stream, ctx: ctx, src: src, proc: proc, chunks: chunks, sr: ctx.sampleRate, startedAt: startedAt };
|
|
2121
|
+
document.getElementById('qaRec').classList.add('on');
|
|
2122
|
+
document.getElementById('recDot').classList.add('on');
|
|
2123
|
+
document.getElementById('recTime').classList.add('on');
|
|
2124
|
+
recState.timer = setInterval(function(){
|
|
2125
|
+
var sec = Math.floor((Date.now() - startedAt) / 1000);
|
|
2126
|
+
var m = Math.floor(sec / 60), s = sec % 60;
|
|
2127
|
+
document.getElementById('recTime').textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
|
2128
|
+
}, 250);
|
|
2129
|
+
showToast('Recording… click again to stop');
|
|
2130
|
+
} catch (e) {
|
|
2131
|
+
showToast('Mic permission: ' + e.message, 'err');
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
async function stopRecording() {
|
|
2136
|
+
var r = recState; if (!r) return;
|
|
2137
|
+
recState = null;
|
|
2138
|
+
document.getElementById('qaRec').classList.remove('on');
|
|
2139
|
+
document.getElementById('recDot').classList.remove('on');
|
|
2140
|
+
document.getElementById('recTime').classList.remove('on');
|
|
2141
|
+
document.getElementById('recTime').textContent = '';
|
|
2142
|
+
clearInterval(r.timer);
|
|
2143
|
+
try { r.proc.disconnect(); } catch(_){}
|
|
2144
|
+
try { r.src.disconnect(); } catch(_){}
|
|
2145
|
+
try { r.stream.getTracks().forEach(function(t){ t.stop(); }); } catch(_){}
|
|
2146
|
+
try { await r.ctx.close(); } catch(_){}
|
|
2147
|
+
|
|
2148
|
+
var len = 0; for (var i = 0; i < r.chunks.length; i++) len += r.chunks[i].length;
|
|
2149
|
+
if (len < r.sr / 4) { showToast('Too short (< 250 ms)', 'warn'); return; }
|
|
2150
|
+
var merged = new Float32Array(len);
|
|
2151
|
+
var off = 0;
|
|
2152
|
+
for (var j = 0; j < r.chunks.length; j++) { merged.set(r.chunks[j], off); off += r.chunks[j].length; }
|
|
2153
|
+
var wav = encodeWAV(merged, r.sr);
|
|
2154
|
+
var stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
|
2155
|
+
showToast('Uploading recording…');
|
|
2156
|
+
try {
|
|
2157
|
+
var rel = await uploadBlob(new Blob([wav], { type: 'audio/wav' }),
|
|
2158
|
+
'rec-' + stamp + '.wav',
|
|
2159
|
+
'.sapper/voice/incoming');
|
|
2160
|
+
loadTree();
|
|
2161
|
+
sendCmd('/voice file ' + rel);
|
|
2162
|
+
showToast('Sent to Sapper for transcription');
|
|
2163
|
+
} catch (e) {
|
|
2164
|
+
showToast('Upload failed: ' + e.message, 'err');
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function encodeWAV(samples, sampleRate) {
|
|
2169
|
+
var bytesPerSample = 2;
|
|
2170
|
+
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
|
|
2171
|
+
var view = new DataView(buffer);
|
|
2172
|
+
function writeStr(o, s) { for (var i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); }
|
|
2173
|
+
writeStr(0, 'RIFF');
|
|
2174
|
+
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
|
|
2175
|
+
writeStr(8, 'WAVE');
|
|
2176
|
+
writeStr(12, 'fmt ');
|
|
2177
|
+
view.setUint32(16, 16, true);
|
|
2178
|
+
view.setUint16(20, 1, true); // PCM
|
|
2179
|
+
view.setUint16(22, 1, true); // mono
|
|
2180
|
+
view.setUint32(24, sampleRate, true);
|
|
2181
|
+
view.setUint32(28, sampleRate * bytesPerSample, true);
|
|
2182
|
+
view.setUint16(32, bytesPerSample, true);
|
|
2183
|
+
view.setUint16(34, 16, true);
|
|
2184
|
+
writeStr(36, 'data');
|
|
2185
|
+
view.setUint32(40, samples.length * bytesPerSample, true);
|
|
2186
|
+
var o = 44;
|
|
2187
|
+
for (var i = 0; i < samples.length; i++) {
|
|
2188
|
+
var s = Math.max(-1, Math.min(1, samples[i]));
|
|
2189
|
+
view.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
|
2190
|
+
o += 2;
|
|
2191
|
+
}
|
|
2192
|
+
return buffer;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
window.sendOpenPrompt = async function() {
|
|
2196
|
+
var v = await showModal({
|
|
2197
|
+
title: 'Open file in Sapper',
|
|
2198
|
+
label: 'Path',
|
|
2199
|
+
placeholder: 'src/index.ts',
|
|
2200
|
+
okLabel: 'Open',
|
|
2201
|
+
});
|
|
2202
|
+
if (v == null || !v.trim()) return;
|
|
2203
|
+
sendCmd('/open ' + v.trim());
|
|
2204
|
+
};
|
|
2205
|
+
|
|
1301
2206
|
// ─── Boot ────────────────────────────────────────────────────
|
|
1302
2207
|
connectPty();
|
|
1303
2208
|
connectEvents();
|
|
1304
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
|
+
}
|
|
1305
2261
|
</script>
|
|
1306
2262
|
</body>
|
|
1307
2263
|
</html>`;
|
|
@@ -1376,8 +2332,38 @@ const server = http.createServer(async (req, res) => {
|
|
|
1376
2332
|
if (stat.size > 2 * 1024 * 1024) return json(res, { error: 'file too large (>2MB)', size: stat.size, binary: true }, 200);
|
|
1377
2333
|
const buf = fs.readFileSync(abs);
|
|
1378
2334
|
if (looksBinary(buf)) return json(res, { binary: true, size: stat.size });
|
|
1379
|
-
|
|
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
|
+
}
|
|
1380
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 });
|
|
1381
2367
|
}
|
|
1382
2368
|
|
|
1383
2369
|
// ── File raw (images)
|
|
@@ -1498,6 +2484,53 @@ const server = http.createServer(async (req, res) => {
|
|
|
1498
2484
|
} catch (e) { return json(res, { error: e.message }, 500); }
|
|
1499
2485
|
}
|
|
1500
2486
|
|
|
2487
|
+
// ── Upload (raw body; headers carry filename + target dir)
|
|
2488
|
+
if (req.method === 'POST' && path === '/api/upload') {
|
|
2489
|
+
try {
|
|
2490
|
+
let name = decodeURIComponent(req.headers['x-filename'] || 'upload.bin');
|
|
2491
|
+
let dir = decodeURIComponent(req.headers['x-target-dir'] || 'uploads');
|
|
2492
|
+
// sanitize filename (strip slashes), keep extension
|
|
2493
|
+
name = name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 200) || 'upload.bin';
|
|
2494
|
+
dir = dir.replace(/^[\\/]+/, '');
|
|
2495
|
+
const absDir = safePath(dir);
|
|
2496
|
+
if (!absDir) return json(res, { error: 'invalid target dir' }, 400);
|
|
2497
|
+
ensureDir(absDir);
|
|
2498
|
+
let target = join(absDir, name);
|
|
2499
|
+
// de-dupe if exists
|
|
2500
|
+
if (fs.existsSync(target)) {
|
|
2501
|
+
const dot = name.lastIndexOf('.');
|
|
2502
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
2503
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
2504
|
+
for (let i = 1; i < 1000; i++) {
|
|
2505
|
+
const cand = join(absDir, stem + '-' + i + ext);
|
|
2506
|
+
if (!fs.existsSync(cand)) { target = cand; break; }
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
const ws = fs.createWriteStream(target);
|
|
2510
|
+
let size = 0; let aborted = false;
|
|
2511
|
+
const MAX = 50 * 1024 * 1024;
|
|
2512
|
+
req.on('data', (c) => {
|
|
2513
|
+
size += c.length;
|
|
2514
|
+
if (size > MAX && !aborted) {
|
|
2515
|
+
aborted = true;
|
|
2516
|
+
ws.destroy();
|
|
2517
|
+
try { fs.unlinkSync(target); } catch {}
|
|
2518
|
+
json(res, { error: 'upload too large (>50MB)' }, 413);
|
|
2519
|
+
req.destroy();
|
|
2520
|
+
}
|
|
2521
|
+
});
|
|
2522
|
+
req.pipe(ws);
|
|
2523
|
+
await new Promise((resolve, reject) => {
|
|
2524
|
+
ws.on('finish', resolve);
|
|
2525
|
+
ws.on('error', reject);
|
|
2526
|
+
req.on('error', reject);
|
|
2527
|
+
});
|
|
2528
|
+
if (aborted) return;
|
|
2529
|
+
const rel = relative(workingDir, target).split(sep).join('/');
|
|
2530
|
+
return json(res, { ok: true, path: rel, size });
|
|
2531
|
+
} catch (e) { return json(res, { error: e.message }, 500); }
|
|
2532
|
+
}
|
|
2533
|
+
|
|
1501
2534
|
// ── Config read/write
|
|
1502
2535
|
if (req.method === 'GET' && path === '/api/config') {
|
|
1503
2536
|
return json(res, { config: readJSON(CONFIG_FILE, {}), path: relative(workingDir, CONFIG_FILE) });
|
|
@@ -1589,8 +2622,79 @@ wssPty.on('connection', (ws) => {
|
|
|
1589
2622
|
let watcher = null;
|
|
1590
2623
|
const eventsClients = new Set();
|
|
1591
2624
|
const recentEvents = new Map(); // path -> timestamp (dedupe burst events)
|
|
2625
|
+
const knownPaths = new Set(); // paths we have seen exist (for create vs delete detection)
|
|
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
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function classifyEvent(rawEvent, rel, abs) {
|
|
2675
|
+
// fs.watch only gives 'rename' or 'change'
|
|
2676
|
+
const exists = fs.existsSync(abs);
|
|
2677
|
+
if (rawEvent === 'change') return exists ? 'modified' : 'deleted';
|
|
2678
|
+
// 'rename' = created, deleted, or moved-in/out
|
|
2679
|
+
if (!exists) return 'deleted';
|
|
2680
|
+
return knownPaths.has(rel) ? 'modified' : 'created';
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
function seedKnownPaths(dir, rel = '') {
|
|
2684
|
+
try {
|
|
2685
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2686
|
+
if (IGNORE_NAMES.has(ent.name)) continue;
|
|
2687
|
+
const sub = rel ? rel + '/' + ent.name : ent.name;
|
|
2688
|
+
knownPaths.add(sub);
|
|
2689
|
+
if (ent.isDirectory() && knownPaths.size < 20000) {
|
|
2690
|
+
seedKnownPaths(join(dir, ent.name), sub);
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
} catch {}
|
|
2694
|
+
}
|
|
1592
2695
|
|
|
1593
2696
|
function startWatcher() {
|
|
2697
|
+
seedKnownPaths(workingDir);
|
|
1594
2698
|
try {
|
|
1595
2699
|
watcher = fs.watch(workingDir, { recursive: true }, (event, filename) => {
|
|
1596
2700
|
if (!filename) return;
|
|
@@ -1606,7 +2710,19 @@ function startWatcher() {
|
|
|
1606
2710
|
const cutoff = now - 10000;
|
|
1607
2711
|
for (const [k, t] of recentEvents) if (t < cutoff) recentEvents.delete(k);
|
|
1608
2712
|
}
|
|
1609
|
-
const
|
|
2713
|
+
const abs = pathResolve(workingDir, rel);
|
|
2714
|
+
const kind = classifyEvent(event, rel, abs);
|
|
2715
|
+
if (kind === 'deleted') knownPaths.delete(rel);
|
|
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 {}
|
|
2719
|
+
let isDir = false;
|
|
2720
|
+
try { isDir = fs.statSync(abs).isDirectory(); } catch {}
|
|
2721
|
+
const enriched = { event, kind, path: rel, isDir, ts: now };
|
|
2722
|
+
// remember for new clients (cap at 50)
|
|
2723
|
+
recentActivity.push(enriched);
|
|
2724
|
+
if (recentActivity.length > 50) recentActivity.shift();
|
|
2725
|
+
const payload = JSON.stringify(enriched);
|
|
1610
2726
|
for (const c of eventsClients) {
|
|
1611
2727
|
if (c.readyState === c.OPEN) { try { c.send(payload); } catch {} }
|
|
1612
2728
|
}
|
|
@@ -1620,6 +2736,10 @@ function startWatcher() {
|
|
|
1620
2736
|
wssEvents.on('connection', (ws) => {
|
|
1621
2737
|
eventsClients.add(ws);
|
|
1622
2738
|
dbg('events client connected (total=' + eventsClients.size + ')');
|
|
2739
|
+
// Replay last activity so the new tab sees recent changes
|
|
2740
|
+
if (recentActivity.length) {
|
|
2741
|
+
try { ws.send(JSON.stringify({ type: 'activity-replay', items: recentActivity.slice(-25) })); } catch {}
|
|
2742
|
+
}
|
|
1623
2743
|
if (lastStats) { try { ws.send(lastStats); } catch {} }
|
|
1624
2744
|
ws.on('close', () => { eventsClients.delete(ws); });
|
|
1625
2745
|
});
|