network-infra-utility 0.8.3 → 1.0.0

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.
@@ -0,0 +1,2674 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>WebSSH - 端口自动探测终端</title>
7
+ <link rel="stylesheet" href="/static/vendor/xterm.css">
8
+ <style>
9
+ :root {
10
+ --bg-primary: #0d1117;
11
+ --bg-secondary: #161b22;
12
+ --bg-tertiary: #21262d;
13
+ --bg-input: #0d1117;
14
+ --text-primary: #e6edf3;
15
+ --text-secondary: #8b949e;
16
+ --accent: #2f81f7;
17
+ --accent-hover: #1f6feb;
18
+ --success: #2ea043;
19
+ --error: #f85149;
20
+ --warning: #d29922;
21
+ --border: #30363d;
22
+ --radius: 6px;
23
+ }
24
+
25
+ * { box-sizing: border-box; margin: 0; padding: 0; }
26
+ html, body { height: 100%; }
27
+
28
+ body {
29
+ background: var(--bg-primary);
30
+ color: var(--text-primary);
31
+ font-family: "Segoe UI", "Microsoft YaHei", -apple-system, sans-serif;
32
+ font-size: 13px;
33
+ overflow: hidden;
34
+ }
35
+
36
+ .app {
37
+ display: flex;
38
+ flex-direction: column;
39
+ height: 100vh;
40
+ }
41
+
42
+ /* ---- Header ---- */
43
+ header {
44
+ background: var(--bg-secondary);
45
+ border-bottom: 1px solid var(--border);
46
+ padding: 8px 16px;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: space-between;
50
+ flex-shrink: 0;
51
+ }
52
+ .header-left { display: flex; align-items: baseline; gap: 10px; }
53
+ .header-left h1 { font-size: 16px; font-weight: 600; }
54
+ .header-left .subtitle { color: var(--text-secondary); font-size: 12px; }
55
+
56
+ .status-badge {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ gap: 5px;
60
+ padding: 3px 10px;
61
+ border-radius: 20px;
62
+ font-size: 12px;
63
+ font-weight: 500;
64
+ }
65
+ .status-dot { width: 8px; height: 8px; border-radius: 50%; }
66
+ .status-connected { background: rgba(46,160,67,.15); color: var(--success); }
67
+ .status-connected .status-dot { background: var(--success); }
68
+ .status-disconnected { background: rgba(139,148,158,.15); color: var(--text-secondary); }
69
+ .status-disconnected .status-dot { background: var(--text-secondary); }
70
+
71
+ /* ---- Main layout ---- */
72
+ .main { display: flex; flex: 1; overflow: hidden; }
73
+
74
+ /* ---- Sidebar ---- */
75
+ .sidebar {
76
+ width: 300px;
77
+ background: var(--bg-secondary);
78
+ border-right: 1px solid var(--border);
79
+ display: flex;
80
+ flex-direction: column;
81
+ flex-shrink: 0;
82
+ }
83
+
84
+ /* Sidebar tabs */
85
+ .sidebar-tabs {
86
+ display: flex;
87
+ border-bottom: 1px solid var(--border);
88
+ flex-shrink: 0;
89
+ }
90
+ .sidebar-tab {
91
+ padding: 8px 12px;
92
+ font-size: 12px;
93
+ cursor: pointer;
94
+ border: none;
95
+ background: transparent;
96
+ color: var(--text-secondary);
97
+ border-bottom: 2px solid transparent;
98
+ transition: all .15s;
99
+ white-space: nowrap;
100
+ }
101
+ .sidebar-tab:hover { color: var(--text-primary); }
102
+ .sidebar-tab.active {
103
+ color: var(--text-primary);
104
+ border-bottom-color: var(--accent);
105
+ }
106
+
107
+ .sidebar-content {
108
+ flex: 1;
109
+ overflow-y: auto;
110
+ padding: 10px;
111
+ }
112
+
113
+ .panel {
114
+ background: var(--bg-tertiary);
115
+ border: 1px solid var(--border);
116
+ border-radius: var(--radius);
117
+ padding: 10px;
118
+ margin-bottom: 10px;
119
+ }
120
+ .panel h2 {
121
+ font-size: 11px;
122
+ text-transform: uppercase;
123
+ letter-spacing: .5px;
124
+ color: var(--text-secondary);
125
+ margin-bottom: 8px;
126
+ font-weight: 600;
127
+ }
128
+
129
+ /* ---- Process items ---- */
130
+ .process-item { margin-bottom: 8px; }
131
+ .process-item:last-child { margin-bottom: 0; }
132
+ .process-name { font-weight: 600; font-size: 12px; }
133
+ .process-pid { color: var(--text-secondary); font-size: 11px; margin: 1px 0 3px; }
134
+ .port-item {
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: space-between;
138
+ padding: 3px 0;
139
+ font-size: 12px;
140
+ }
141
+ .port-item .port-text { color: var(--text-primary); }
142
+ .port-item .port-addr { color: var(--text-secondary); font-size: 11px; }
143
+
144
+ .info { color: var(--text-secondary); font-size: 12px; padding: 3px 0; }
145
+
146
+ /* ---- Buttons ---- */
147
+ .btn {
148
+ background: var(--accent);
149
+ color: #fff;
150
+ border: 1px solid transparent;
151
+ border-radius: var(--radius);
152
+ padding: 5px 12px;
153
+ cursor: pointer;
154
+ font-size: 12px;
155
+ font-weight: 500;
156
+ transition: background .15s;
157
+ white-space: nowrap;
158
+ }
159
+ .btn:hover { background: var(--accent-hover); }
160
+ .btn:disabled { opacity: .4; cursor: not-allowed; }
161
+ .btn-sm { padding: 3px 10px; font-size: 11px; }
162
+ .btn-secondary {
163
+ background: var(--bg-tertiary);
164
+ color: var(--text-primary);
165
+ border: 1px solid var(--border);
166
+ }
167
+ .btn-secondary:hover { background: var(--border); }
168
+ .btn-danger { background: var(--error); }
169
+ .btn-danger:hover { background: #da3633; }
170
+ .btn-success { background: var(--success); }
171
+ .btn-success:hover { background: #238636; }
172
+
173
+ /* ---- Input ---- */
174
+ .input-row { display: flex; gap: 6px; }
175
+ .input-row input {
176
+ flex: 1;
177
+ background: var(--bg-input);
178
+ color: var(--text-primary);
179
+ border: 1px solid var(--border);
180
+ border-radius: var(--radius);
181
+ padding: 5px 10px;
182
+ font-size: 12px;
183
+ outline: none;
184
+ }
185
+ .input-row input:focus { border-color: var(--accent); }
186
+
187
+ .btn-row { display: flex; gap: 6px; flex-wrap: wrap; }
188
+
189
+ /* ---- Log items ---- */
190
+ .log-item {
191
+ padding: 4px 6px;
192
+ border-bottom: 1px solid var(--border);
193
+ cursor: pointer;
194
+ font-size: 12px;
195
+ }
196
+ .log-item:last-child { border-bottom: none; }
197
+ .log-item:hover { background: rgba(47,129,247,.1); }
198
+ .log-item.active-log { background: rgba(47,129,247,.15); }
199
+ .log-item .log-name {
200
+ color: var(--accent);
201
+ }
202
+ .log-mode-bar {
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 6px;
206
+ margin-bottom: 6px;
207
+ }
208
+ .log-mode-bar label { font-size: 11px; color: var(--text-secondary); }
209
+ .log-mode-bar select {
210
+ background: var(--bg-input);
211
+ color: var(--text-primary);
212
+ border: 1px solid var(--border);
213
+ border-radius: var(--radius);
214
+ padding: 2px 6px;
215
+ font-size: 11px;
216
+ outline: none;
217
+ }
218
+
219
+ /* ---- Session items ---- */
220
+ .session-item { margin-bottom: 6px; }
221
+ .session-item:last-child { margin-bottom: 0; }
222
+
223
+ /* ---- Terminal tabs ---- */
224
+ .terminal-area {
225
+ flex: 1;
226
+ display: flex;
227
+ flex-direction: column;
228
+ overflow: hidden;
229
+ background: #000;
230
+ }
231
+
232
+ .terminal-tabs {
233
+ display: flex;
234
+ background: var(--bg-secondary);
235
+ border-bottom: 1px solid var(--border);
236
+ flex-shrink: 0;
237
+ overflow-x: auto;
238
+ }
239
+ .terminal-tabs::-webkit-scrollbar { height: 3px; }
240
+
241
+ .terminal-tab {
242
+ display: flex;
243
+ align-items: center;
244
+ gap: 6px;
245
+ padding: 6px 12px;
246
+ font-size: 12px;
247
+ cursor: pointer;
248
+ border-right: 1px solid var(--border);
249
+ color: var(--text-secondary);
250
+ background: transparent;
251
+ transition: all .15s;
252
+ white-space: nowrap;
253
+ }
254
+ .terminal-tab:hover { background: var(--bg-tertiary); }
255
+ .terminal-tab.active {
256
+ color: var(--text-primary);
257
+ background: var(--bg-tertiary);
258
+ border-top: 2px solid var(--accent);
259
+ }
260
+ .terminal-tab .tab-close {
261
+ width: 16px; height: 16px;
262
+ display: flex; align-items: center; justify-content: center;
263
+ border-radius: 3px;
264
+ font-size: 14px;
265
+ opacity: .5;
266
+ transition: opacity .15s;
267
+ }
268
+ .terminal-tab .tab-close:hover { opacity: 1; background: var(--error); color: #fff; }
269
+
270
+ .terminal-tab .tab-dot {
271
+ width: 6px; height: 6px; border-radius: 50%;
272
+ flex-shrink: 0;
273
+ }
274
+ .tab-dot.connected { background: var(--success); }
275
+ .tab-dot.disconnected { background: var(--text-secondary); }
276
+ .tab-dot.connecting { background: var(--warning); animation: blink 1s infinite; }
277
+ @keyframes blink { 50% { opacity: .3; } }
278
+
279
+ .terminal-pages {
280
+ flex: 1;
281
+ overflow: hidden;
282
+ position: relative;
283
+ }
284
+ .terminal-page {
285
+ position: absolute;
286
+ top: 0; left: 0; right: 0; bottom: 0;
287
+ padding: 4px;
288
+ display: none;
289
+ }
290
+ .terminal-page.active { display: block; }
291
+ .terminal-page .terminal-container {
292
+ width: 100%; height: 100%;
293
+ }
294
+ .terminal-page.read-only {
295
+ padding: 8px;
296
+ overflow: auto;
297
+ background: var(--bg-primary);
298
+ }
299
+ .terminal-page.read-only pre {
300
+ font-family: Consolas, "Courier New", monospace;
301
+ font-size: 13px;
302
+ line-height: 1.5;
303
+ color: var(--text-primary);
304
+ white-space: pre-wrap;
305
+ word-break: break-all;
306
+ }
307
+
308
+ /* ---- Scrollbar ---- */
309
+ .sidebar-content::-webkit-scrollbar { width: 6px; }
310
+ .sidebar-content::-webkit-scrollbar-track { background: transparent; }
311
+ .sidebar-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
312
+
313
+ /* ---- Toast ---- */
314
+ .toast {
315
+ position: fixed;
316
+ bottom: 20px; right: 20px;
317
+ background: var(--bg-tertiary);
318
+ border: 1px solid var(--border);
319
+ border-radius: var(--radius);
320
+ padding: 10px 16px;
321
+ font-size: 13px;
322
+ z-index: 999;
323
+ opacity: 0;
324
+ transform: translateY(10px);
325
+ transition: opacity .2s, transform .2s;
326
+ pointer-events: none;
327
+ }
328
+ .toast.show { opacity: 1; transform: translateY(0); }
329
+
330
+ /* ---- Toolbar ---- */
331
+ .toolbar-group { margin-bottom: 8px; }
332
+ .toolbar-group-header {
333
+ font-size: 12px;
334
+ font-weight: 600;
335
+ color: var(--text-primary);
336
+ cursor: pointer;
337
+ padding: 5px 8px;
338
+ background: var(--bg-tertiary);
339
+ border-radius: var(--radius);
340
+ margin-bottom: 4px;
341
+ display: flex;
342
+ align-items: center;
343
+ gap: 6px;
344
+ }
345
+ .toolbar-group-header:hover { background: var(--border); }
346
+ .toolbar-group-header .arrow {
347
+ font-size: 10px;
348
+ transition: transform .15s;
349
+ color: var(--text-secondary);
350
+ }
351
+ .toolbar-group-header.collapsed .arrow { transform: rotate(-90deg); }
352
+ .toolbar-tools {
353
+ padding-left: 14px;
354
+ display: flex;
355
+ flex-wrap: wrap;
356
+ gap: 4px;
357
+ }
358
+ .toolbar-tools.hidden { display: none; }
359
+ .tool-btn {
360
+ background: var(--bg-tertiary);
361
+ color: var(--text-primary);
362
+ border: 1px solid var(--border);
363
+ border-radius: var(--radius);
364
+ padding: 4px 10px;
365
+ cursor: pointer;
366
+ font-size: 12px;
367
+ transition: all .15s;
368
+ position: relative;
369
+ }
370
+ .tool-btn:hover {
371
+ border-color: var(--accent);
372
+ color: var(--accent);
373
+ }
374
+ .tool-btn:active { background: var(--accent); color: #fff; }
375
+ .tool-btn:disabled { opacity: .4; cursor: not-allowed; }
376
+ .tool-btn .tooltip {
377
+ position: absolute;
378
+ bottom: 100%;
379
+ left: 50%;
380
+ transform: translateX(-50%);
381
+ background: var(--bg-secondary);
382
+ border: 1px solid var(--border);
383
+ border-radius: var(--radius);
384
+ padding: 6px 10px;
385
+ font-size: 11px;
386
+ color: var(--text-secondary);
387
+ white-space: pre-wrap;
388
+ max-width: 250px;
389
+ max-height: 120px;
390
+ overflow-y: auto;
391
+ opacity: 0;
392
+ pointer-events: none;
393
+ transition: opacity .15s;
394
+ z-index: 100;
395
+ }
396
+ .tool-btn:hover .tooltip { opacity: 1; }
397
+ .toolbar-divider {
398
+ border: none;
399
+ border-top: 1px dashed var(--border);
400
+ margin: 10px 0;
401
+ }
402
+ .ext-group-header {
403
+ font-size: 13px;
404
+ font-weight: 700;
405
+ color: var(--accent);
406
+ padding: 6px 8px;
407
+ background: var(--bg-secondary);
408
+ border-radius: var(--radius);
409
+ margin: 8px 0 4px;
410
+ }
411
+ .ext-subgroup-header {
412
+ font-size: 12px;
413
+ font-weight: 600;
414
+ color: var(--text-primary);
415
+ cursor: pointer;
416
+ padding: 4px 8px;
417
+ background: var(--bg-tertiary);
418
+ border-radius: var(--radius);
419
+ margin-bottom: 3px;
420
+ display: flex;
421
+ align-items: center;
422
+ gap: 6px;
423
+ }
424
+ .ext-subgroup-header:hover { background: var(--border); }
425
+ .ext-subgroup-header .arrow {
426
+ font-size: 10px;
427
+ transition: transform .15s;
428
+ color: var(--text-secondary);
429
+ }
430
+ .ext-subgroup-header.collapsed .arrow { transform: rotate(-90deg); }
431
+
432
+ /* ---- Command Sender panel ---- */
433
+ .cmd-target-select {
434
+ width: 100%;
435
+ background: var(--bg-input);
436
+ color: var(--text-primary);
437
+ border: 1px solid var(--border);
438
+ border-radius: var(--radius);
439
+ padding: 4px 8px;
440
+ font-size: 12px;
441
+ outline: none;
442
+ margin-bottom: 8px;
443
+ }
444
+ .cmd-textarea {
445
+ width: 100%;
446
+ min-height: 120px;
447
+ background: var(--bg-input);
448
+ color: var(--text-primary);
449
+ border: 1px solid var(--border);
450
+ border-radius: var(--radius);
451
+ padding: 8px;
452
+ font-size: 12px;
453
+ font-family: Consolas, "Courier New", monospace;
454
+ outline: none;
455
+ resize: vertical;
456
+ }
457
+ .cmd-textarea:focus { border-color: var(--accent); }
458
+ .cmd-interval-row {
459
+ display: flex;
460
+ align-items: center;
461
+ gap: 6px;
462
+ margin: 8px 0;
463
+ }
464
+ .cmd-interval-row label { font-size: 11px; color: var(--text-secondary); white-space: nowrap; }
465
+ .cmd-interval-row input[type="range"] {
466
+ flex: 1;
467
+ accent-color: var(--accent);
468
+ }
469
+ .cmd-interval-row .interval-val {
470
+ font-size: 11px;
471
+ color: var(--accent);
472
+ min-width: 50px;
473
+ text-align: right;
474
+ }
475
+
476
+ /* ---- File Browser panel ---- */
477
+ .file-session-select {
478
+ width: 100%;
479
+ background: var(--bg-input);
480
+ color: var(--text-primary);
481
+ border: 1px solid var(--border);
482
+ border-radius: var(--radius);
483
+ padding: 4px 8px;
484
+ font-size: 12px;
485
+ outline: none;
486
+ margin-bottom: 8px;
487
+ }
488
+ .file-path-bar {
489
+ display: flex;
490
+ align-items: center;
491
+ gap: 4px;
492
+ margin-bottom: 6px;
493
+ }
494
+ .file-path-bar input {
495
+ flex: 1;
496
+ background: var(--bg-input);
497
+ color: var(--text-primary);
498
+ border: 1px solid var(--border);
499
+ border-radius: var(--radius);
500
+ padding: 4px 8px;
501
+ font-size: 12px;
502
+ outline: none;
503
+ }
504
+ .file-list {
505
+ max-height: 300px;
506
+ overflow-y: auto;
507
+ border: 1px solid var(--border);
508
+ border-radius: var(--radius);
509
+ background: var(--bg-input);
510
+ }
511
+ .file-list::-webkit-scrollbar { width: 6px; }
512
+ .file-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
513
+ .file-entry {
514
+ display: flex;
515
+ align-items: center;
516
+ gap: 6px;
517
+ padding: 4px 8px;
518
+ font-size: 12px;
519
+ cursor: pointer;
520
+ border-bottom: 1px solid rgba(48,54,61,.3);
521
+ }
522
+ .file-entry:last-child { border-bottom: none; }
523
+ .file-entry:hover { background: rgba(47,129,247,.1); }
524
+ .file-entry .file-icon { width: 14px; text-align: center; flex-shrink: 0; }
525
+ .file-entry .file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
526
+ .file-entry .file-size { color: var(--text-secondary); font-size: 11px; flex-shrink: 0; }
527
+ .file-icon.dir { color: var(--accent); }
528
+ .file-icon.file { color: var(--text-secondary); }
529
+
530
+ .file-entry.selected { background: rgba(47,129,247,.22); }
531
+ .file-entry.selected .file-name { color: var(--accent); font-weight: 600; }
532
+ .file-card.selected {
533
+ background: rgba(47,129,247,.18);
534
+ outline: 1px solid var(--accent);
535
+ }
536
+ .file-card.selected .card-name { color: var(--accent); font-weight: 600; }
537
+
538
+ .file-action-row {
539
+ display: flex;
540
+ align-items: center;
541
+ gap: 6px;
542
+ margin-top: 8px;
543
+ }
544
+ .file-action-row .file-selected-info {
545
+ flex: 1;
546
+ font-size: 11px;
547
+ color: var(--text-secondary);
548
+ overflow: hidden;
549
+ text-overflow: ellipsis;
550
+ white-space: nowrap;
551
+ }
552
+
553
+ .upload-bar-fill.down { background: var(--warning); }
554
+ .upload-bar-fill.down.done { background: var(--success); }
555
+
556
+ .upload-rate-row {
557
+ display: flex;
558
+ align-items: center;
559
+ justify-content: space-between;
560
+ gap: 6px;
561
+ margin: 8px 0 2px;
562
+ }
563
+ .upload-rate-row label {
564
+ font-size: 12px;
565
+ color: var(--text-primary);
566
+ white-space: nowrap;
567
+ font-weight: 600;
568
+ }
569
+ .upload-rate-row .rate-val {
570
+ font-size: 13px;
571
+ color: var(--accent);
572
+ font-weight: 700;
573
+ white-space: nowrap;
574
+ }
575
+ .upload-rate-slider-row { margin: 0 0 2px; }
576
+ .upload-rate-slider-row input[type="range"] {
577
+ width: 100%;
578
+ height: 20px;
579
+ accent-color: var(--accent);
580
+ cursor: pointer;
581
+ }
582
+ .upload-rate-marks {
583
+ display: flex;
584
+ justify-content: space-between;
585
+ font-size: 10px;
586
+ color: var(--text-secondary);
587
+ user-select: none;
588
+ }
589
+
590
+ /* ---- Upload progress bar ---- */
591
+ .upload-progress-area {
592
+ background: var(--bg-secondary);
593
+ border-top: 1px solid var(--border);
594
+ padding: 8px 12px;
595
+ max-height: 150px;
596
+ overflow-y: auto;
597
+ flex-shrink: 0;
598
+ display: none;
599
+ }
600
+ .upload-progress-area.visible { display: block; }
601
+ .upload-progress-area::-webkit-scrollbar { width: 6px; }
602
+ .upload-progress-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
603
+ .upload-task {
604
+ margin-bottom: 8px;
605
+ }
606
+ .upload-task:last-child { margin-bottom: 0; }
607
+ .upload-task-header {
608
+ display: flex;
609
+ align-items: center;
610
+ justify-content: space-between;
611
+ font-size: 12px;
612
+ margin-bottom: 3px;
613
+ }
614
+ .upload-task-name {
615
+ color: var(--text-primary);
616
+ overflow: hidden;
617
+ text-overflow: ellipsis;
618
+ white-space: nowrap;
619
+ max-width: 60%;
620
+ }
621
+ .upload-task-info {
622
+ color: var(--text-secondary);
623
+ font-size: 11px;
624
+ }
625
+ .upload-bar {
626
+ height: 6px;
627
+ background: var(--bg-tertiary);
628
+ border-radius: 3px;
629
+ overflow: hidden;
630
+ }
631
+ .upload-bar-fill {
632
+ height: 100%;
633
+ background: var(--accent);
634
+ border-radius: 3px;
635
+ transition: width .15s;
636
+ width: 0%;
637
+ }
638
+ .upload-bar-fill.done { background: var(--success); }
639
+ .upload-bar-fill.error { background: var(--error); }
640
+ .upload-dropzone {
641
+ position: absolute;
642
+ top: 0; left: 0; right: 0; bottom: 0;
643
+ background: rgba(47,129,247,.15);
644
+ border: 3px dashed var(--accent);
645
+ display: none;
646
+ align-items: center;
647
+ justify-content: center;
648
+ z-index: 50;
649
+ pointer-events: none;
650
+ font-size: 18px;
651
+ color: var(--accent);
652
+ font-weight: 600;
653
+ }
654
+ .upload-dropzone.active { display: flex; }
655
+
656
+ /* ---- File browser main area ---- */
657
+ .file-main-area {
658
+ flex: 1;
659
+ overflow: auto;
660
+ padding: 8px;
661
+ background: var(--bg-primary);
662
+ }
663
+ .file-main-area.hidden { display: none; }
664
+ .file-breadcrumb {
665
+ display: flex;
666
+ align-items: center;
667
+ gap: 4px;
668
+ flex-wrap: wrap;
669
+ margin-bottom: 8px;
670
+ }
671
+ .file-breadcrumb-item {
672
+ cursor: pointer;
673
+ color: var(--accent);
674
+ font-size: 13px;
675
+ }
676
+ .file-breadcrumb-item:hover { text-decoration: underline; }
677
+ .file-breadcrumb-sep { color: var(--text-secondary); font-size: 13px; }
678
+ .file-grid {
679
+ display: flex;
680
+ flex-wrap: wrap;
681
+ gap: 8px;
682
+ }
683
+ .file-card {
684
+ display: flex;
685
+ flex-direction: column;
686
+ align-items: center;
687
+ width: 90px;
688
+ padding: 8px 4px;
689
+ border-radius: var(--radius);
690
+ cursor: pointer;
691
+ transition: background .15s;
692
+ }
693
+ .file-card:hover { background: var(--bg-tertiary); }
694
+ .file-card .card-icon { font-size: 28px; margin-bottom: 4px; }
695
+ .file-card .card-name {
696
+ font-size: 11px;
697
+ text-align: center;
698
+ word-break: break-all;
699
+ max-width: 100%;
700
+ overflow: hidden;
701
+ text-overflow: ellipsis;
702
+ display: -webkit-box;
703
+ -webkit-line-clamp: 2;
704
+ -webkit-box-orient: vertical;
705
+ }
706
+ .file-card .card-size { font-size: 10px; color: var(--text-secondary); }
707
+ .file-card-icon-dir { color: var(--accent); }
708
+ .file-card-icon-file { color: var(--text-secondary); }
709
+
710
+ /* ---- File main toolbar + detail table view ---- */
711
+ .file-toolbar {
712
+ display: flex;
713
+ align-items: center;
714
+ justify-content: space-between;
715
+ gap: 8px;
716
+ margin-bottom: 8px;
717
+ }
718
+ .file-toolbar .file-breadcrumb { margin-bottom: 0; flex: 1; }
719
+ .file-view-toggle {
720
+ display: flex;
721
+ gap: 4px;
722
+ flex-shrink: 0;
723
+ }
724
+ .file-view-toggle .btn { padding: 3px 10px; font-size: 11px; }
725
+ .file-table {
726
+ width: 100%;
727
+ border: 1px solid var(--border);
728
+ border-radius: var(--radius);
729
+ overflow: hidden;
730
+ background: var(--bg-input);
731
+ font-size: 12px;
732
+ }
733
+ .file-table-head,
734
+ .file-table-row {
735
+ display: grid;
736
+ grid-template-columns: minmax(160px, 2fr) 90px 70px 130px 130px 110px;
737
+ gap: 8px;
738
+ align-items: center;
739
+ padding: 6px 10px;
740
+ }
741
+ .file-table-head {
742
+ background: var(--bg-tertiary);
743
+ color: var(--text-secondary);
744
+ font-weight: 600;
745
+ border-bottom: 1px solid var(--border);
746
+ position: sticky;
747
+ top: 0;
748
+ }
749
+ .file-table-row {
750
+ cursor: pointer;
751
+ border-bottom: 1px solid rgba(48,54,61,.3);
752
+ }
753
+ .file-table-row:last-child { border-bottom: none; }
754
+ .file-table-row:hover { background: rgba(47,129,247,.1); }
755
+ .file-table-row.selected { background: rgba(47,129,247,.18); }
756
+ .file-table-row .t-name {
757
+ display: flex;
758
+ align-items: center;
759
+ gap: 6px;
760
+ overflow: hidden;
761
+ text-overflow: ellipsis;
762
+ white-space: nowrap;
763
+ }
764
+ .file-table-row .table-icon { flex-shrink: 0; }
765
+ .file-table-row .t-size,
766
+ .file-table-row .t-type,
767
+ .file-table-row .t-mtime,
768
+ .file-table-row .t-owner,
769
+ .file-table-row .t-perm {
770
+ color: var(--text-secondary);
771
+ font-size: 11px;
772
+ overflow: hidden;
773
+ text-overflow: ellipsis;
774
+ white-space: nowrap;
775
+ }
776
+ .file-table-row .t-perm { font-family: Consolas, "Courier New", monospace; }
777
+ </style>
778
+ </head>
779
+ <body>
780
+ <div class="app">
781
+ <header>
782
+ <div class="header-left">
783
+ <h1>WebSSH</h1>
784
+ <span class="subtitle">端口自动探测终端 · 多标签 · 会话独立进程</span>
785
+ </div>
786
+ <div class="header-right">
787
+ <span class="status-badge status-disconnected" id="status-badge">
788
+ <span class="status-dot"></span>
789
+ <span id="status-text">未连接</span>
790
+ </span>
791
+ </div>
792
+ </header>
793
+
794
+ <div class="main">
795
+ <!-- Sidebar -->
796
+ <aside class="sidebar">
797
+ <div class="sidebar-tabs">
798
+ <button class="sidebar-tab active" data-tab="sessions" onclick="switchSidebarTab('sessions')">会话管理</button>
799
+ <button class="sidebar-tab" data-tab="command" onclick="switchSidebarTab('command')">命令发送</button>
800
+ <button class="sidebar-tab" data-tab="files" onclick="switchSidebarTab('files')">文件夹</button>
801
+ <button class="sidebar-tab" data-tab="toolbar" onclick="switchSidebarTab('toolbar')">工具栏</button>
802
+ </div>
803
+
804
+ <!-- Tab: Sessions -->
805
+ <div class="sidebar-content" id="sidebar-sessions">
806
+ <div class="panel">
807
+ <h2>进程探测 / 手动连接</h2>
808
+ <div id="process-list">
809
+ <p class="info">检测中...</p>
810
+ </div>
811
+ <div style="margin-top:6px; margin-bottom:8px;">
812
+ <button class="btn btn-secondary btn-sm" onclick="detectProcess()">刷新</button>
813
+ </div>
814
+ <div class="input-row" style="margin-top:8px;">
815
+ <input type="number" id="manual-port" placeholder="手动端口 (如 4983)" min="1" max="65535">
816
+ <button class="btn btn-sm" onclick="connectManual()">连接</button>
817
+ </div>
818
+ </div>
819
+
820
+ <div class="panel">
821
+ <h2>会话控制</h2>
822
+ <div class="btn-row">
823
+ <button class="btn btn-danger btn-sm" onclick="disconnectActive()">断开当前标签</button>
824
+ <button class="btn btn-secondary btn-sm" onclick="saveActiveLog()">保存日志</button>
825
+ </div>
826
+ <p class="info" style="margin-top:4px;">断开页面连接不会结束SSH会话,后端进程会继续保持。</p>
827
+ </div>
828
+
829
+ <div class="panel">
830
+ <h2>后端会话 (独立进程)</h2>
831
+ <div id="session-list">
832
+ <p class="info">加载中...</p>
833
+ </div>
834
+ <div style="margin-top:6px;">
835
+ <button class="btn btn-secondary btn-sm" onclick="loadSessions()">刷新</button>
836
+ </div>
837
+ </div>
838
+
839
+ <div class="panel">
840
+ <h2>历史记录</h2>
841
+ <div class="log-mode-bar">
842
+ <label>显示模式:</label>
843
+ <select id="log-display-mode" onchange="updateLogDisplayMode()">
844
+ <option value="raw">原文本</option>
845
+ <option value="debug">调试模式</option>
846
+ </select>
847
+ </div>
848
+ <div id="log-list">
849
+ <p class="info">加载中...</p>
850
+ </div>
851
+ </div>
852
+ </div>
853
+
854
+ <!-- Tab: Command Sender -->
855
+ <div class="sidebar-content" id="sidebar-command" style="display:none;">
856
+ <div class="panel">
857
+ <h2>命令发送</h2>
858
+ <select class="cmd-target-select" id="cmd-target-session">
859
+ <option value="">-- 选择目标会话 --</option>
860
+ </select>
861
+ <textarea class="cmd-textarea" id="cmd-input" placeholder="输入要发送的命令,支持多行...&#10;Alt+Enter 发送到选中会话" onkeydown="if(event.altKey&&event.key==='Enter'){event.preventDefault();sendCommandToSession();}"></textarea>
862
+ <div class="cmd-interval-row">
863
+ <label>命令间隔</label>
864
+ <input type="range" id="cmd-interval" min="0" max="2000" value="100" step="50">
865
+ <span class="interval-val" id="cmd-interval-val">100ms</span>
866
+ </div>
867
+ <div class="btn-row">
868
+ <button class="btn btn-sm" onclick="sendCommandToSession()">发送 (Alt+Enter)</button>
869
+ <button class="btn btn-sm btn-secondary" onclick="sendCommandToAll()">群体发送</button>
870
+ <button class="btn btn-sm btn-secondary" onclick="clearCmdInput()">清空</button>
871
+ </div>
872
+ </div>
873
+ </div>
874
+
875
+ <!-- Tab: File Browser -->
876
+ <div class="sidebar-content" id="sidebar-files" style="display:none;">
877
+ <div class="panel">
878
+ <h2>远程文件夹</h2>
879
+ <select class="file-session-select" id="file-session-select" onchange="onFileSessionChange()">
880
+ <option value="">-- 选择会话 --</option>
881
+ </select>
882
+ <div class="file-path-bar">
883
+ <input type="text" id="file-current-path" placeholder="/" readonly>
884
+ <button class="btn btn-sm btn-secondary" onclick="fileGoUp()">上级</button>
885
+ <button class="btn btn-sm btn-secondary" onclick="fileRefresh()">刷新</button>
886
+ </div>
887
+ <div class="file-list" id="file-list-sidebar">
888
+ <p class="info">请先选择会话</p>
889
+ </div>
890
+ <div class="file-action-row">
891
+ <span class="file-selected-info" id="file-selected-info">点击文件可选中/取消选中</span>
892
+ <button class="btn btn-sm" onclick="downloadSelectedFiles()">下载选中</button>
893
+ <button class="btn btn-sm btn-secondary" onclick="clearFileSelection()">清除</button>
894
+ </div>
895
+ </div>
896
+ <div class="panel">
897
+ <h2>上传设置</h2>
898
+ <div class="upload-rate-row">
899
+ <label>上传限速</label>
900
+ <span class="rate-val" id="upload-rate-val">不限速</span>
901
+ </div>
902
+ <div class="upload-rate-slider-row">
903
+ <input type="range" id="upload-rate-slider" min="0" max="15" value="0" step="1">
904
+ </div>
905
+ <div class="upload-rate-marks">
906
+ <span>不限</span><span>1K</span><span>50K</span><span>1M</span><span>10M</span><span>50M</span>
907
+ </div>
908
+ <p class="info" style="margin-top:6px;">将文件拖拽到主页面文件夹区域即可上传。</p>
909
+ </div>
910
+ </div>
911
+
912
+ <!-- Tab: Toolbar -->
913
+ <div class="sidebar-content" id="sidebar-toolbar" style="display:none;">
914
+ <div class="panel">
915
+ <h2>工具栏管理</h2>
916
+ <div class="btn-row">
917
+ <button class="btn btn-sm btn-secondary" onclick="loadToolbar(true)">刷新工具栏</button>
918
+ <button class="btn btn-sm btn-secondary" onclick="triggerImportExtension()">导入工具栏 (.md)</button>
919
+ </div>
920
+ <input type="file" id="ext-import-file" accept=".md,.markdown" style="display:none"
921
+ onchange="importExtensionFile(this)">
922
+ <p class="info" style="margin-top:4px;">导入的 .md 会保存到服务器 extension/ 目录并立即生效。</p>
923
+ </div>
924
+ <div id="toolbar-container">
925
+ <p class="info">工具栏加载中...</p>
926
+ </div>
927
+ <hr class="toolbar-divider" id="ext-divider" style="display:none;">
928
+ <div id="extension-container"></div>
929
+ </div>
930
+ </aside>
931
+
932
+ <!-- Terminal area -->
933
+ <main class="terminal-area">
934
+ <div class="terminal-tabs" id="terminal-tabs">
935
+ <!-- terminal tabs inserted here -->
936
+ </div>
937
+ <div class="terminal-pages" id="terminal-pages">
938
+ <!-- terminal pages inserted here -->
939
+ </div>
940
+ <!-- File browser main area (shown when files tab is active) -->
941
+ <div class="file-main-area hidden" id="file-main-area">
942
+ <div id="file-main-content">
943
+ <p class="info">请从左侧"文件夹"标签页选择一个会话。</p>
944
+ </div>
945
+ </div>
946
+ <!-- Drag-and-drop overlay (shown over terminal/file area) -->
947
+ <div class="upload-dropzone" id="upload-dropzone">
948
+ 释放文件以上传到当前目录
949
+ </div>
950
+ <!-- Upload progress area -->
951
+ <div class="upload-progress-area" id="upload-progress-area">
952
+ <div id="upload-tasks-container"></div>
953
+ </div>
954
+ </main>
955
+ </div>
956
+ </div>
957
+
958
+ <div class="toast" id="toast"></div>
959
+
960
+ <!-- xterm.js (local, offline-friendly) -->
961
+ <script src="/static/vendor/xterm.min.js"></script>
962
+ <script src="/static/vendor/xterm-addon-fit.min.js"></script>
963
+ <script>
964
+ (function () {
965
+ "use strict";
966
+
967
+ // ---- State ----
968
+ var tabs = []; // { id, label, type: 'terminal'|'log', term, fitAddon, ws, sid, log, status, scrollback }
969
+ var activeTabId = null;
970
+ var tabCounter = 0;
971
+ var scrollbackLimit = 10000;
972
+ var toolbarConfig = {};
973
+ var currentSid = null; // active session sid for toolbar send
974
+
975
+ // Upload rate ladder (Kbps). Index 0 = unlimited.
976
+ // A stepped slider keeps common rates (1K/10K/100K/1M/10M...) easy
977
+ // to hit precisely instead of a 1..10240 linear scale that is
978
+ // impossible to aim at.
979
+ var UPLOAD_RATE_STEPS = [0, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1024, 2048, 5120, 10240, 20480, 51200];
980
+
981
+ // ---- Terminal init ----
982
+ function newTerminal(scrollback) {
983
+ var term = new Terminal({
984
+ fontSize: 14,
985
+ fontFamily: 'Consolas, "Courier New", monospace',
986
+ cursorBlink: true,
987
+ scrollback: scrollback > 0 ? scrollback : 10000,
988
+ theme: {
989
+ background: "#000000",
990
+ foreground: "#e6edf3",
991
+ cursor: "#ffffff"
992
+ }
993
+ });
994
+ return term;
995
+ }
996
+
997
+ function fitTab(tabId) {
998
+ var tab = tabs.find(function (t) { return t.id === tabId; });
999
+ if (tab && tab.fitAddon && tab.type === 'terminal') {
1000
+ try { tab.fitAddon.fit(); } catch (e) {}
1001
+ }
1002
+ }
1003
+
1004
+ // ---- Tab management ----
1005
+ function createTerminalTab(label) {
1006
+ var id = ++tabCounter;
1007
+ var pageDiv = document.createElement('div');
1008
+ pageDiv.className = 'terminal-page';
1009
+ pageDiv.dataset.tabId = id;
1010
+
1011
+ var containerDiv = document.createElement('div');
1012
+ containerDiv.className = 'terminal-container';
1013
+ pageDiv.appendChild(containerDiv);
1014
+ document.getElementById('terminal-pages').appendChild(pageDiv);
1015
+
1016
+ var term = newTerminal(scrollbackLimit);
1017
+ var fitAddon = new FitAddon.FitAddon();
1018
+ term.loadAddon(fitAddon);
1019
+ term.open(containerDiv);
1020
+
1021
+ var tab = {
1022
+ id: id,
1023
+ label: label,
1024
+ type: 'terminal',
1025
+ term: term,
1026
+ fitAddon: fitAddon,
1027
+ ws: null,
1028
+ sid: null,
1029
+ log: [],
1030
+ status: 'disconnected',
1031
+ pageDiv: pageDiv
1032
+ };
1033
+ tabs.push(tab);
1034
+
1035
+ // Tab header
1036
+ renderTerminalTabs();
1037
+
1038
+ // Terminal input -> ws
1039
+ term.onData(function (data) {
1040
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
1041
+ tab.ws.send(JSON.stringify({ type: "input", data: data }));
1042
+ }
1043
+ });
1044
+
1045
+ // Resize observer
1046
+ var ro = new ResizeObserver(function () { fitTab(id); });
1047
+ ro.observe(containerDiv);
1048
+
1049
+ activateTab(id);
1050
+ return tab;
1051
+ }
1052
+
1053
+ function createLogTab(label, content, logFile, rawContent, isInteractive) {
1054
+ var id = ++tabCounter;
1055
+ var pageDiv = document.createElement('div');
1056
+ pageDiv.className = 'terminal-page read-only';
1057
+ pageDiv.dataset.tabId = id;
1058
+
1059
+ var pre = document.createElement('pre');
1060
+ pre.textContent = content;
1061
+ pageDiv.appendChild(pre);
1062
+ document.getElementById('terminal-pages').appendChild(pageDiv);
1063
+
1064
+ var tab = {
1065
+ id: id,
1066
+ label: label,
1067
+ type: 'log',
1068
+ term: null,
1069
+ fitAddon: null,
1070
+ ws: null,
1071
+ sid: null,
1072
+ log: [],
1073
+ status: 'idle',
1074
+ pageDiv: pageDiv,
1075
+ logFile: logFile || null,
1076
+ rawContent: rawContent || content,
1077
+ isInteractive: isInteractive || false
1078
+ };
1079
+ tabs.push(tab);
1080
+ renderTerminalTabs();
1081
+ activateTab(id);
1082
+ return tab;
1083
+ }
1084
+
1085
+ function formatTabLabel(tab) {
1086
+ if (!tab || tab.type !== 'terminal') return tab ? tab.label : '';
1087
+ var sidPart = tab.sid ? tab.sid.substring(0, 8) : '';
1088
+ if (tab.port) {
1089
+ return sidPart ? ('端口 ' + tab.port + ' · ' + sidPart) : ('端口 ' + tab.port);
1090
+ }
1091
+ if (sidPart) return '会话 ' + sidPart;
1092
+ return tab.label || ('Tab ' + tab.id);
1093
+ }
1094
+
1095
+ function renderTerminalTabs() {
1096
+ var container = document.getElementById('terminal-tabs');
1097
+ container.innerHTML = '';
1098
+ tabs.forEach(function (tab) {
1099
+ var el = document.createElement('div');
1100
+ el.className = 'terminal-tab' + (tab.id === activeTabId ? ' active' : '');
1101
+ el.onclick = function () { activateTab(tab.id); };
1102
+
1103
+ // Status dot for terminals
1104
+ if (tab.type === 'terminal') {
1105
+ var dot = document.createElement('span');
1106
+ dot.className = 'tab-dot ' + (tab.status || 'disconnected');
1107
+ el.appendChild(dot);
1108
+ }
1109
+
1110
+ var label = document.createElement('span');
1111
+ label.textContent = formatTabLabel(tab);
1112
+ el.appendChild(label);
1113
+
1114
+ var close = document.createElement('span');
1115
+ close.className = 'tab-close';
1116
+ close.textContent = '\u00d7';
1117
+ close.onclick = function (e) {
1118
+ e.stopPropagation();
1119
+ closeTab(tab.id);
1120
+ };
1121
+ el.appendChild(close);
1122
+
1123
+ container.appendChild(el);
1124
+ });
1125
+ }
1126
+
1127
+ function activateTab(id) {
1128
+ activeTabId = id;
1129
+ tabs.forEach(function (tab) {
1130
+ tab.pageDiv.classList.toggle('active', tab.id === id);
1131
+ });
1132
+ renderTerminalTabs();
1133
+ var tab = tabs.find(function (t) { return t.id === id; });
1134
+ if (tab && tab.type === 'terminal') {
1135
+ setTimeout(function () { fitTab(id); tab.term.focus(); }, 50);
1136
+ currentSid = tab.sid;
1137
+ updateStatus(tab.status);
1138
+ } else {
1139
+ currentSid = null;
1140
+ updateStatus('idle');
1141
+ }
1142
+ }
1143
+
1144
+ function closeTab(id) {
1145
+ var idx = tabs.findIndex(function (t) { return t.id === id; });
1146
+ if (idx < 0) return;
1147
+ var tab = tabs[idx];
1148
+ // Close WebSocket if open
1149
+ if (tab.ws) {
1150
+ try { tab.ws.close(); } catch (e) {}
1151
+ }
1152
+ // Dispose terminal
1153
+ if (tab.term) {
1154
+ try { tab.term.dispose(); } catch (e) {}
1155
+ }
1156
+ // Remove DOM
1157
+ tab.pageDiv.remove();
1158
+ tabs.splice(idx, 1);
1159
+ // Activate neighbor
1160
+ if (activeTabId === id) {
1161
+ if (tabs.length > 0) {
1162
+ activateTab(tabs[Math.min(idx, tabs.length - 1)].id);
1163
+ } else {
1164
+ activeTabId = null;
1165
+ renderTerminalTabs();
1166
+ updateStatus('disconnected');
1167
+ }
1168
+ } else {
1169
+ renderTerminalTabs();
1170
+ }
1171
+ }
1172
+
1173
+ function getActiveTerminalTab() {
1174
+ return tabs.find(function (t) { return t.id === activeTabId && t.type === 'terminal'; });
1175
+ }
1176
+
1177
+ // ---- Sidebar tabs ----
1178
+ function switchSidebarTab(name) {
1179
+ document.querySelectorAll('.sidebar-tab').forEach(function (el) {
1180
+ el.classList.toggle('active', el.dataset.tab === name);
1181
+ });
1182
+ document.getElementById('sidebar-sessions').style.display = name === 'sessions' ? '' : 'none';
1183
+ document.getElementById('sidebar-command').style.display = name === 'command' ? '' : 'none';
1184
+ document.getElementById('sidebar-files').style.display = name === 'files' ? '' : 'none';
1185
+ document.getElementById('sidebar-toolbar').style.display = name === 'toolbar' ? '' : 'none';
1186
+
1187
+ // Show/hide file main area vs terminal pages
1188
+ var fileMain = document.getElementById('file-main-area');
1189
+ var termPages = document.getElementById('terminal-pages');
1190
+ var termTabs = document.getElementById('terminal-tabs');
1191
+ if (name === 'files') {
1192
+ fileMain.classList.remove('hidden');
1193
+ termPages.style.display = 'none';
1194
+ termTabs.style.display = 'none';
1195
+ refreshFileSessionSelect();
1196
+ } else {
1197
+ fileMain.classList.add('hidden');
1198
+ termPages.style.display = '';
1199
+ termTabs.style.display = '';
1200
+ }
1201
+ }
1202
+
1203
+ // ---- Process detection ----
1204
+ function detectProcess() {
1205
+ var container = document.getElementById('process-list');
1206
+ container.innerHTML = '<p class="info">检测中...</p>';
1207
+ fetch("/api/detect")
1208
+ .then(function (r) { return r.json(); })
1209
+ .then(function (data) { renderProcessList(data); })
1210
+ .catch(function (err) {
1211
+ container.innerHTML = '<p class="info">检测失败: ' + escapeHtml(String(err)) + "</p>";
1212
+ });
1213
+ }
1214
+
1215
+ function renderProcessList(data) {
1216
+ var container = document.getElementById("process-list");
1217
+ var html = "";
1218
+ if (!data || !data.results || data.results.length === 0) {
1219
+ var names = (data && data.process_names) ? data.process_names.join(", ") : "";
1220
+ html += '<p class="info">未找到匹配进程: ' + escapeHtml(names) + "</p>";
1221
+ }
1222
+ (data && data.results || []).forEach(function (proc) {
1223
+ html += '<div class="process-item">';
1224
+ html += ' <div class="process-name">' + escapeHtml(proc.name) + "</div>";
1225
+ html += ' <div class="process-pid">PID: ' + proc.pid + "</div>";
1226
+ (proc.ports || []).forEach(function (p) {
1227
+ html += ' <div class="port-item">';
1228
+ html += ' <span><span class="port-text">端口 ' + p.port + "</span>";
1229
+ html += ' <span class="port-addr"> ' + escapeHtml(p.full) + "</span></span>";
1230
+ html += ' <button class="btn btn-sm btn-success" onclick="connectToPort(' + p.port + ')">连接</button>';
1231
+ html += " </div>";
1232
+ });
1233
+ html += "</div>";
1234
+ });
1235
+ if (data && data.manual_port) {
1236
+ html += '<div class="process-item">';
1237
+ html += ' <div class="info">配置文件指定端口: ' + data.manual_port + '</div>';
1238
+ html += ' <div class="port-item">';
1239
+ html += ' <span class="port-text">端口 ' + data.manual_port + " (手动)</span>";
1240
+ html += ' <button class="btn btn-sm" onclick="connectToPort(' + data.manual_port + ')">连接</button>';
1241
+ html += " </div>";
1242
+ html += "</div>";
1243
+ }
1244
+ container.innerHTML = html;
1245
+ }
1246
+
1247
+ // ---- Connect (spawns a NEW worker session) ----
1248
+ function connectToPort(port) {
1249
+ var tab = createTerminalTab("端口 " + port);
1250
+ tab.port = port; // remember target port for label/selectors
1251
+ tab.status = 'connecting';
1252
+ renderTerminalTabs();
1253
+ tab.term.write("\r\n*** 正在连接端口 " + port + " ... ***\r\n");
1254
+ openWs(tab, "port=" + port);
1255
+ }
1256
+
1257
+ // ---- Attach to an existing detached session ----
1258
+ function attachSession(sid) {
1259
+ var tab = createTerminalTab("会话 " + sid.substring(0, 8));
1260
+ tab.sid = sid;
1261
+ tab.status = 'connecting';
1262
+ renderTerminalTabs();
1263
+ tab.term.write("\r\n*** 接入会话 " + sid + " ... ***\r\n");
1264
+ openWs(tab, "attach=" + sid);
1265
+ }
1266
+
1267
+ function openWs(tab, query) {
1268
+ var proto = location.protocol === "https:" ? "wss:" : "ws:";
1269
+ var url = proto + "//" + location.host + "/ws/ssh?" + query;
1270
+ try {
1271
+ tab.ws = new WebSocket(url);
1272
+ } catch (e) {
1273
+ tab.term.write("\r\n*** WebSocket error: " + escapeHtml(String(e.message || e)) + " ***\r\n");
1274
+ return;
1275
+ }
1276
+
1277
+ tab.ws.onopen = function () {
1278
+ tab.status = 'connected';
1279
+ updateTabStatus(tab);
1280
+ if (tab.term) tab.term.focus();
1281
+ tab.ws.send(JSON.stringify({ type: "resize", cols: tab.term.cols, rows: tab.term.rows }));
1282
+ };
1283
+
1284
+ tab.ws.onmessage = function (event) {
1285
+ var msg;
1286
+ try { msg = JSON.parse(event.data); } catch (e) { return; }
1287
+ if (msg.type === "output") {
1288
+ tab.term.write(msg.data);
1289
+ tab.log.push(msg.data);
1290
+ } else if (msg.type === "status") {
1291
+ // hello/state from the worker. The sid must be recorded here:
1292
+ // new "connect" sessions are created without a sid and only
1293
+ // the hello event tells us which worker session owns this tab.
1294
+ if (msg.sid) {
1295
+ tab.sid = msg.sid;
1296
+ // Label now shows port + session id; refresh the tab header.
1297
+ renderTerminalTabs();
1298
+ refreshCmdSessionSelect();
1299
+ refreshFileSessionSelect();
1300
+ }
1301
+ if (msg.data === "connected") {
1302
+ tab.status = 'connected';
1303
+ tab.term.write("\r\n*** SSH 已连接 ***\r\n");
1304
+ } else {
1305
+ tab.term.write("\r\n*** " + msg.data + " ***\r\n");
1306
+ }
1307
+ updateTabStatus(tab);
1308
+ } else if (msg.type === "error") {
1309
+ tab.term.write("\r\n*** Error: " + msg.data + " ***\r\n");
1310
+ tab.status = 'disconnected';
1311
+ updateTabStatus(tab);
1312
+ } else if (msg.type && msg.type.indexOf("sftp_") === 0) {
1313
+ // SFTP events (sftp_home, sftp_list, sftp_progress, sftp_done, sftp_error, sftp_mkdir_done)
1314
+ handleSftpEvent(msg);
1315
+ }
1316
+ };
1317
+
1318
+ tab.ws.onclose = function (event) {
1319
+ if (tab.status === 'connected') {
1320
+ if (event && event.code === 1006) {
1321
+ tab.term.write("\r\n*** WebSocket 连接已断开 (可能是网络/隧道切换) ***\r\n");
1322
+ tab.term.write("\r\n*** 后端会话若仍在运行,可从左侧\"后端会话\"重新接入 ***\r\n");
1323
+ } else {
1324
+ tab.term.write("\r\n*** 连接已关闭 (code " + (event && event.code) + ") ***\r\n");
1325
+ }
1326
+ }
1327
+ tab.status = 'disconnected';
1328
+ updateTabStatus(tab);
1329
+ loadSessions();
1330
+ };
1331
+
1332
+ tab.ws.onerror = function () {
1333
+ if (tab.status === 'connected') {
1334
+ tab.term.write("\r\n*** WebSocket 错误: 通道异常中断 ***\r\n");
1335
+ }
1336
+ tab.status = 'disconnected';
1337
+ updateTabStatus(tab);
1338
+ };
1339
+ }
1340
+
1341
+ function updateTabStatus(tab) {
1342
+ if (tab.id === activeTabId) {
1343
+ updateStatus(tab.status);
1344
+ if (tab.status === 'connected') {
1345
+ currentSid = tab.sid;
1346
+ } else {
1347
+ currentSid = null;
1348
+ }
1349
+ }
1350
+ renderTerminalTabs();
1351
+ }
1352
+
1353
+ // NOTE: toolbar buttons are intentionally NEVER disabled.
1354
+ // They must stay clickable at all times (even with no session yet);
1355
+ // clicking without an active session shows a toast hint instead.
1356
+ // This removes the old "grey until page refresh" behaviour that
1357
+ // was caused by a sid that never reached the frontend.
1358
+
1359
+ function connectManual() {
1360
+ var port = parseInt(document.getElementById("manual-port").value, 10);
1361
+ if (!port || port < 1 || port > 65535) {
1362
+ showToast("请输入有效端口号 (1-65535)");
1363
+ return;
1364
+ }
1365
+ connectToPort(port);
1366
+ }
1367
+
1368
+ // ---- Disconnect active terminal tab ----
1369
+ function disconnectActive() {
1370
+ var tab = getActiveTerminalTab();
1371
+ if (!tab) { showToast("没有活动的终端标签"); return; }
1372
+ if (tab.ws) {
1373
+ try { tab.ws.close(); } catch (e) {}
1374
+ }
1375
+ tab.status = 'disconnected';
1376
+ updateTabStatus(tab);
1377
+ }
1378
+
1379
+ // ---- Save active tab log ----
1380
+ function saveActiveLog() {
1381
+ var tab = getActiveTerminalTab();
1382
+ if (!tab || tab.log.length === 0) {
1383
+ showToast("暂无日志可保存");
1384
+ return;
1385
+ }
1386
+ var content = tab.log.join("");
1387
+ var blob = new Blob([content], { type: "text/plain;charset=utf-8" });
1388
+ var a = document.createElement("a");
1389
+ var ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1390
+ a.href = URL.createObjectURL(blob);
1391
+ a.download = "session_" + ts + ".txt";
1392
+ document.body.appendChild(a);
1393
+ a.click();
1394
+ document.body.removeChild(a);
1395
+ URL.revokeObjectURL(a.href);
1396
+ showToast("日志已下载");
1397
+ }
1398
+
1399
+ // ---- Backend sessions list ----
1400
+ function loadSessions() {
1401
+ fetch("/api/sessions")
1402
+ .then(function (r) { return r.json(); })
1403
+ .then(function (data) {
1404
+ var container = document.getElementById("session-list");
1405
+ if (!data.sessions || data.sessions.length === 0) {
1406
+ container.innerHTML = '<p class="info">暂无后端会话</p>';
1407
+ return;
1408
+ }
1409
+ var html = "";
1410
+ data.sessions.forEach(function (s) {
1411
+ var stateColor = s.state === "connected" ? "var(--success)" : "var(--text-secondary)";
1412
+ html += '<div class="session-item">';
1413
+ html += ' <div class="port-item">';
1414
+ html += ' <span><span class="port-text" style="color:' + stateColor + '">' +
1415
+ escapeHtml(s.state || "?") + " · 端口 " + s.ssh_port + "</span>";
1416
+ html += ' <span class="port-addr">PID ' + s.pid + " · " + escapeHtml(s.created || "") + "</span></span>";
1417
+ html += " </div>";
1418
+ html += ' <div class="btn-row" style="margin-top:3px;">';
1419
+ html += ' <button class="btn btn-sm btn-success" onclick="attachSession(\'' + s.sid + '\')">接入</button>';
1420
+ html += ' <button class="btn btn-sm btn-danger" onclick="shutdownSession(\'' + s.sid + '\')">关闭</button>';
1421
+ html += " </div>";
1422
+ html += "</div>";
1423
+ });
1424
+ container.innerHTML = html;
1425
+ })
1426
+ .catch(function () {
1427
+ document.getElementById("session-list").innerHTML = '<p class="info">加载失败</p>';
1428
+ });
1429
+ }
1430
+
1431
+ function shutdownSession(sid) {
1432
+ fetch("/api/sessions/" + sid + "/shutdown", { method: "POST" })
1433
+ .then(function () {
1434
+ showToast("已发送关闭指令");
1435
+ setTimeout(loadSessions, 500);
1436
+ })
1437
+ .catch(function () { showToast("关闭失败"); });
1438
+ }
1439
+
1440
+ // ---- Load history (click to open in tab, reuse existing tab) ----
1441
+ var logDisplayMode = 'raw'; // 'raw' or 'debug'
1442
+
1443
+ function updateLogDisplayMode() {
1444
+ var sel = document.getElementById('log-display-mode');
1445
+ if (sel) logDisplayMode = sel.value;
1446
+ // Re-render all open log tabs with the new mode
1447
+ tabs.forEach(function (tab) {
1448
+ if (tab.type === 'log' && tab.rawContent) {
1449
+ var pre = tab.pageDiv.querySelector('pre');
1450
+ if (pre) {
1451
+ pre.textContent = formatLogContent(tab.rawContent, tab.isInteractive, logDisplayMode);
1452
+ }
1453
+ }
1454
+ });
1455
+ }
1456
+
1457
+ function formatLogContent(content, isInteractive, mode) {
1458
+ if (!isInteractive) return content;
1459
+ if (mode === 'debug') return content;
1460
+ // Raw mode: strip debug formatting from interactive logs
1461
+ // Format: "2026-09-12 14:32:46.123 [OUT] 'text\\r\\n'"
1462
+ // Extract just the actual text from [OUT] and [IN] lines
1463
+ var lines = content.split('\n');
1464
+ var output = [];
1465
+ var re = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \[(\w+)\] (.*)$/;
1466
+ for (var i = 0; i < lines.length; i++) {
1467
+ var m = lines[i].match(re);
1468
+ if (!m) {
1469
+ // Non-log line, keep as-is
1470
+ if (lines[i].trim()) output.push(lines[i]);
1471
+ continue;
1472
+ }
1473
+ var tag = m[1];
1474
+ var text = m[2];
1475
+ if (tag === 'OUT' || tag === 'IN') {
1476
+ // Unescape repr(): 'text\r\n' -> text\r\n
1477
+ // Remove surrounding quotes
1478
+ if (text.startsWith("'") && text.endsWith("'")) {
1479
+ text = text.slice(1, -1);
1480
+ }
1481
+ // Unescape common Python repr sequences
1482
+ text = text
1483
+ .replace(/\\\\n/g, '\n')
1484
+ .replace(/\\\\r/g, '\r')
1485
+ .replace(/\\\\t/g, '\t')
1486
+ .replace(/\\\\x1b/g, '\x1b')
1487
+ .replace(/\\\\e/g, '\x1b')
1488
+ .replace(/\\\\\\\\/g, '\\\\')
1489
+ .replace(/\\\\'/g, "'");
1490
+ output.push(text);
1491
+ }
1492
+ // Skip non-OUT/IN lines (timestamps, tags like CONNECT-START etc.) in raw mode
1493
+ }
1494
+ return output.join('');
1495
+ }
1496
+
1497
+ function loadLogs() {
1498
+ fetch("/api/logs")
1499
+ .then(function (r) { return r.json(); })
1500
+ .then(function (data) {
1501
+ var container = document.getElementById("log-list");
1502
+ if (!data.logs || data.logs.length === 0) {
1503
+ container.innerHTML = '<p class="info">暂无历史记录</p>';
1504
+ return;
1505
+ }
1506
+ var html = "";
1507
+ data.logs.forEach(function (log) {
1508
+ html += '<div class="log-item" onclick="openLogInTab(\'' + log.name + '\')" data-log-name="' + escapeHtml(log.name) + '">';
1509
+ html += ' <span class="log-name">' + escapeHtml(log.name) + "</span>";
1510
+ html += "</div>";
1511
+ });
1512
+ container.innerHTML = html;
1513
+ })
1514
+ .catch(function () {
1515
+ document.getElementById("log-list").innerHTML = '<p class="info">加载失败</p>';
1516
+ });
1517
+ }
1518
+
1519
+ function openLogInTab(filename) {
1520
+ // If a tab for this file already exists, switch to it
1521
+ var existing = tabs.find(function (t) { return t.type === 'log' && t.logFile === filename; });
1522
+ if (existing) {
1523
+ activateTab(existing.id);
1524
+ return;
1525
+ }
1526
+ fetch("/api/logs/" + encodeURIComponent(filename) + "/content")
1527
+ .then(function (r) { return r.json(); })
1528
+ .then(function (data) {
1529
+ if (data.error) { showToast("读取失败: " + data.error); return; }
1530
+ var label = filename.length > 20 ? filename.substring(0, 18) + ".." : filename;
1531
+ var isInteractive = filename.startsWith("interactive_");
1532
+ var rawContent = data.content || "";
1533
+ var formatted = formatLogContent(rawContent, isInteractive, logDisplayMode);
1534
+ createLogTab(label, formatted, filename, rawContent, isInteractive);
1535
+ })
1536
+ .catch(function (e) { showToast("读取失败: " + e); });
1537
+ }
1538
+
1539
+ // ---- Toolbar ----
1540
+ var extensionData = []; // parsed extension toolbars
1541
+
1542
+ function loadToolbar(manual) {
1543
+ fetch("/api/config")
1544
+ .then(function (r) { return r.json(); })
1545
+ .then(function (data) {
1546
+ scrollbackLimit = (data.terminal && data.terminal.scrollback !== undefined)
1547
+ ? data.terminal.scrollback : 10000;
1548
+ toolbarConfig = data.toolbar || {};
1549
+ renderToolbar();
1550
+ })
1551
+ .catch(function () {
1552
+ document.getElementById("toolbar-container").innerHTML =
1553
+ '<p class="info">工具栏加载失败</p>';
1554
+ });
1555
+ loadExtensions(function () {
1556
+ if (manual) showToast('工具栏已重新加载');
1557
+ });
1558
+ }
1559
+
1560
+ function loadExtensions(done) {
1561
+ fetch("/api/extensions")
1562
+ .then(function (r) { return r.json(); })
1563
+ .then(function (data) {
1564
+ extensionData = data.extensions || [];
1565
+ renderExtensions();
1566
+ if (done) done();
1567
+ })
1568
+ .catch(function () {
1569
+ document.getElementById("extension-container").innerHTML =
1570
+ '<p class="info">额外工具栏加载失败</p>';
1571
+ if (done) done();
1572
+ });
1573
+ }
1574
+
1575
+ // ---- Toolbar import (temporary .md import without touching files) ----
1576
+ function triggerImportExtension() {
1577
+ var input = document.getElementById('ext-import-file');
1578
+ if (input) { input.value = ''; input.click(); }
1579
+ }
1580
+
1581
+ function importExtensionFile(input) {
1582
+ var file = input.files && input.files[0];
1583
+ if (!file) return;
1584
+ var fd = new FormData();
1585
+ fd.append('file', file, file.name);
1586
+ fetch('/api/extensions/import', { method: 'POST', body: fd })
1587
+ .then(function (r) { return r.json(); })
1588
+ .then(function (data) {
1589
+ if (data.ok) {
1590
+ showToast('已导入: ' + data.name + ',正在刷新工具栏');
1591
+ loadToolbar();
1592
+ } else {
1593
+ showToast('导入失败: ' + (data.error || '未知错误'));
1594
+ }
1595
+ })
1596
+ .catch(function (e) { showToast('导入失败: ' + e); });
1597
+ }
1598
+
1599
+ function renderToolbar() {
1600
+ var container = document.getElementById("toolbar-container");
1601
+ var groups = (toolbarConfig.groups || []);
1602
+ if (groups.length === 0) {
1603
+ container.innerHTML =
1604
+ '<p class="info">暂无工具栏配置。</p>' +
1605
+ '<p class="info" style="margin-top:6px;">在 config.yml 中配置 toolbar.groups 即可显示工具按钮。</p>';
1606
+ return;
1607
+ }
1608
+ var lineDelay = toolbarConfig.line_delay || 0;
1609
+ var html = "";
1610
+ if (lineDelay > 0) {
1611
+ html += '<p class="info" style="margin-bottom:6px;">行间隔: ' + lineDelay + 's</p>';
1612
+ }
1613
+ groups.forEach(function (group, gi) {
1614
+ html += '<div class="toolbar-group">';
1615
+ html += ' <div class="toolbar-group-header" onclick="toggleToolbarGroup(' + gi + ')">';
1616
+ html += ' <span class="arrow">\u25bc</span>';
1617
+ html += ' <span>' + escapeHtml(group.name || "未命名分组") + "</span>";
1618
+ html += " </div>";
1619
+ html += ' <div class="toolbar-tools" id="toolbar-tools-' + gi + '">';
1620
+ (group.tools || []).forEach(function (tool, ti) {
1621
+ html += '<button class="tool-btn" onclick="sendToolCommand(' + gi + ', ' + ti + ')"' +
1622
+ ' data-group="' + gi + '" data-tool="' + ti + '"' +
1623
+ ">" + escapeHtml(tool.name || "未命名");
1624
+ if (tool.command) {
1625
+ html += '<span class="tooltip">' + escapeHtml(tool.command) + "</span>";
1626
+ }
1627
+ html += "</button>";
1628
+ });
1629
+ html += " </div>";
1630
+ html += "</div>";
1631
+ });
1632
+ container.innerHTML = html;
1633
+ }
1634
+
1635
+ function renderExtensions() {
1636
+ var container = document.getElementById("extension-container");
1637
+ var divider = document.getElementById("ext-divider");
1638
+ if (extensionData.length === 0) {
1639
+ container.innerHTML = "";
1640
+ divider.style.display = "none";
1641
+ return;
1642
+ }
1643
+ divider.style.display = "";
1644
+ var html = "";
1645
+ extensionData.forEach(function (ext, ei) {
1646
+ html += '<div class="ext-group-header">' + escapeHtml(ext.name) + "</div>";
1647
+ // Recursively render the tree
1648
+ var root = ext.root;
1649
+ if (root && root.children) {
1650
+ html += renderExtNodes(root.children, "ext_" + ei, []);
1651
+ }
1652
+ });
1653
+ container.innerHTML = html;
1654
+ }
1655
+
1656
+ function renderExtNodes(nodes, prefix, cmdPath) {
1657
+ var html = "";
1658
+ nodes.forEach(function (node, ni) {
1659
+ var nodePath = cmdPath.concat([ni]);
1660
+ var pathStr = JSON.stringify(nodePath);
1661
+ if (node.command) {
1662
+ // This is a leaf command node - render as button
1663
+ var tooltip = node.comment || "";
1664
+ html += '<button class="tool-btn" onclick="sendExtCommand(' + pathStr + ')"' +
1665
+ ">" + escapeHtml(node.name || "未命名");
1666
+ if (tooltip) {
1667
+ html += '<span class="tooltip">' + escapeHtml(tooltip) + "</span>";
1668
+ }
1669
+ html += "</button>";
1670
+ }
1671
+ if (node.children && node.children.length > 0) {
1672
+ // This is a directory node - render as collapsible group
1673
+ var groupId = prefix + "_" + ni;
1674
+ html += '<div class="ext-subgroup-header" onclick="toggleExtGroup(\'' + groupId + '\')" id="' + groupId + '_header">';
1675
+ html += ' <span class="arrow">\u25bc</span>';
1676
+ html += ' <span>' + escapeHtml(node.name || "未命名") + "</span>";
1677
+ html += "</div>";
1678
+ if (node.comment) {
1679
+ html += '<p class="info" style="padding:2px 8px; font-size:11px;">' + escapeHtml(node.comment) + "</p>";
1680
+ }
1681
+ html += '<div class="toolbar-tools" id="' + groupId + '_tools">';
1682
+ html += renderExtNodes(node.children, groupId, nodePath);
1683
+ html += "</div>";
1684
+ }
1685
+ });
1686
+ return html;
1687
+ }
1688
+
1689
+ function toggleExtGroup(groupId) {
1690
+ var tools = document.getElementById(groupId + "_tools");
1691
+ var header = document.getElementById(groupId + "_header");
1692
+ if (tools) {
1693
+ tools.classList.toggle("hidden");
1694
+ if (header) { header.classList.toggle("collapsed"); }
1695
+ }
1696
+ }
1697
+
1698
+ function sendExtCommand(pathArr) {
1699
+ var tab = getActiveTerminalTab();
1700
+ if (!tab || !tab.ws || tab.ws.readyState !== WebSocket.OPEN) {
1701
+ showToast("没有活动的终端会话");
1702
+ return;
1703
+ }
1704
+ // Navigate the extension tree to find the command
1705
+ var cmd = null;
1706
+ var toolName = "";
1707
+ function findCmd(nodes, path) {
1708
+ if (path.length === 0) return;
1709
+ var idx = path[0];
1710
+ var node = nodes[idx];
1711
+ if (!node) return;
1712
+ if (path.length === 1) {
1713
+ cmd = node.command;
1714
+ toolName = node.name;
1715
+ return;
1716
+ }
1717
+ if (node.children) findCmd(node.children, path.slice(1));
1718
+ }
1719
+ for (var ei = 0; ei < extensionData.length; ei++) {
1720
+ var root = extensionData[ei].root;
1721
+ if (root && root.children) {
1722
+ findCmd(root.children, pathArr);
1723
+ if (cmd) break;
1724
+ }
1725
+ }
1726
+ if (!cmd) { showToast("未找到命令"); return; }
1727
+ var lines = cmd.split("\n");
1728
+ var lineDelay = (toolbarConfig.line_delay || 0) * 1000;
1729
+ function sendLine(i) {
1730
+ if (i >= lines.length) return;
1731
+ tab.ws.send(JSON.stringify({ type: "input", data: lines[i] + "\r" }));
1732
+ if (i + 1 < lines.length && lineDelay > 0) {
1733
+ setTimeout(function () { sendLine(i + 1); }, lineDelay);
1734
+ } else {
1735
+ sendLine(i + 1);
1736
+ }
1737
+ }
1738
+ sendLine(0);
1739
+ showToast("已发送: " + escapeHtml(toolName || cmd.substring(0, 30)));
1740
+ // move focus back to the terminal so the button does not keep it
1741
+ // (a focused button re-fires on Enter/Space -> accidental sends)
1742
+ if (tab && tab.term) tab.term.focus();
1743
+ }
1744
+
1745
+ function toggleToolbarGroup(gi) {
1746
+ var tools = document.getElementById("toolbar-tools-" + gi);
1747
+ var header = tools.previousElementSibling;
1748
+ if (tools) {
1749
+ tools.classList.toggle("hidden");
1750
+ if (header) { header.classList.toggle("collapsed"); }
1751
+ }
1752
+ }
1753
+
1754
+ function sendToolCommand(gi, ti) {
1755
+ var tab = getActiveTerminalTab();
1756
+ if (!tab || !tab.ws || tab.ws.readyState !== WebSocket.OPEN) {
1757
+ showToast("没有活动的终端会话");
1758
+ return;
1759
+ }
1760
+ var group = (toolbarConfig.groups || [])[gi];
1761
+ if (!group) return;
1762
+ var tool = (group.tools || [])[ti];
1763
+ if (!tool) return;
1764
+ var cmd = tool.command || "";
1765
+ var lines = cmd.split("\n");
1766
+ var lineDelay = (toolbarConfig.line_delay || 0) * 1000;
1767
+
1768
+ function sendLine(i) {
1769
+ if (i >= lines.length) return;
1770
+ var line = lines[i];
1771
+ // Send the line + Enter
1772
+ tab.ws.send(JSON.stringify({ type: "input", data: line + "\r" }));
1773
+ if (i + 1 < lines.length && lineDelay > 0) {
1774
+ setTimeout(function () { sendLine(i + 1); }, lineDelay);
1775
+ } else {
1776
+ sendLine(i + 1);
1777
+ }
1778
+ }
1779
+ sendLine(0);
1780
+ showToast("已发送: " + escapeHtml(tool.name || cmd.substring(0, 30)));
1781
+ // move focus back to the terminal so the button does not keep it
1782
+ // (a focused button re-fires on Enter/Space -> accidental sends)
1783
+ if (tab && tab.term) tab.term.focus();
1784
+ }
1785
+
1786
+ // ---- Status badge ----
1787
+ function updateStatus(status) {
1788
+ var badge = document.getElementById("status-badge");
1789
+ var text = document.getElementById("status-text");
1790
+ if (status === 'connected') {
1791
+ badge.className = "status-badge status-connected";
1792
+ text.textContent = "已连接";
1793
+ } else if (status === 'connecting') {
1794
+ badge.className = "status-badge status-disconnected";
1795
+ text.textContent = "连接中...";
1796
+ } else {
1797
+ badge.className = "status-badge status-disconnected";
1798
+ text.textContent = status === 'idle' ? "就绪" : "未连接";
1799
+ }
1800
+ }
1801
+
1802
+ // ---- Toast ----
1803
+ var toastTimer = null;
1804
+ function showToast(message) {
1805
+ var t = document.getElementById("toast");
1806
+ t.textContent = message;
1807
+ t.classList.add("show");
1808
+ if (toastTimer) clearTimeout(toastTimer);
1809
+ toastTimer = setTimeout(function () {
1810
+ t.classList.remove("show");
1811
+ }, 2500);
1812
+ }
1813
+
1814
+ // ---- Utils ----
1815
+ function escapeHtml(s) {
1816
+ // quotes are escaped too so the result is safe inside HTML attributes
1817
+ // (data-name / data-fullpath / tooltip)
1818
+ return String(s)
1819
+ .replace(/&/g, "&amp;")
1820
+ .replace(/</g, "&lt;")
1821
+ .replace(/>/g, "&gt;")
1822
+ .replace(/"/g, "&quot;");
1823
+ }
1824
+
1825
+ // ---- Command Sender ----
1826
+ var cmdIntervalMs = 100;
1827
+
1828
+ function updateCmdInterval() {
1829
+ var slider = document.getElementById('cmd-interval');
1830
+ var val = document.getElementById('cmd-interval-val');
1831
+ if (slider) cmdIntervalMs = parseInt(slider.value, 10);
1832
+ if (val) val.textContent = cmdIntervalMs + 'ms';
1833
+ }
1834
+
1835
+ function refreshCmdSessionSelect() {
1836
+ var sel = document.getElementById('cmd-target-session');
1837
+ if (!sel) return;
1838
+ var current = sel.value;
1839
+ var html = '<option value="">-- 选择目标会话 --</option>';
1840
+ tabs.forEach(function (tab) {
1841
+ if (tab.type === 'terminal' && tab.ws && tab.ws.readyState === WebSocket.OPEN) {
1842
+ var label = formatTabLabel(tab);
1843
+ html += '<option value="' + tab.id + '">' + escapeHtml(label) + '</option>';
1844
+ }
1845
+ });
1846
+ sel.innerHTML = html;
1847
+ if (current) sel.value = current;
1848
+ }
1849
+
1850
+ function sendCommandToSession() {
1851
+ var sel = document.getElementById('cmd-target-session');
1852
+ var tabId = sel ? parseInt(sel.value, 10) : NaN;
1853
+ if (!tabId) { showToast('请选择目标会话'); return; }
1854
+ var tab = tabs.find(function (t) { return t.id === tabId; });
1855
+ if (!tab || !tab.ws || tab.ws.readyState !== WebSocket.OPEN) {
1856
+ showToast('目标会话未连接');
1857
+ return;
1858
+ }
1859
+ var text = document.getElementById('cmd-input').value;
1860
+ if (!text.trim()) { showToast('请输入命令'); return; }
1861
+ sendCommandsToTab(tab, text);
1862
+ showToast('已发送到: ' + formatTabLabel(tab));
1863
+ // 发送后焦点回到目标会话终端,便于直接在远端继续操作
1864
+ activateTab(tab.id);
1865
+ }
1866
+
1867
+ function sendCommandToAll() {
1868
+ var text = document.getElementById('cmd-input').value;
1869
+ if (!text.trim()) { showToast('请输入命令'); return; }
1870
+ var count = 0;
1871
+ var first = null;
1872
+ tabs.forEach(function (tab) {
1873
+ if (tab.type === 'terminal' && tab.ws && tab.ws.readyState === WebSocket.OPEN) {
1874
+ if (!first) first = tab;
1875
+ sendCommandsToTab(tab, text);
1876
+ count++;
1877
+ }
1878
+ });
1879
+ if (count === 0) { showToast('没有已连接的会话'); return; }
1880
+ showToast('已群体发送到 ' + count + ' 个会话');
1881
+ // 群体发送后焦点回到第一个活跃会话终端
1882
+ if (first) activateTab(first.id);
1883
+ }
1884
+
1885
+ function sendCommandsToTab(tab, text) {
1886
+ var lines = text.split('\n');
1887
+ function sendLine(i) {
1888
+ if (i >= lines.length) return;
1889
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
1890
+ tab.ws.send(JSON.stringify({ type: 'input', data: lines[i] + '\r' }));
1891
+ }
1892
+ if (i + 1 < lines.length && cmdIntervalMs > 0) {
1893
+ setTimeout(function () { sendLine(i + 1); }, cmdIntervalMs);
1894
+ } else {
1895
+ sendLine(i + 1);
1896
+ }
1897
+ }
1898
+ sendLine(0);
1899
+ }
1900
+
1901
+ function clearCmdInput() {
1902
+ document.getElementById('cmd-input').value = '';
1903
+ }
1904
+
1905
+ // ---- Alt+C / Alt+Z / Alt+V copy/cut/paste for terminals ----
1906
+ function getClipboardText() {
1907
+ // Try clipboard API (works in secure contexts; fallback below)
1908
+ return '';
1909
+ }
1910
+
1911
+ function setupTerminalShortcuts() {
1912
+ document.addEventListener('keydown', function (e) {
1913
+ // Only when a terminal tab is active
1914
+ var tab = getActiveTerminalTab();
1915
+ if (!tab) return;
1916
+ if (!e.altKey) return;
1917
+
1918
+ if (e.key === 'c' || e.key === 'C') {
1919
+ e.preventDefault();
1920
+ // Copy selection
1921
+ try {
1922
+ var sel = tab.term.getSelection();
1923
+ if (sel) {
1924
+ navigator.clipboard.writeText(sel).then(function () {
1925
+ showToast('已复制');
1926
+ }).catch(function () {
1927
+ showToast('复制失败 (浏览器限制)');
1928
+ });
1929
+ } else {
1930
+ showToast('终端无选中内容');
1931
+ }
1932
+ } catch (err) {}
1933
+ } else if (e.key === 'z' || e.key === 'Z') {
1934
+ e.preventDefault();
1935
+ // Cut = copy selection + clear line
1936
+ try {
1937
+ var sel2 = tab.term.getSelection();
1938
+ if (sel2) {
1939
+ navigator.clipboard.writeText(sel2).then(function () {
1940
+ // Clear selected text by sending backspaces (approximate cut)
1941
+ var lines = sel2.split('\n');
1942
+ var bsCount = 0;
1943
+ lines.forEach(function (l) { bsCount += l.length; });
1944
+ bsCount += lines.length - 1; // for newlines
1945
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN && bsCount > 0) {
1946
+ var bs = '';
1947
+ for (var i = 0; i < bsCount; i++) bs += '\b';
1948
+ tab.ws.send(JSON.stringify({ type: 'input', data: bs }));
1949
+ }
1950
+ showToast('已剪切');
1951
+ }).catch(function () {
1952
+ showToast('剪切失败 (浏览器限制)');
1953
+ });
1954
+ } else {
1955
+ showToast('终端无选中内容');
1956
+ }
1957
+ } catch (err) {}
1958
+ } else if (e.key === 'v' || e.key === 'V') {
1959
+ e.preventDefault();
1960
+ // Paste from clipboard
1961
+ navigator.clipboard.readText().then(function (clipText) {
1962
+ if (clipText && tab.ws && tab.ws.readyState === WebSocket.OPEN) {
1963
+ tab.ws.send(JSON.stringify({ type: 'input', data: clipText }));
1964
+ showToast('已粘贴');
1965
+ } else {
1966
+ showToast('剪贴板为空');
1967
+ }
1968
+ }).catch(function () {
1969
+ showToast('粘贴失败 (浏览器限制,需 HTTPS 或 localhost)');
1970
+ });
1971
+ }
1972
+ });
1973
+ }
1974
+
1975
+ // ---- File Browser ----
1976
+ var fileCurrentPath = '/';
1977
+ var fileSessionTab = null; // the tab whose ws is used for SFTP
1978
+ var uploadRateLimit = 0; // 0 = unlimited, >0 = bytes per second
1979
+ var uploadTasks = {}; // id -> { name, path, size, uploaded, status }
1980
+ var selectedFiles = new Set(); // full remote paths of selected files
1981
+ var downloadQueue = []; // pending remote paths to download (serial)
1982
+ var downloadActive = false;
1983
+ var fileViewMode = 'grid'; // 'grid' = icon cards, 'list' = detail table
1984
+ var currentDownload = null; // { taskId, path } of the running download
1985
+ var downloadTasks = {}; // id -> { name, path, size, received, status, chunks }
1986
+
1987
+ function refreshFileSessionSelect() {
1988
+ var sel = document.getElementById('file-session-select');
1989
+ if (!sel) return;
1990
+ var current = sel.value;
1991
+ var html = '<option value="">-- 选择会话 --</option>';
1992
+ tabs.forEach(function (tab) {
1993
+ if (tab.type === 'terminal' && tab.ws && tab.ws.readyState === WebSocket.OPEN) {
1994
+ var label = formatTabLabel(tab);
1995
+ html += '<option value="' + tab.id + '">' + escapeHtml(label) + '</option>';
1996
+ }
1997
+ });
1998
+ sel.innerHTML = html;
1999
+ if (current) sel.value = current;
2000
+ }
2001
+
2002
+ function onFileSessionChange() {
2003
+ var sel = document.getElementById('file-session-select');
2004
+ var tabId = parseInt(sel.value, 10);
2005
+ fileSessionTab = tabs.find(function (t) { return t.id === tabId; });
2006
+ if (fileSessionTab && fileSessionTab.ws && fileSessionTab.ws.readyState === WebSocket.OPEN) {
2007
+ // Get home directory
2008
+ fileSessionTab.ws.send(JSON.stringify({ type: 'sftp_home' }));
2009
+ } else {
2010
+ fileSessionTab = null;
2011
+ showFileMainEmpty('请先选择一个已连接的会话');
2012
+ }
2013
+ }
2014
+
2015
+ function showFileMainEmpty(msg) {
2016
+ var content = document.getElementById('file-main-content');
2017
+ if (content) content.innerHTML = '<p class="info">' + escapeHtml(msg) + '</p>';
2018
+ var sidebar = document.getElementById('file-list-sidebar');
2019
+ if (sidebar) sidebar.innerHTML = '<p class="info">' + escapeHtml(msg) + '</p>';
2020
+ }
2021
+
2022
+ function fileListRemote(path) {
2023
+ if (!fileSessionTab || !fileSessionTab.ws || fileSessionTab.ws.readyState !== WebSocket.OPEN) return;
2024
+ fileSessionTab.ws.send(JSON.stringify({ type: 'sftp_list', path: path }));
2025
+ }
2026
+
2027
+ function renderFileList(path, entries) {
2028
+ // navigating to a different directory invalidates the selection
2029
+ if (path !== fileCurrentPath) selectedFiles.clear();
2030
+ fileCurrentPath = path;
2031
+ var pathInput = document.getElementById('file-current-path');
2032
+ if (pathInput) pathInput.value = path;
2033
+
2034
+ // Sidebar list
2035
+ var sidebar = document.getElementById('file-list-sidebar');
2036
+ var html = '';
2037
+ if (entries.length === 0) {
2038
+ html = '<p class="info">空目录</p>';
2039
+ }
2040
+ entries.forEach(function (e) {
2041
+ var icon = e.is_dir ? '\u{1F4C1}' : '\u{1F4C4}';
2042
+ var sizeStr = e.is_dir ? '' : formatSize(e.size);
2043
+ var nameAttr = escapeHtml(e.name);
2044
+ var fullPath = (path === '/' ? '' : path) + '/' + e.name;
2045
+ var selCls = (!e.is_dir && selectedFiles.has(fullPath)) ? ' selected' : '';
2046
+ html += '<div class="file-entry' + selCls + '" data-name="' + nameAttr + '" data-isdir="' + e.is_dir + '" data-fullpath="' + escapeHtml(fullPath) + '">';
2047
+ html += ' <span class="file-icon ' + (e.is_dir ? 'dir' : 'file') + '">' + icon + '</span>';
2048
+ html += ' <span class="file-name">' + escapeHtml(e.name) + "</span>";
2049
+ html += ' <span class="file-size">' + sizeStr + "</span>";
2050
+ html += '</div>';
2051
+ });
2052
+ sidebar.innerHTML = html;
2053
+ // Attach click handlers (avoid inline onclick quoting issues)
2054
+ sidebar.querySelectorAll('.file-entry').forEach(function (el) {
2055
+ el.addEventListener('click', function () {
2056
+ fileEntryClick(this.getAttribute('data-name'), this.getAttribute('data-isdir') === 'true');
2057
+ });
2058
+ });
2059
+
2060
+ // Main area: toolbar (breadcrumb + view switch) then grid or detail table
2061
+ var mainContent = document.getElementById('file-main-content');
2062
+ var mainHtml = '<div class="file-toolbar">';
2063
+ mainHtml += ' <div class="file-breadcrumb" id="file-breadcrumb"></div>';
2064
+ mainHtml += ' <div class="file-view-toggle">';
2065
+ mainHtml += ' <button class="btn btn-sm' + (fileViewMode === 'grid' ? ' btn-primary' : ' btn-secondary') + '" onclick="setFileViewMode(\'grid\')">图标视图</button>';
2066
+ mainHtml += ' <button class="btn btn-sm' + (fileViewMode === 'list' ? ' btn-primary' : ' btn-secondary') + '" onclick="setFileViewMode(\'list\')">列表视图</button>';
2067
+ mainHtml += ' </div>';
2068
+ mainHtml += '</div>';
2069
+
2070
+ if (fileViewMode === 'list') {
2071
+ // ---- Detail table: name | size | type | mtime | owner | perm ----
2072
+ mainHtml += '<div class="file-table">';
2073
+ mainHtml += ' <div class="file-table-head">';
2074
+ mainHtml += ' <span class="t-name">名称</span>';
2075
+ mainHtml += ' <span class="t-size">大小</span>';
2076
+ mainHtml += ' <span class="t-type">类型</span>';
2077
+ mainHtml += ' <span class="t-mtime">修改时间</span>';
2078
+ mainHtml += ' <span class="t-owner">所有者</span>';
2079
+ mainHtml += ' <span class="t-perm">权限</span>';
2080
+ mainHtml += ' </div>';
2081
+ entries.forEach(function (e) {
2082
+ var icon = e.is_dir ? '\u{1F4C1}' : '\u{1F4C4}';
2083
+ var fullPath = (path === '/' ? '' : path) + '/' + e.name;
2084
+ var selCls = (!e.is_dir && selectedFiles.has(fullPath)) ? ' selected' : '';
2085
+ var typeStr = e.is_dir ? '文件夹' : (e.is_link ? '链接' : '文件');
2086
+ var sizeStr = e.is_dir ? '—' : formatSize(e.size);
2087
+ var permStr = e.perm || '-';
2088
+ mainHtml += '<div class="file-table-row' + selCls + '" data-name="' + escapeHtml(e.name) + '" data-isdir="' + e.is_dir + '" data-fullpath="' + escapeHtml(fullPath) + '">';
2089
+ mainHtml += ' <span class="t-name"><span class="table-icon">' + icon + '</span>' + escapeHtml(e.name) + '</span>';
2090
+ mainHtml += ' <span class="t-size">' + sizeStr + '</span>';
2091
+ mainHtml += ' <span class="t-type">' + typeStr + '</span>';
2092
+ mainHtml += ' <span class="t-mtime">' + escapeHtml(e.mtime_str || '') + '</span>';
2093
+ mainHtml += ' <span class="t-owner">' + escapeHtml(e.owner || '') + (e.group ? ':' + escapeHtml(e.group) : '') + '</span>';
2094
+ mainHtml += ' <span class="t-perm">' + escapeHtml(permStr) + '</span>';
2095
+ mainHtml += '</div>';
2096
+ });
2097
+ mainHtml += '</div>';
2098
+ if (entries.length === 0) {
2099
+ mainHtml += '<p class="info">空目录</p>';
2100
+ }
2101
+ mainContent.innerHTML = mainHtml;
2102
+ // Attach click handlers
2103
+ mainContent.querySelectorAll('.file-table-row').forEach(function (el) {
2104
+ el.addEventListener('click', function () {
2105
+ fileEntryClick(this.getAttribute('data-name'), this.getAttribute('data-isdir') === 'true');
2106
+ });
2107
+ });
2108
+ } else {
2109
+ // ---- Icon grid ----
2110
+ mainHtml += '<div class="file-grid">';
2111
+ entries.forEach(function (e) {
2112
+ var icon = e.is_dir ? '\u{1F4C1}' : '\u{1F4C4}';
2113
+ var cls = e.is_dir ? 'file-card-icon-dir' : 'file-card-icon-file';
2114
+ var nameAttr = escapeHtml(e.name);
2115
+ var fullPath = (path === '/' ? '' : path) + '/' + e.name;
2116
+ var selCls = (!e.is_dir && selectedFiles.has(fullPath)) ? ' selected' : '';
2117
+ mainHtml += '<div class="file-card' + selCls + '" data-name="' + nameAttr + '" data-isdir="' + e.is_dir + '" data-fullpath="' + escapeHtml(fullPath) + '">';
2118
+ mainHtml += ' <span class="card-icon ' + cls + '">' + icon + '</span>';
2119
+ mainHtml += ' <span class="card-name">' + escapeHtml(e.name) + "</span>";
2120
+ if (!e.is_dir) mainHtml += ' <span class="card-size">' + formatSize(e.size) + "</span>";
2121
+ mainHtml += '</div>';
2122
+ });
2123
+ mainHtml += '</div>';
2124
+ if (entries.length === 0) {
2125
+ mainHtml += '<p class="info">空目录</p>';
2126
+ }
2127
+ mainContent.innerHTML = mainHtml;
2128
+ // Attach click handlers
2129
+ mainContent.querySelectorAll('.file-card').forEach(function (el) {
2130
+ el.addEventListener('click', function () {
2131
+ fileEntryClick(this.getAttribute('data-name'), this.getAttribute('data-isdir') === 'true');
2132
+ });
2133
+ });
2134
+ }
2135
+ renderBreadcrumb(path);
2136
+ updateFileSelectionInfo();
2137
+ }
2138
+
2139
+ function setFileViewMode(mode) {
2140
+ fileViewMode = mode;
2141
+ if (fileCurrentPath) fileListRemote(fileCurrentPath);
2142
+ }
2143
+
2144
+ function renderBreadcrumb(path) {
2145
+ var bc = document.getElementById('file-breadcrumb');
2146
+ if (!bc) return;
2147
+ var parts = path.split('/').filter(function (p) { return p; });
2148
+ var html = '<span class="file-breadcrumb-item" data-path="/">/</span>';
2149
+ var cur = '';
2150
+ parts.forEach(function (part, i) {
2151
+ cur += '/' + part;
2152
+ html += '<span class="file-breadcrumb-sep">/</span>';
2153
+ html += '<span class="file-breadcrumb-item" data-path="' + escapeHtml(cur) + '">' + escapeHtml(part) + '</span>';
2154
+ });
2155
+ bc.innerHTML = html;
2156
+ // Attach click handlers for breadcrumb
2157
+ var bcRoot = bc.querySelector('.file-breadcrumb-item');
2158
+ if (bcRoot) bcRoot.addEventListener('click', function() { fileListRemote('/'); });
2159
+ bc.querySelectorAll('.file-breadcrumb-item:not(:first-child)').forEach(function (el) {
2160
+ el.addEventListener('click', function () {
2161
+ fileListRemote(this.getAttribute('data-path'));
2162
+ });
2163
+ });
2164
+ }
2165
+
2166
+ function fileEntryClick(name, isDir) {
2167
+ if (isDir) {
2168
+ var newPath = fileCurrentPath === '/' ? '/' + name : fileCurrentPath + '/' + name;
2169
+ fileListRemote(newPath);
2170
+ } else {
2171
+ // toggle selection for download
2172
+ var fullPath = (fileCurrentPath === '/' ? '' : fileCurrentPath) + '/' + name;
2173
+ toggleFileSelected(fullPath);
2174
+ }
2175
+ }
2176
+
2177
+ // ---- File selection + download ----
2178
+ function cssEscapeAttr(s) {
2179
+ if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
2180
+ return String(s).replace(/["\\\]]/g, '\\$&');
2181
+ }
2182
+
2183
+ function toggleFileSelected(fullPath) {
2184
+ var sel;
2185
+ if (selectedFiles.has(fullPath)) {
2186
+ selectedFiles.delete(fullPath);
2187
+ sel = false;
2188
+ } else {
2189
+ selectedFiles.add(fullPath);
2190
+ sel = true;
2191
+ }
2192
+ // sync highlight across both views (sidebar list + main grid)
2193
+ document.querySelectorAll('[data-fullpath="' + cssEscapeAttr(fullPath) + '"]').forEach(function (el) {
2194
+ el.classList.toggle('selected', sel);
2195
+ });
2196
+ updateFileSelectionInfo();
2197
+ }
2198
+
2199
+ function updateFileSelectionInfo() {
2200
+ var info = document.getElementById('file-selected-info');
2201
+ if (!info) return;
2202
+ if (selectedFiles.size === 0) {
2203
+ info.textContent = '点击文件可选中/取消选中';
2204
+ } else {
2205
+ info.textContent = '已选 ' + selectedFiles.size + ' 个文件';
2206
+ }
2207
+ }
2208
+
2209
+ function clearFileSelection() {
2210
+ selectedFiles.clear();
2211
+ document.querySelectorAll('.file-entry.selected, .file-card.selected').forEach(function (el) {
2212
+ el.classList.remove('selected');
2213
+ });
2214
+ updateFileSelectionInfo();
2215
+ }
2216
+
2217
+ function downloadSelectedFiles() {
2218
+ if (selectedFiles.size === 0) { showToast('请先点击选中要下载的文件'); return; }
2219
+ if (!fileSessionTab || !fileSessionTab.ws || fileSessionTab.ws.readyState !== WebSocket.OPEN) {
2220
+ showToast('请先选择一个已连接的会话');
2221
+ return;
2222
+ }
2223
+ var paths = Array.from(selectedFiles);
2224
+ clearFileSelection();
2225
+ downloadQueue = downloadQueue.concat(paths);
2226
+ processDownloadQueue();
2227
+ }
2228
+
2229
+ function processDownloadQueue() {
2230
+ if (downloadActive) return; // serial: one file at a time
2231
+ var path = downloadQueue.shift();
2232
+ if (!path) return;
2233
+ if (!fileSessionTab || !fileSessionTab.ws || fileSessionTab.ws.readyState !== WebSocket.OPEN) {
2234
+ downloadQueue = [];
2235
+ showToast('会话已断开,下载中止');
2236
+ return;
2237
+ }
2238
+ downloadActive = true;
2239
+ var name = path.substring(path.lastIndexOf('/') + 1) || 'download.bin';
2240
+ var taskId = 'd' + Date.now() + '_' + Math.random().toString(36).substr(2, 6);
2241
+ downloadTasks[taskId] = {
2242
+ name: name, path: path, size: 0, received: 0,
2243
+ status: 'downloading', chunks: []
2244
+ };
2245
+ currentDownload = { taskId: taskId, path: path };
2246
+ renderTransferTasks();
2247
+ showUploadProgress();
2248
+ fileSessionTab.ws.send(JSON.stringify({ type: 'sftp_download_start', path: path }));
2249
+ }
2250
+
2251
+ function finishDownload(ok, errMsg) {
2252
+ var cd = currentDownload;
2253
+ currentDownload = null;
2254
+ downloadActive = false;
2255
+ if (!cd) { processDownloadQueue(); return; }
2256
+ var t = downloadTasks[cd.taskId];
2257
+ if (t) {
2258
+ if (ok) {
2259
+ t.status = 'done';
2260
+ try {
2261
+ saveBlob(new Blob(t.chunks), t.name);
2262
+ showToast('下载完成: ' + t.name);
2263
+ } catch (e) {
2264
+ showToast('保存文件失败: ' + e);
2265
+ }
2266
+ t.chunks = [];
2267
+ } else {
2268
+ t.status = 'error';
2269
+ t.chunks = [];
2270
+ showToast('下载失败: ' + (errMsg || t.name));
2271
+ }
2272
+ }
2273
+ renderTransferTasks();
2274
+ processDownloadQueue(); // next queued file, if any
2275
+ }
2276
+
2277
+ function base64ToUint8Array(b64) {
2278
+ var bin = atob(b64);
2279
+ var bytes = new Uint8Array(bin.length);
2280
+ for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
2281
+ return bytes;
2282
+ }
2283
+
2284
+ function saveBlob(blob, filename) {
2285
+ var a = document.createElement('a');
2286
+ a.href = URL.createObjectURL(blob);
2287
+ a.download = filename;
2288
+ document.body.appendChild(a);
2289
+ a.click();
2290
+ document.body.removeChild(a);
2291
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
2292
+ }
2293
+
2294
+ function fileGoUp() {
2295
+ if (fileCurrentPath === '/' || fileCurrentPath === '') return;
2296
+ var parts = fileCurrentPath.replace(/\/$/, '').split('/');
2297
+ parts.pop();
2298
+ var up = parts.join('/') || '/';
2299
+ fileListRemote(up);
2300
+ }
2301
+
2302
+ function fileRefresh() {
2303
+ if (fileCurrentPath) fileListRemote(fileCurrentPath);
2304
+ }
2305
+
2306
+ function formatSize(bytes) {
2307
+ if (bytes < 1024) return bytes + ' B';
2308
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
2309
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
2310
+ return (bytes / 1073741824).toFixed(1) + ' GB';
2311
+ }
2312
+
2313
+ // ---- Upload rate slider (stepped ladder, see UPLOAD_RATE_STEPS) ----
2314
+ function updateUploadRate() {
2315
+ var slider = document.getElementById('upload-rate-slider');
2316
+ var val = document.getElementById('upload-rate-val');
2317
+ if (!slider) return;
2318
+ var idx = parseInt(slider.value, 10);
2319
+ var v = (idx >= 0 && idx < UPLOAD_RATE_STEPS.length) ? UPLOAD_RATE_STEPS[idx] : 0;
2320
+ if (!v) {
2321
+ uploadRateLimit = 0;
2322
+ if (val) val.textContent = '不限速';
2323
+ } else {
2324
+ uploadRateLimit = v * 1024; // Kbps -> bytes per second
2325
+ if (v < 1024) {
2326
+ if (val) val.textContent = v + ' Kbps';
2327
+ } else {
2328
+ if (val) val.textContent = (v / 1024) + ' Mbps';
2329
+ }
2330
+ }
2331
+ }
2332
+
2333
+ // ---- Drag-and-drop upload ----
2334
+ function setupDragDrop() {
2335
+ var mainArea = document.querySelector('.terminal-area');
2336
+ if (!mainArea) return;
2337
+ var dropzone = document.getElementById('upload-dropzone');
2338
+ var dragCounter = 0;
2339
+
2340
+ mainArea.addEventListener('dragenter', function (e) {
2341
+ e.preventDefault();
2342
+ e.stopPropagation();
2343
+ dragCounter++;
2344
+ // Only show dropzone if files tab is active and session selected
2345
+ var activeTab = document.querySelector('.sidebar-tab.active');
2346
+ if (activeTab && activeTab.dataset.tab === 'files' && fileSessionTab) {
2347
+ dropzone.classList.add('active');
2348
+ }
2349
+ });
2350
+ mainArea.addEventListener('dragover', function (e) {
2351
+ e.preventDefault();
2352
+ e.stopPropagation();
2353
+ });
2354
+ mainArea.addEventListener('dragleave', function (e) {
2355
+ e.preventDefault();
2356
+ e.stopPropagation();
2357
+ dragCounter--;
2358
+ if (dragCounter <= 0) {
2359
+ dragCounter = 0;
2360
+ dropzone.classList.remove('active');
2361
+ }
2362
+ });
2363
+ mainArea.addEventListener('drop', function (e) {
2364
+ e.preventDefault();
2365
+ e.stopPropagation();
2366
+ dragCounter = 0;
2367
+ dropzone.classList.remove('active');
2368
+ // Only upload if files tab is active
2369
+ var activeTab = document.querySelector('.sidebar-tab.active');
2370
+ if (!activeTab || activeTab.dataset.tab !== 'files') return;
2371
+ if (!fileSessionTab || !fileSessionTab.ws || fileSessionTab.ws.readyState !== WebSocket.OPEN) {
2372
+ showToast('请先选择一个已连接的会话');
2373
+ return;
2374
+ }
2375
+ var files = e.dataTransfer.files;
2376
+ if (!files || files.length === 0) return;
2377
+ for (var i = 0; i < files.length; i++) {
2378
+ uploadFileToRemote(files[i]);
2379
+ }
2380
+ });
2381
+ }
2382
+
2383
+ function uploadFileToRemote(file) {
2384
+ if (!fileSessionTab || !fileSessionTab.ws || fileSessionTab.ws.readyState !== WebSocket.OPEN) {
2385
+ showToast('会话未连接');
2386
+ return;
2387
+ }
2388
+ var remotePath = (fileCurrentPath === '/' ? '' : fileCurrentPath) + '/' + file.name;
2389
+ var taskId = 'u' + Date.now() + '_' + Math.random().toString(36).substr(2, 6);
2390
+ uploadTasks[taskId] = {
2391
+ name: file.name,
2392
+ path: remotePath,
2393
+ size: file.size,
2394
+ uploaded: 0,
2395
+ status: 'uploading'
2396
+ };
2397
+ renderTransferTasks();
2398
+ showUploadProgress();
2399
+
2400
+ // Progress / done / error events arrive on this ws and are handled
2401
+ // centrally in handleSftpEvent() - we must NOT replace ws.onmessage
2402
+ // here (the old approach swallowed terminal output and download
2403
+ // events for the whole duration of an upload).
2404
+ var ws = fileSessionTab.ws;
2405
+
2406
+ // Start upload
2407
+ ws.send(JSON.stringify({ type: 'sftp_upload_start', path: remotePath, size: file.size }));
2408
+
2409
+ // Read and send file in chunks
2410
+ var chunkSize = 32768; // 32KB per chunk
2411
+ var offset = 0;
2412
+ var reader = new FileReader();
2413
+ var lastTime = 0;
2414
+
2415
+ function readChunk() {
2416
+ var end = Math.min(offset + chunkSize, file.size);
2417
+ reader.readAsArrayBuffer(file.slice(offset, end));
2418
+ }
2419
+
2420
+ reader.onload = function (e) {
2421
+ var arr = new Uint8Array(e.target.result);
2422
+ var b64 = arrayBufferToBase64(arr);
2423
+
2424
+ // Rate limiting
2425
+ if (uploadRateLimit > 0) {
2426
+ var now = Date.now();
2427
+ var elapsed = (now - lastTime) / 1000;
2428
+ var allowedBytes = uploadRateLimit * elapsed;
2429
+ if (offset > 0 && arr.length > allowedBytes) {
2430
+ var delayMs = Math.ceil((arr.length / uploadRateLimit - elapsed) * 1000);
2431
+ setTimeout(function () {
2432
+ lastTime = Date.now();
2433
+ ws.send(JSON.stringify({ type: 'sftp_upload_chunk', data: b64 }));
2434
+ offset += arr.length;
2435
+ if (offset < file.size) {
2436
+ readChunk();
2437
+ } else {
2438
+ ws.send(JSON.stringify({ type: 'sftp_upload_end' }));
2439
+ }
2440
+ }, delayMs);
2441
+ return;
2442
+ }
2443
+ }
2444
+
2445
+ lastTime = Date.now();
2446
+ ws.send(JSON.stringify({ type: 'sftp_upload_chunk', data: b64 }));
2447
+ offset += arr.length;
2448
+ if (offset < file.size) {
2449
+ readChunk();
2450
+ } else {
2451
+ ws.send(JSON.stringify({ type: 'sftp_upload_end' }));
2452
+ }
2453
+ };
2454
+
2455
+ reader.onerror = function () {
2456
+ uploadTasks[taskId].status = 'error';
2457
+ renderTransferTasks();
2458
+ showToast('读取文件失败: ' + file.name);
2459
+ };
2460
+
2461
+ // Small delay to let upload_start be processed
2462
+ setTimeout(readChunk, 200);
2463
+ }
2464
+
2465
+ function arrayBufferToBase64(buf) {
2466
+ var binary = '';
2467
+ var bytes = new Uint8Array(buf);
2468
+ var len = bytes.byteLength;
2469
+ for (var i = 0; i < len; i++) {
2470
+ binary += String.fromCharCode(bytes[i]);
2471
+ }
2472
+ return btoa(binary);
2473
+ }
2474
+
2475
+ function showUploadProgress() {
2476
+ var area = document.getElementById('upload-progress-area');
2477
+ if (area) area.classList.add('visible');
2478
+ }
2479
+
2480
+ function hideUploadProgressIfEmpty() {
2481
+ var hasActive = false;
2482
+ Object.keys(uploadTasks).forEach(function (k) {
2483
+ if (uploadTasks[k].status === 'uploading') hasActive = true;
2484
+ });
2485
+ Object.keys(downloadTasks).forEach(function (k) {
2486
+ if (downloadTasks[k].status === 'downloading') hasActive = true;
2487
+ });
2488
+ if (!hasActive) {
2489
+ var area = document.getElementById('upload-progress-area');
2490
+ if (area) area.classList.remove('visible');
2491
+ }
2492
+ }
2493
+
2494
+ // Render all transfer tasks (uploads + downloads) in one progress area
2495
+ function renderTransferTasks() {
2496
+ var container = document.getElementById('upload-tasks-container');
2497
+ if (!container) return;
2498
+ var html = '';
2499
+ Object.keys(uploadTasks).forEach(function (k) {
2500
+ html += renderOneTransferTask(uploadTasks[k], false);
2501
+ });
2502
+ Object.keys(downloadTasks).forEach(function (k) {
2503
+ html += renderOneTransferTask(downloadTasks[k], true);
2504
+ });
2505
+ container.innerHTML = html;
2506
+ // Auto-hide if all done (after a delay)
2507
+ hideUploadProgressIfEmpty();
2508
+ }
2509
+
2510
+ function renderOneTransferTask(t, isDownload) {
2511
+ var done = t.size > 0 ? (isDownload ? t.received : t.uploaded) : (t.status === 'done' ? t.size : 0);
2512
+ var moved = isDownload ? (t.received || 0) : (t.uploaded || 0);
2513
+ var pct = t.size > 0 ? Math.round(moved / t.size * 100) : (t.status === 'done' ? 100 : 0);
2514
+ var fillClass = t.status === 'done' ? ' done' : (t.status === 'error' ? ' error' : '');
2515
+ if (isDownload) fillClass = ' down' + fillClass;
2516
+ var label = isDownload ? '下载' : '上传';
2517
+ var infoStr = formatSize(moved) + ' / ' + formatSize(t.size) + ' (' + pct + '%)';
2518
+ if (t.status === 'error') infoStr = label + '失败';
2519
+ var html = '<div class="upload-task">';
2520
+ html += ' <div class="upload-task-header">';
2521
+ html += ' <span class="upload-task-name">[' + label + '] ' + escapeHtml(t.name) + '</span>';
2522
+ html += ' <span class="upload-task-info">' + infoStr + '</span>';
2523
+ html += ' </div>';
2524
+ html += ' <div class="upload-bar"><div class="upload-bar-fill' + fillClass + '" style="width:' + pct + '%"></div></div>';
2525
+ html += '</div>';
2526
+ return html;
2527
+ }
2528
+
2529
+ // ---- Handle SFTP events from worker ----
2530
+ // Covers file listing, upload progress/done/error AND streaming
2531
+ // downloads (begin/chunk/done/error). One central dispatcher so the
2532
+ // websocket onmessage handler never has to be swapped out.
2533
+ function handleSftpEvent(msg) {
2534
+ var d = msg.data;
2535
+ if (!d) return;
2536
+ if (msg.type === 'sftp_home') {
2537
+ fileListRemote(d.path || '/');
2538
+ } else if (msg.type === 'sftp_list') {
2539
+ renderFileList(d.path || '/', d.entries || []);
2540
+ } else if (msg.type === 'sftp_progress') {
2541
+ // upload progress (worker echoes after each chunk)
2542
+ var ut = findUploadTask(d.path);
2543
+ if (ut) {
2544
+ ut.uploaded = d.uploaded || 0;
2545
+ renderTransferTasks();
2546
+ }
2547
+ } else if (msg.type === 'sftp_done') {
2548
+ var ut2 = findUploadTask(d.path);
2549
+ if (ut2) {
2550
+ ut2.uploaded = d.uploaded || ut2.uploaded;
2551
+ ut2.status = 'done';
2552
+ renderTransferTasks();
2553
+ showToast('上传完成: ' + ut2.name);
2554
+ fileRefresh();
2555
+ }
2556
+ } else if (msg.type === 'sftp_download_begin') {
2557
+ if (currentDownload && d.path === currentDownload.path) {
2558
+ downloadTasks[currentDownload.taskId].size = d.size || 0;
2559
+ renderTransferTasks();
2560
+ }
2561
+ } else if (msg.type === 'sftp_download_chunk') {
2562
+ if (currentDownload && d.path === currentDownload.path) {
2563
+ var t = downloadTasks[currentDownload.taskId];
2564
+ if (t && d.data) {
2565
+ var bytes = base64ToUint8Array(d.data);
2566
+ t.chunks.push(bytes);
2567
+ t.received += bytes.length;
2568
+ renderTransferTasks();
2569
+ }
2570
+ }
2571
+ } else if (msg.type === 'sftp_download_done') {
2572
+ if (currentDownload && d.path === currentDownload.path) {
2573
+ finishDownload(true, '');
2574
+ }
2575
+ } else if (msg.type === 'sftp_download_error') {
2576
+ if (currentDownload && d.path === currentDownload.path) {
2577
+ finishDownload(false, d.msg || '');
2578
+ }
2579
+ } else if (msg.type === 'sftp_error') {
2580
+ showToast(d.msg || 'SFTP 错误');
2581
+ // mark the matching upload task as failed (worker's error text
2582
+ // contains the remote path)
2583
+ var matched = false;
2584
+ Object.keys(uploadTasks).forEach(function (k) {
2585
+ var t = uploadTasks[k];
2586
+ if (t.status === 'uploading' && d.msg && d.msg.indexOf(t.path) >= 0) {
2587
+ t.status = 'error';
2588
+ matched = true;
2589
+ }
2590
+ });
2591
+ if (matched) renderTransferTasks();
2592
+ showFileMainEmpty(d.msg || '操作失败');
2593
+ }
2594
+ }
2595
+
2596
+ function findUploadTask(path) {
2597
+ var found = null;
2598
+ Object.keys(uploadTasks).forEach(function (k) {
2599
+ var t = uploadTasks[k];
2600
+ if (!found && t.status === 'uploading' && t.path === path) found = t;
2601
+ });
2602
+ return found;
2603
+ }
2604
+
2605
+ // ---- Expose to global ----
2606
+ window.switchSidebarTab = switchSidebarTab;
2607
+ window.detectProcess = detectProcess;
2608
+ window.connectToPort = connectToPort;
2609
+ window.connectManual = connectManual;
2610
+ window.attachSession = attachSession;
2611
+ window.shutdownSession = shutdownSession;
2612
+ window.disconnectActive = disconnectActive;
2613
+ window.saveActiveLog = saveActiveLog;
2614
+ window.loadSessions = loadSessions;
2615
+ window.openLogInTab = openLogInTab;
2616
+ window.updateLogDisplayMode = updateLogDisplayMode;
2617
+ window.toggleToolbarGroup = toggleToolbarGroup;
2618
+ window.loadToolbar = loadToolbar;
2619
+ window.sendToolCommand = sendToolCommand;
2620
+ window.toggleExtGroup = toggleExtGroup;
2621
+ window.sendExtCommand = sendExtCommand;
2622
+ window.sendCommandToSession = sendCommandToSession;
2623
+ window.sendCommandToAll = sendCommandToAll;
2624
+ window.clearCmdInput = clearCmdInput;
2625
+ window.onFileSessionChange = onFileSessionChange;
2626
+ window.fileEntryClick = fileEntryClick;
2627
+ window.fileGoUp = fileGoUp;
2628
+ window.fileRefresh = fileRefresh;
2629
+ window.fileListRemote = fileListRemote;
2630
+ window.setFileViewMode = setFileViewMode;
2631
+ window.downloadSelectedFiles = downloadSelectedFiles;
2632
+ window.clearFileSelection = clearFileSelection;
2633
+ window.triggerImportExtension = triggerImportExtension;
2634
+ window.importExtensionFile = importExtensionFile;
2635
+
2636
+ // ---- Resize handler ----
2637
+ var resizeTimer = null;
2638
+ window.addEventListener("resize", function () {
2639
+ if (resizeTimer) clearTimeout(resizeTimer);
2640
+ resizeTimer = setTimeout(function () {
2641
+ var tab = getActiveTerminalTab();
2642
+ if (tab && tab.fitAddon) {
2643
+ fitTab(tab.id);
2644
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN && tab.term) {
2645
+ tab.ws.send(JSON.stringify({ type: "resize", cols: tab.term.cols, rows: tab.term.rows }));
2646
+ }
2647
+ }
2648
+ }, 100);
2649
+ });
2650
+
2651
+ // ---- Init ----
2652
+ document.addEventListener("DOMContentLoaded", function () {
2653
+ detectProcess();
2654
+ loadSessions();
2655
+ loadLogs();
2656
+ loadToolbar();
2657
+ setupTerminalShortcuts();
2658
+ setupDragDrop();
2659
+ // Wire up sliders
2660
+ var cmdSlider = document.getElementById('cmd-interval');
2661
+ if (cmdSlider) cmdSlider.addEventListener('input', updateCmdInterval);
2662
+ var rateSlider = document.getElementById('upload-rate-slider');
2663
+ if (rateSlider) rateSlider.addEventListener('input', updateUploadRate);
2664
+ // Refresh session selects periodically (for command & file tabs)
2665
+ setInterval(function () {
2666
+ var activeTab = document.querySelector('.sidebar-tab.active');
2667
+ if (activeTab && activeTab.dataset.tab === 'command') refreshCmdSessionSelect();
2668
+ if (activeTab && activeTab.dataset.tab === 'files') refreshFileSessionSelect();
2669
+ }, 3000);
2670
+ });
2671
+ })();
2672
+ </script>
2673
+ </body>
2674
+ </html>