@co0ontty/wand 1.24.0 → 1.25.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.
- package/dist/server.js +93 -3
- package/dist/web-ui/content/scripts.js +399 -39
- package/dist/web-ui/content/styles.css +435 -76
- package/package.json +1 -1
|
@@ -1411,19 +1411,38 @@
|
|
|
1411
1411
|
// File side panel
|
|
1412
1412
|
'<div id="file-side-panel" class="file-side-panel' + (state.filePanelOpen ? " open" : "") + '">' +
|
|
1413
1413
|
'<div class="file-side-panel-header">' +
|
|
1414
|
-
'<
|
|
1415
|
-
|
|
1414
|
+
'<div class="file-side-panel-title-group">' +
|
|
1415
|
+
'<span class="file-side-panel-icon">' + wandFileIcon("folder-open", { size: 16 }) + '</span>' +
|
|
1416
|
+
'<span class="file-side-panel-title">文件</span>' +
|
|
1417
|
+
'</div>' +
|
|
1418
|
+
'<div class="file-side-panel-header-actions">' +
|
|
1419
|
+
'<button class="file-side-panel-iconbtn file-explorer-toggle-hidden' +
|
|
1420
|
+
(state.fileExplorerShowHidden ? ' active' : '') + '" id="file-explorer-toggle-hidden" type="button" title="' +
|
|
1421
|
+
(state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件") + '" aria-pressed="' +
|
|
1422
|
+
(state.fileExplorerShowHidden ? "true" : "false") + '" aria-label="切换显示隐藏文件">' +
|
|
1423
|
+
wandFileIcon(state.fileExplorerShowHidden ? "eye" : "eye-off", { size: 15 }) +
|
|
1424
|
+
'</button>' +
|
|
1425
|
+
'<button class="file-side-panel-iconbtn" id="file-explorer-refresh" type="button" title="刷新" aria-label="刷新文件列表">' +
|
|
1426
|
+
wandFileIcon("refresh", { size: 15 }) +
|
|
1427
|
+
'</button>' +
|
|
1428
|
+
'<button id="file-side-panel-close" class="file-side-panel-iconbtn close" type="button" aria-label="关闭文件面板" title="关闭">' +
|
|
1429
|
+
wandFileIcon("x", { size: 16 }) +
|
|
1430
|
+
'</button>' +
|
|
1431
|
+
'</div>' +
|
|
1416
1432
|
'</div>' +
|
|
1417
1433
|
'<div class="file-side-panel-body">' +
|
|
1418
1434
|
'<div class="file-explorer-header">' +
|
|
1419
|
-
'<button class="file-explorer-up" id="file-explorer-up" type="button" title="返回上级目录" aria-label="返回上级目录"
|
|
1435
|
+
'<button class="file-explorer-up" id="file-explorer-up" type="button" title="返回上级目录" aria-label="返回上级目录">' +
|
|
1436
|
+
wandFileIcon("arrow-up", { size: 15 }) +
|
|
1437
|
+
'</button>' +
|
|
1420
1438
|
'<input type="text" class="file-explorer-path" id="file-explorer-cwd" value="' + escapeHtml(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '" title="' + escapeHtml(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '" placeholder="输入路径并回车..." spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" aria-label="当前路径,可直接修改后回车" />' +
|
|
1421
|
-
'<button class="file-explorer-toggle-hidden' + (state.fileExplorerShowHidden ? ' active' : '') + '" id="file-explorer-toggle-hidden" type="button" title="' + (state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件") + '" aria-pressed="' + (state.fileExplorerShowHidden ? "true" : "false") + '">' + (state.fileExplorerShowHidden ? "👁" : "👁🗨") + '</button>' +
|
|
1422
|
-
'<button class="file-explorer-refresh" id="file-explorer-refresh" title="刷新" aria-label="刷新文件列表">↻</button>' +
|
|
1423
1439
|
'</div>' +
|
|
1424
1440
|
'<div class="file-search-box">' +
|
|
1425
|
-
'<
|
|
1426
|
-
'<
|
|
1441
|
+
'<span class="file-search-icon">' + wandFileIcon("search", { size: 14 }) + '</span>' +
|
|
1442
|
+
'<input type="text" id="file-search-input" class="file-search-input" placeholder="搜索当前目录…" autocomplete="off" />' +
|
|
1443
|
+
'<button class="file-search-clear" id="file-search-clear" type="button" aria-label="清除搜索" title="清除">' +
|
|
1444
|
+
wandFileIcon("x", { size: 13 }) +
|
|
1445
|
+
'</button>' +
|
|
1427
1446
|
'</div>' +
|
|
1428
1447
|
'<div class="file-explorer" id="file-explorer">' + renderFileExplorer(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '</div>' +
|
|
1429
1448
|
'</div>' +
|
|
@@ -1501,18 +1520,20 @@
|
|
|
1501
1520
|
'</svg>' +
|
|
1502
1521
|
'<span class="prompt-optimize-spinner" aria-hidden="true"></span>' +
|
|
1503
1522
|
'</button>' +
|
|
1504
|
-
'<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
|
|
1523
|
+
'<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" enterkeyhint="send">' + escapeHtml(currentDraft) + '</textarea>' +
|
|
1505
1524
|
'<div id="attachment-preview" class="attachment-preview hidden"></div>' +
|
|
1506
1525
|
'<div class="input-composer-bar">' +
|
|
1507
1526
|
'<div class="input-composer-left">' +
|
|
1508
1527
|
'<button id="attach-btn" class="btn-circle btn-circle-attach" type="button" title="附加文件">' +
|
|
1509
1528
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>' +
|
|
1510
1529
|
'</button>' +
|
|
1511
|
-
|
|
1512
|
-
|
|
1530
|
+
// tabindex="-1": 把这些控件移出 iOS Safari 的表单导航链,
|
|
1531
|
+
// 这样 textarea 聚焦时键盘上方就不会出现 ⌃ ⌄ ✓ 表单辅助栏。
|
|
1532
|
+
'<input type="file" id="file-upload-input" multiple tabindex="-1" style="position:absolute;width:1px;height:1px;opacity:0;overflow:hidden;clip:rect(0,0,0,0);pointer-events:none">' +
|
|
1533
|
+
'<select id="chat-mode-select" class="chat-mode-select" tabindex="-1" title="仅对新建会话生效">' +
|
|
1513
1534
|
renderModeOptions(preferredTool, composerMode) +
|
|
1514
1535
|
'</select>' +
|
|
1515
|
-
'<select id="chat-model-select" class="chat-mode-select chat-model-select" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
|
|
1536
|
+
'<select id="chat-model-select" class="chat-mode-select chat-model-select" tabindex="-1" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
|
|
1516
1537
|
renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
|
|
1517
1538
|
'</select>' +
|
|
1518
1539
|
'<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
|
|
@@ -2992,6 +3013,49 @@
|
|
|
2992
3013
|
}
|
|
2993
3014
|
}
|
|
2994
3015
|
|
|
3016
|
+
// ── Inline SVG icon library for file UI ──
|
|
3017
|
+
// The previous design used unicode/emoji glyphs (⬆ ↻ 👁 ✕ 📋 ✏️ ⬇ ↩ A−) for
|
|
3018
|
+
// toolbar/header buttons. Those render inconsistently across OSes and don't
|
|
3019
|
+
// visually convey their action. These SVG icons are stroke-based, follow
|
|
3020
|
+
// currentColor, and stay crisp at any zoom.
|
|
3021
|
+
var WAND_FILE_ICONS = {
|
|
3022
|
+
"chevron-left": '<path d="M15 18l-6-6 6-6"/>',
|
|
3023
|
+
"arrow-up": '<path d="M12 19V5"/><path d="M5 12l7-7 7 7"/>',
|
|
3024
|
+
"refresh": '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/>',
|
|
3025
|
+
"eye": '<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/>',
|
|
3026
|
+
"eye-off": '<path d="M17.94 17.94A10.94 10.94 0 0 1 12 19c-7 0-11-7-11-7a19.86 19.86 0 0 1 4.22-5.18"/><path d="M1 1l22 22"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 7 11 7a19.83 19.83 0 0 1-3.36 4.27"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/>',
|
|
3027
|
+
"x": '<path d="M18 6L6 18"/><path d="M6 6l12 12"/>',
|
|
3028
|
+
"search": '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
|
|
3029
|
+
"copy": '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
|
|
3030
|
+
"clipboard": '<rect x="8" y="3" width="8" height="4" rx="1"/><path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2"/>',
|
|
3031
|
+
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>',
|
|
3032
|
+
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/>',
|
|
3033
|
+
"save": '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
|
|
3034
|
+
"rotate-ccw": '<path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/>',
|
|
3035
|
+
"wrap-text": '<path d="M3 6h18"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><path d="M16 16l-2 2 2 2"/><path d="M3 18h6"/>',
|
|
3036
|
+
"type": '<path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/>',
|
|
3037
|
+
"minus": '<path d="M5 12h14"/>',
|
|
3038
|
+
"plus": '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
|
3039
|
+
"send-to-input": '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>',
|
|
3040
|
+
"terminal": '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
|
3041
|
+
"folder-open": '<path d="M6 14l1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v2"/>',
|
|
3042
|
+
"info": '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
|
3043
|
+
};
|
|
3044
|
+
|
|
3045
|
+
// Render a stroke-based 16x16 SVG icon by name. Extra classes get appended
|
|
3046
|
+
// to the outer svg, so callers can target specific icons in CSS.
|
|
3047
|
+
function wandFileIcon(name, opts) {
|
|
3048
|
+
opts = opts || {};
|
|
3049
|
+
var body = WAND_FILE_ICONS[name] || "";
|
|
3050
|
+
var size = opts.size || 16;
|
|
3051
|
+
var extraClass = opts.className ? " " + opts.className : "";
|
|
3052
|
+
return '<svg class="wand-icon wand-icon-' + name + extraClass +
|
|
3053
|
+
'" width="' + size + '" height="' + size +
|
|
3054
|
+
'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"' +
|
|
3055
|
+
' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
3056
|
+
body + '</svg>';
|
|
3057
|
+
}
|
|
3058
|
+
|
|
2995
3059
|
function renderFileExplorer(cwd) {
|
|
2996
3060
|
var root = cwd || getConfigCwd();
|
|
2997
3061
|
if (!root) {
|
|
@@ -3311,7 +3375,7 @@
|
|
|
3311
3375
|
if (btn) {
|
|
3312
3376
|
btn.classList.toggle("active", state.fileExplorerShowHidden);
|
|
3313
3377
|
btn.setAttribute("aria-pressed", state.fileExplorerShowHidden ? "true" : "false");
|
|
3314
|
-
btn.
|
|
3378
|
+
btn.innerHTML = wandFileIcon(state.fileExplorerShowHidden ? "eye" : "eye-off", { size: 15 });
|
|
3315
3379
|
btn.title = state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件";
|
|
3316
3380
|
}
|
|
3317
3381
|
refreshFileExplorer();
|
|
@@ -3454,11 +3518,15 @@
|
|
|
3454
3518
|
'<div class="file-preview-header">' +
|
|
3455
3519
|
'<div class="file-preview-title">' +
|
|
3456
3520
|
'<span class="file-preview-icon">📄</span>' +
|
|
3457
|
-
'<
|
|
3521
|
+
'<div class="file-preview-name-block">' +
|
|
3522
|
+
'<div class="file-preview-name-row">' +
|
|
3523
|
+
'<span class="file-preview-filename">加载中…</span>' +
|
|
3524
|
+
'</div>' +
|
|
3525
|
+
'<span class="file-preview-path" title=""></span>' +
|
|
3526
|
+
'</div>' +
|
|
3458
3527
|
'</div>' +
|
|
3459
|
-
'<div class="file-preview-path" title=""></div>' +
|
|
3460
3528
|
'<div class="file-preview-toolbar"></div>' +
|
|
3461
|
-
'<button class="file-preview-close" title="关闭 (Esc)" aria-label="关闭"
|
|
3529
|
+
'<button class="file-preview-close" title="关闭 (Esc)" aria-label="关闭">' + wandFileIcon("x", { size: 18 }) + '</button>' +
|
|
3462
3530
|
'</div>' +
|
|
3463
3531
|
'<div class="file-preview-body">' +
|
|
3464
3532
|
'<div class="file-preview-loading">加载预览…</div>' +
|
|
@@ -3468,6 +3536,25 @@
|
|
|
3468
3536
|
|
|
3469
3537
|
var closeBtn = overlay.querySelector(".file-preview-close");
|
|
3470
3538
|
var closeModal = function() {
|
|
3539
|
+
// Guard: warn before discarding unsaved edits.
|
|
3540
|
+
if (_activeFilePreview && _activeFilePreview.dirty) {
|
|
3541
|
+
if (typeof openWandDialog === "function") {
|
|
3542
|
+
openWandDialog({
|
|
3543
|
+
type: "warning",
|
|
3544
|
+
title: "放弃未保存的修改?",
|
|
3545
|
+
message: "当前文件有未保存的改动,关闭后会丢失。",
|
|
3546
|
+
buttons: [
|
|
3547
|
+
{ label: "继续编辑", value: false, kind: "ghost" },
|
|
3548
|
+
{ label: "放弃修改", value: true, kind: "danger", autofocus: true },
|
|
3549
|
+
],
|
|
3550
|
+
cancelValue: false,
|
|
3551
|
+
}).then(function(go) { if (go) doClose(); });
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
doClose();
|
|
3556
|
+
};
|
|
3557
|
+
var doClose = function() {
|
|
3471
3558
|
overlay.remove();
|
|
3472
3559
|
document.removeEventListener("keydown", keyHandler);
|
|
3473
3560
|
_activeFilePreview = null;
|
|
@@ -3477,21 +3564,49 @@
|
|
|
3477
3564
|
if (e.target === overlay) closeModal();
|
|
3478
3565
|
});
|
|
3479
3566
|
var keyHandler = function(e) {
|
|
3480
|
-
|
|
3567
|
+
// Ctrl/Cmd+S to save in edit mode.
|
|
3568
|
+
if ((e.key === "s" || e.key === "S") && (e.ctrlKey || e.metaKey)) {
|
|
3569
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3570
|
+
e.preventDefault();
|
|
3571
|
+
saveFileEdit();
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
if (e.key === "Escape") {
|
|
3576
|
+
// Inside edit mode, Esc exits edit instead of closing the modal.
|
|
3577
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3578
|
+
e.preventDefault();
|
|
3579
|
+
exitFileEdit();
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3582
|
+
closeModal();
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3481
3585
|
if (!_activeFilePreview) return;
|
|
3586
|
+
// Don't intercept arrow keys while typing.
|
|
3482
3587
|
if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
|
|
3588
|
+
if (_activeFilePreview.editing) return;
|
|
3483
3589
|
if (e.key === "ArrowLeft") { e.preventDefault(); navigatePreviewSibling(-1); }
|
|
3484
3590
|
else if (e.key === "ArrowRight") { e.preventDefault(); navigatePreviewSibling(1); }
|
|
3485
3591
|
};
|
|
3486
3592
|
document.addEventListener("keydown", keyHandler);
|
|
3487
3593
|
|
|
3488
|
-
_activeFilePreview = { overlay: overlay, close: closeModal, path: filePath, data: null };
|
|
3594
|
+
_activeFilePreview = { overlay: overlay, close: closeModal, path: filePath, data: null, editing: false, dirty: false };
|
|
3489
3595
|
} else {
|
|
3490
3596
|
_activeFilePreview.path = filePath;
|
|
3597
|
+
_activeFilePreview.editing = false;
|
|
3598
|
+
_activeFilePreview.dirty = false;
|
|
3491
3599
|
// Reset header / body for the new file.
|
|
3492
3600
|
var titleEl = overlay.querySelector(".file-preview-title");
|
|
3493
3601
|
if (titleEl) {
|
|
3494
|
-
titleEl.innerHTML =
|
|
3602
|
+
titleEl.innerHTML =
|
|
3603
|
+
'<span class="file-preview-icon">📄</span>' +
|
|
3604
|
+
'<div class="file-preview-name-block">' +
|
|
3605
|
+
'<div class="file-preview-name-row">' +
|
|
3606
|
+
'<span class="file-preview-filename">加载中…</span>' +
|
|
3607
|
+
'</div>' +
|
|
3608
|
+
'<span class="file-preview-path" title=""></span>' +
|
|
3609
|
+
'</div>';
|
|
3495
3610
|
}
|
|
3496
3611
|
var toolbarEl = overlay.querySelector(".file-preview-toolbar");
|
|
3497
3612
|
if (toolbarEl) toolbarEl.innerHTML = "";
|
|
@@ -3681,16 +3796,31 @@
|
|
|
3681
3796
|
var bar = overlay.querySelector(".file-preview-toolbar");
|
|
3682
3797
|
if (!bar) return;
|
|
3683
3798
|
bar.innerHTML = "";
|
|
3799
|
+
bar.classList.remove("editing");
|
|
3800
|
+
|
|
3801
|
+
// ── Edit mode renders its own dedicated toolbar (save / revert / cancel). ──
|
|
3802
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3803
|
+
bar.classList.add("editing");
|
|
3804
|
+
renderEditToolbar(overlay, data);
|
|
3805
|
+
return;
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3684
3808
|
var buttons = [];
|
|
3685
3809
|
|
|
3810
|
+
if (data.kind === "text") {
|
|
3811
|
+
buttons.push({ label: "编辑文件 (E)", icon: wandFileIcon("edit"), primary: true, action: function() {
|
|
3812
|
+
enterFileEdit();
|
|
3813
|
+
}});
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3686
3816
|
// Common actions across all kinds
|
|
3687
|
-
buttons.push({ label: "复制路径", icon: "
|
|
3817
|
+
buttons.push({ label: "复制路径", icon: wandFileIcon("clipboard"), action: function() {
|
|
3688
3818
|
copyTextSafely(data.path).then(function() { showToastIfPossible("已复制路径"); });
|
|
3689
3819
|
}});
|
|
3690
|
-
buttons.push({ label: "粘贴到输入框", icon: "
|
|
3820
|
+
buttons.push({ label: "粘贴到输入框", icon: wandFileIcon("send-to-input"), action: function() {
|
|
3691
3821
|
if (appendToComposer(data.path)) showToastIfPossible("已粘贴到输入框");
|
|
3692
3822
|
}});
|
|
3693
|
-
buttons.push({ label: "下载", icon: "
|
|
3823
|
+
buttons.push({ label: "下载", icon: wandFileIcon("download"), action: function() {
|
|
3694
3824
|
var a = document.createElement("a");
|
|
3695
3825
|
a.href = "/api/file-raw?download=1&path=" + encodeURIComponent(data.path);
|
|
3696
3826
|
a.download = data.name || "";
|
|
@@ -3700,10 +3830,10 @@
|
|
|
3700
3830
|
}});
|
|
3701
3831
|
|
|
3702
3832
|
if (data.kind === "text") {
|
|
3703
|
-
buttons.push({ label: "
|
|
3833
|
+
buttons.push({ label: "复制全部内容", icon: wandFileIcon("copy"), action: function() {
|
|
3704
3834
|
copyTextSafely(data.content || "").then(function() { showToastIfPossible("已复制内容"); });
|
|
3705
3835
|
}});
|
|
3706
|
-
buttons.push({ label: "
|
|
3836
|
+
buttons.push({ label: "切换自动换行", icon: wandFileIcon("wrap-text"), toggleClass: "toolbar-active",
|
|
3707
3837
|
getInitial: function() {
|
|
3708
3838
|
var pre = overlay.querySelector(".code-preview-content pre");
|
|
3709
3839
|
return pre && pre.classList.contains("wrap");
|
|
@@ -3715,26 +3845,235 @@
|
|
|
3715
3845
|
btn.classList.toggle("toolbar-active", pre.classList.contains("wrap"));
|
|
3716
3846
|
}
|
|
3717
3847
|
});
|
|
3718
|
-
|
|
3719
|
-
buttons.push({
|
|
3848
|
+
// Font-size adjustments — render as a single grouped chip with two halves.
|
|
3849
|
+
buttons.push({ kind: "group", className: "toolbar-group-fontsize",
|
|
3850
|
+
children: [
|
|
3851
|
+
{ label: "缩小字号", icon: wandFileIcon("minus"), action: function() { adjustPreviewFontSize(overlay, -1); }},
|
|
3852
|
+
{ kind: "label", icon: wandFileIcon("type"), label: "字号" },
|
|
3853
|
+
{ label: "放大字号", icon: wandFileIcon("plus"), action: function() { adjustPreviewFontSize(overlay, +1); }},
|
|
3854
|
+
],
|
|
3855
|
+
});
|
|
3720
3856
|
}
|
|
3721
3857
|
|
|
3858
|
+
renderToolbarButtons(bar, buttons, overlay);
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
// Render a flat list of toolbar buttons (with optional grouped chips).
|
|
3862
|
+
function renderToolbarButtons(bar, buttons, overlay) {
|
|
3722
3863
|
buttons.forEach(function(b) {
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3864
|
+
if (b.kind === "group") {
|
|
3865
|
+
var group = document.createElement("div");
|
|
3866
|
+
group.className = "file-preview-toolbar-group" + (b.className ? " " + b.className : "");
|
|
3867
|
+
b.children.forEach(function(child) {
|
|
3868
|
+
if (child.kind === "label") {
|
|
3869
|
+
var lab = document.createElement("span");
|
|
3870
|
+
lab.className = "file-preview-toolbar-grouplabel";
|
|
3871
|
+
lab.title = child.label || "";
|
|
3872
|
+
lab.innerHTML = child.icon || "";
|
|
3873
|
+
group.appendChild(lab);
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
group.appendChild(buildToolbarButton(child));
|
|
3877
|
+
});
|
|
3878
|
+
bar.appendChild(group);
|
|
3879
|
+
return;
|
|
3880
|
+
}
|
|
3881
|
+
bar.appendChild(buildToolbarButton(b));
|
|
3882
|
+
});
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
function buildToolbarButton(b) {
|
|
3886
|
+
var btn = document.createElement("button");
|
|
3887
|
+
btn.type = "button";
|
|
3888
|
+
btn.className = "file-preview-toolbar-btn";
|
|
3889
|
+
if (b.primary) btn.classList.add("primary");
|
|
3890
|
+
if (b.danger) btn.classList.add("danger");
|
|
3891
|
+
btn.title = b.label;
|
|
3892
|
+
btn.setAttribute("aria-label", b.label);
|
|
3893
|
+
btn.innerHTML = '<span class="toolbar-icon">' + (b.icon || "") + '</span>' +
|
|
3894
|
+
(b.text ? '<span class="toolbar-text">' + escapeHtml(b.text) + '</span>' : '');
|
|
3895
|
+
if (b.getInitial && b.getInitial()) btn.classList.add("toolbar-active");
|
|
3896
|
+
btn.addEventListener("click", function(ev) {
|
|
3897
|
+
ev.stopPropagation();
|
|
3898
|
+
if (typeof b.action === "function") b.action(btn);
|
|
3899
|
+
});
|
|
3900
|
+
return btn;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
// ── Edit mode ──
|
|
3904
|
+
function renderEditToolbar(overlay, data) {
|
|
3905
|
+
var bar = overlay.querySelector(".file-preview-toolbar");
|
|
3906
|
+
if (!bar) return;
|
|
3907
|
+
bar.innerHTML = "";
|
|
3908
|
+
var saving = _activeFilePreview && _activeFilePreview.saving;
|
|
3909
|
+
var buttons = [
|
|
3910
|
+
{ label: "保存 (Ctrl+S)", icon: wandFileIcon("save"), text: "保存", primary: true,
|
|
3911
|
+
action: function() { saveFileEdit(); } },
|
|
3912
|
+
{ label: "撤销改动", icon: wandFileIcon("rotate-ccw"),
|
|
3913
|
+
action: function() { revertFileEdit(); } },
|
|
3914
|
+
{ label: "退出编辑 (Esc)", icon: wandFileIcon("x"),
|
|
3915
|
+
action: function() { exitFileEdit(); } },
|
|
3916
|
+
];
|
|
3917
|
+
renderToolbarButtons(bar, buttons, overlay);
|
|
3918
|
+
if (saving) {
|
|
3919
|
+
bar.querySelectorAll(".file-preview-toolbar-btn").forEach(function(b) { b.disabled = true; });
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3923
|
+
function enterFileEdit() {
|
|
3924
|
+
if (!_activeFilePreview || !_activeFilePreview.data) return;
|
|
3925
|
+
var data = _activeFilePreview.data;
|
|
3926
|
+
if (data.kind !== "text") return;
|
|
3927
|
+
_activeFilePreview.editing = true;
|
|
3928
|
+
_activeFilePreview.dirty = false;
|
|
3929
|
+
_activeFilePreview.originalContent = data.content || "";
|
|
3930
|
+
var overlay = _activeFilePreview.overlay;
|
|
3931
|
+
var body = overlay.querySelector(".file-preview-body");
|
|
3932
|
+
if (!body) return;
|
|
3933
|
+
body.classList.add("editing");
|
|
3934
|
+
body.innerHTML =
|
|
3935
|
+
'<div class="code-editor-wrapper">' +
|
|
3936
|
+
'<textarea class="code-editor-textarea" spellcheck="false" autocomplete="off"' +
|
|
3937
|
+
' autocorrect="off" autocapitalize="off" wrap="off"></textarea>' +
|
|
3938
|
+
'</div>';
|
|
3939
|
+
var ta = body.querySelector(".code-editor-textarea");
|
|
3940
|
+
if (ta) {
|
|
3941
|
+
ta.value = data.content || "";
|
|
3942
|
+
ta.addEventListener("input", function() {
|
|
3943
|
+
var dirty = ta.value !== (_activeFilePreview.originalContent || "");
|
|
3944
|
+
if (dirty !== _activeFilePreview.dirty) {
|
|
3945
|
+
_activeFilePreview.dirty = dirty;
|
|
3946
|
+
updateDirtyBadge();
|
|
3947
|
+
}
|
|
3733
3948
|
});
|
|
3734
|
-
|
|
3949
|
+
// Tab key inserts spaces (2-space indent) instead of moving focus.
|
|
3950
|
+
ta.addEventListener("keydown", function(e) {
|
|
3951
|
+
if (e.key === "Tab") {
|
|
3952
|
+
e.preventDefault();
|
|
3953
|
+
var start = ta.selectionStart, end = ta.selectionEnd;
|
|
3954
|
+
var indent = " ";
|
|
3955
|
+
ta.value = ta.value.slice(0, start) + indent + ta.value.slice(end);
|
|
3956
|
+
ta.selectionStart = ta.selectionEnd = start + indent.length;
|
|
3957
|
+
ta.dispatchEvent(new Event("input"));
|
|
3958
|
+
}
|
|
3959
|
+
});
|
|
3960
|
+
// Focus and place caret at start so user sees the top of the file.
|
|
3961
|
+
setTimeout(function() {
|
|
3962
|
+
ta.focus();
|
|
3963
|
+
ta.setSelectionRange(0, 0);
|
|
3964
|
+
ta.scrollTop = 0;
|
|
3965
|
+
}, 30);
|
|
3966
|
+
}
|
|
3967
|
+
renderPreviewToolbar(overlay, data);
|
|
3968
|
+
updateDirtyBadge();
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
function exitFileEdit() {
|
|
3972
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
3973
|
+
var doExit = function() {
|
|
3974
|
+
_activeFilePreview.editing = false;
|
|
3975
|
+
_activeFilePreview.dirty = false;
|
|
3976
|
+
var overlay = _activeFilePreview.overlay;
|
|
3977
|
+
var body = overlay.querySelector(".file-preview-body");
|
|
3978
|
+
if (body) body.classList.remove("editing");
|
|
3979
|
+
// Re-render preview from latest data.
|
|
3980
|
+
renderPreviewContent(overlay, _activeFilePreview.data);
|
|
3981
|
+
updateDirtyBadge();
|
|
3982
|
+
};
|
|
3983
|
+
if (_activeFilePreview.dirty && typeof openWandDialog === "function") {
|
|
3984
|
+
openWandDialog({
|
|
3985
|
+
type: "warning",
|
|
3986
|
+
title: "放弃未保存的修改?",
|
|
3987
|
+
message: "当前文件有未保存的改动,退出编辑后会丢失。",
|
|
3988
|
+
buttons: [
|
|
3989
|
+
{ label: "继续编辑", value: false, kind: "ghost" },
|
|
3990
|
+
{ label: "放弃修改", value: true, kind: "danger", autofocus: true },
|
|
3991
|
+
],
|
|
3992
|
+
cancelValue: false,
|
|
3993
|
+
}).then(function(go) { if (go) doExit(); });
|
|
3994
|
+
return;
|
|
3995
|
+
}
|
|
3996
|
+
doExit();
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
function revertFileEdit() {
|
|
4000
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
4001
|
+
var overlay = _activeFilePreview.overlay;
|
|
4002
|
+
var ta = overlay.querySelector(".code-editor-textarea");
|
|
4003
|
+
if (!ta) return;
|
|
4004
|
+
ta.value = _activeFilePreview.originalContent || "";
|
|
4005
|
+
_activeFilePreview.dirty = false;
|
|
4006
|
+
updateDirtyBadge();
|
|
4007
|
+
ta.focus();
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
function saveFileEdit() {
|
|
4011
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
4012
|
+
if (_activeFilePreview.saving) return;
|
|
4013
|
+
var overlay = _activeFilePreview.overlay;
|
|
4014
|
+
var ta = overlay.querySelector(".code-editor-textarea");
|
|
4015
|
+
if (!ta) return;
|
|
4016
|
+
var newContent = ta.value;
|
|
4017
|
+
if (newContent === (_activeFilePreview.originalContent || "")) {
|
|
4018
|
+
showToastIfPossible("没有改动");
|
|
4019
|
+
return;
|
|
4020
|
+
}
|
|
4021
|
+
_activeFilePreview.saving = true;
|
|
4022
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4023
|
+
fetch("/api/file-write", {
|
|
4024
|
+
method: "POST",
|
|
4025
|
+
credentials: "same-origin",
|
|
4026
|
+
headers: { "Content-Type": "application/json" },
|
|
4027
|
+
body: JSON.stringify({ path: _activeFilePreview.path, content: newContent }),
|
|
4028
|
+
}).then(function(res) {
|
|
4029
|
+
return res.json().then(function(json) { return { ok: res.ok, status: res.status, data: json }; });
|
|
4030
|
+
}).then(function(result) {
|
|
4031
|
+
_activeFilePreview.saving = false;
|
|
4032
|
+
if (!result.ok || (result.data && result.data.error)) {
|
|
4033
|
+
var msg = (result.data && result.data.error) || ("保存失败 (" + result.status + ")");
|
|
4034
|
+
showToastIfPossible(msg);
|
|
4035
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4036
|
+
return;
|
|
4037
|
+
}
|
|
4038
|
+
// Sync local cache so revert points at the new baseline.
|
|
4039
|
+
_activeFilePreview.data.content = newContent;
|
|
4040
|
+
_activeFilePreview.data.size = (result.data && result.data.size) || newContent.length;
|
|
4041
|
+
_activeFilePreview.originalContent = newContent;
|
|
4042
|
+
_activeFilePreview.dirty = false;
|
|
4043
|
+
showToastIfPossible("已保存");
|
|
4044
|
+
updateDirtyBadge();
|
|
4045
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4046
|
+
// Quietly refresh the file tree so size/git-status update.
|
|
4047
|
+
if (typeof refreshFileExplorer === "function") {
|
|
4048
|
+
try { refreshFileExplorer(); } catch (e) {}
|
|
4049
|
+
}
|
|
4050
|
+
}).catch(function(err) {
|
|
4051
|
+
_activeFilePreview.saving = false;
|
|
4052
|
+
showToastIfPossible("保存失败:" + (err && err.message ? err.message : "网络错误"));
|
|
4053
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
3735
4054
|
});
|
|
3736
4055
|
}
|
|
3737
4056
|
|
|
4057
|
+
function updateDirtyBadge() {
|
|
4058
|
+
if (!_activeFilePreview) return;
|
|
4059
|
+
var overlay = _activeFilePreview.overlay;
|
|
4060
|
+
if (!overlay) return;
|
|
4061
|
+
var row = overlay.querySelector(".file-preview-name-row");
|
|
4062
|
+
if (!row) return;
|
|
4063
|
+
var existing = row.querySelector(".file-preview-dirty");
|
|
4064
|
+
if (_activeFilePreview.dirty) {
|
|
4065
|
+
if (!existing) {
|
|
4066
|
+
var dot = document.createElement("span");
|
|
4067
|
+
dot.className = "file-preview-dirty";
|
|
4068
|
+
dot.title = "有未保存的修改";
|
|
4069
|
+
dot.textContent = "● 未保存";
|
|
4070
|
+
row.appendChild(dot);
|
|
4071
|
+
}
|
|
4072
|
+
} else if (existing) {
|
|
4073
|
+
existing.remove();
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
|
|
3738
4077
|
function adjustPreviewFontSize(overlay, delta) {
|
|
3739
4078
|
var pre = overlay.querySelector(".code-preview-content pre");
|
|
3740
4079
|
var nums = overlay.querySelector(".code-preview-lines");
|
|
@@ -12508,7 +12847,27 @@
|
|
|
12508
12847
|
// Keyboard just closed — force terminal refit and scroll to bottom
|
|
12509
12848
|
// after a delay so the keyboard dismiss animation and layout settle.
|
|
12510
12849
|
if (keyboardOpen && !isKeyboardOpen) {
|
|
12850
|
+
// iOS Safari quirk: 用户按系统 Done / 下滑收起键盘 / 应用切换回来时,
|
|
12851
|
+
// 经常不会触发 textarea 的 blur 事件,导致 handleInputBoxBlur 里的
|
|
12852
|
+
// window.scrollTo(0,0) 不跑,页面停在键盘抬起时被 iOS 推上去的
|
|
12853
|
+
// 偏移位置,input-panel 看起来"没回到底"。
|
|
12854
|
+
// 这里在 visualViewport 检测到键盘收起的瞬间直接强制复位一次,
|
|
12855
|
+
// 并把 --app-viewport-height 兜底清掉,确保 .app-container 回到
|
|
12856
|
+
// 100dvh、input-panel 重新贴屏幕底部。
|
|
12857
|
+
var rootEl = document.documentElement;
|
|
12858
|
+
rootEl.style.removeProperty('--app-viewport-height');
|
|
12859
|
+
window.scrollTo(0, 0);
|
|
12860
|
+
if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
|
|
12861
|
+
rootEl.scrollTop = 0;
|
|
12862
|
+
if (document.body) document.body.scrollTop = 0;
|
|
12511
12863
|
setTimeout(function() {
|
|
12864
|
+
// 二次复位:等键盘收起动画 + iOS 自身的回滚跑完后再清一次,
|
|
12865
|
+
// 防止 iOS 在动画过程中又把 scrollTop 推上去。
|
|
12866
|
+
window.scrollTo(0, 0);
|
|
12867
|
+
if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
|
|
12868
|
+
rootEl.scrollTop = 0;
|
|
12869
|
+
if (document.body) document.body.scrollTop = 0;
|
|
12870
|
+
syncAppViewportHeight();
|
|
12512
12871
|
ensureTerminalFit("keyboard-close", { forceReplay: true });
|
|
12513
12872
|
maybeScrollTerminalToBottom("force");
|
|
12514
12873
|
}, 200);
|
|
@@ -14317,11 +14676,12 @@
|
|
|
14317
14676
|
var task = document.getElementById("todo-progress-task");
|
|
14318
14677
|
if (task) task.textContent = activeTask;
|
|
14319
14678
|
|
|
14320
|
-
//
|
|
14321
|
-
//
|
|
14679
|
+
// Keep the ring aligned with the counter text: both reflect the 1-indexed
|
|
14680
|
+
// "current step" (completed + 1, capped at total). Otherwise the ring lags
|
|
14681
|
+
// one step behind whenever a task is in_progress (e.g. text "6/6" but ring 5/6).
|
|
14322
14682
|
var ring = document.getElementById("todo-progress-ring");
|
|
14323
14683
|
if (ring) {
|
|
14324
|
-
var ratio = todos.length > 0 ?
|
|
14684
|
+
var ratio = todos.length > 0 ? currentStep / todos.length : 0;
|
|
14325
14685
|
ring.style.setProperty("--progress", ratio.toFixed(3));
|
|
14326
14686
|
}
|
|
14327
14687
|
|