@co0ontty/wand 1.24.0 → 1.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +93 -3
- package/dist/web-ui/content/scripts.js +413 -39
- package/dist/web-ui/content/styles.css +457 -76
- package/package.json +1 -1
|
@@ -54,6 +54,20 @@
|
|
|
54
54
|
['standalone', 'window-controls-overlay', 'fullscreen'].forEach(function(m) {
|
|
55
55
|
window.matchMedia('(display-mode: ' + m + ')').addEventListener('change', detectDisplayMode);
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
// Wand Android APK detection: the native shell appends "WandApp/<version>"
|
|
59
|
+
// to the WebView user-agent. On Android (targetSdk >= 35 forces edge-to-edge
|
|
60
|
+
// rendering) the WebView extends behind the status bar, but Android WebView
|
|
61
|
+
// doesn't propagate WindowInsets to env(safe-area-inset-*). Tagging the
|
|
62
|
+
// document root lets CSS apply a sane min top inset so top-pinned drawers
|
|
63
|
+
// and modals don't sit under the status bar. Newer APK builds also inject
|
|
64
|
+
// exact pixel values into --app-inset-top via the AndroidInsets bridge.
|
|
65
|
+
try {
|
|
66
|
+
var ua = (navigator && navigator.userAgent) || "";
|
|
67
|
+
if (/WandApp\//.test(ua)) {
|
|
68
|
+
document.documentElement.classList.add('is-wand-app');
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {}
|
|
57
71
|
})();
|
|
58
72
|
|
|
59
73
|
(function() {
|
|
@@ -1411,19 +1425,38 @@
|
|
|
1411
1425
|
// File side panel
|
|
1412
1426
|
'<div id="file-side-panel" class="file-side-panel' + (state.filePanelOpen ? " open" : "") + '">' +
|
|
1413
1427
|
'<div class="file-side-panel-header">' +
|
|
1414
|
-
'<
|
|
1415
|
-
|
|
1428
|
+
'<div class="file-side-panel-title-group">' +
|
|
1429
|
+
'<span class="file-side-panel-icon">' + wandFileIcon("folder-open", { size: 16 }) + '</span>' +
|
|
1430
|
+
'<span class="file-side-panel-title">文件</span>' +
|
|
1431
|
+
'</div>' +
|
|
1432
|
+
'<div class="file-side-panel-header-actions">' +
|
|
1433
|
+
'<button class="file-side-panel-iconbtn file-explorer-toggle-hidden' +
|
|
1434
|
+
(state.fileExplorerShowHidden ? ' active' : '') + '" id="file-explorer-toggle-hidden" type="button" title="' +
|
|
1435
|
+
(state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件") + '" aria-pressed="' +
|
|
1436
|
+
(state.fileExplorerShowHidden ? "true" : "false") + '" aria-label="切换显示隐藏文件">' +
|
|
1437
|
+
wandFileIcon(state.fileExplorerShowHidden ? "eye" : "eye-off", { size: 15 }) +
|
|
1438
|
+
'</button>' +
|
|
1439
|
+
'<button class="file-side-panel-iconbtn" id="file-explorer-refresh" type="button" title="刷新" aria-label="刷新文件列表">' +
|
|
1440
|
+
wandFileIcon("refresh", { size: 15 }) +
|
|
1441
|
+
'</button>' +
|
|
1442
|
+
'<button id="file-side-panel-close" class="file-side-panel-iconbtn close" type="button" aria-label="关闭文件面板" title="关闭">' +
|
|
1443
|
+
wandFileIcon("x", { size: 16 }) +
|
|
1444
|
+
'</button>' +
|
|
1445
|
+
'</div>' +
|
|
1416
1446
|
'</div>' +
|
|
1417
1447
|
'<div class="file-side-panel-body">' +
|
|
1418
1448
|
'<div class="file-explorer-header">' +
|
|
1419
|
-
'<button class="file-explorer-up" id="file-explorer-up" type="button" title="返回上级目录" aria-label="返回上级目录"
|
|
1449
|
+
'<button class="file-explorer-up" id="file-explorer-up" type="button" title="返回上级目录" aria-label="返回上级目录">' +
|
|
1450
|
+
wandFileIcon("arrow-up", { size: 15 }) +
|
|
1451
|
+
'</button>' +
|
|
1420
1452
|
'<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
1453
|
'</div>' +
|
|
1424
1454
|
'<div class="file-search-box">' +
|
|
1425
|
-
'<
|
|
1426
|
-
'<
|
|
1455
|
+
'<span class="file-search-icon">' + wandFileIcon("search", { size: 14 }) + '</span>' +
|
|
1456
|
+
'<input type="text" id="file-search-input" class="file-search-input" placeholder="搜索当前目录…" autocomplete="off" />' +
|
|
1457
|
+
'<button class="file-search-clear" id="file-search-clear" type="button" aria-label="清除搜索" title="清除">' +
|
|
1458
|
+
wandFileIcon("x", { size: 13 }) +
|
|
1459
|
+
'</button>' +
|
|
1427
1460
|
'</div>' +
|
|
1428
1461
|
'<div class="file-explorer" id="file-explorer">' + renderFileExplorer(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '</div>' +
|
|
1429
1462
|
'</div>' +
|
|
@@ -1501,18 +1534,20 @@
|
|
|
1501
1534
|
'</svg>' +
|
|
1502
1535
|
'<span class="prompt-optimize-spinner" aria-hidden="true"></span>' +
|
|
1503
1536
|
'</button>' +
|
|
1504
|
-
'<textarea id="input-box" class="input-textarea" placeholder="' + getComposerPlaceholder(selectedSession, state.terminalInteractive) + '" rows="1">' + escapeHtml(currentDraft) + '</textarea>' +
|
|
1537
|
+
'<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
1538
|
'<div id="attachment-preview" class="attachment-preview hidden"></div>' +
|
|
1506
1539
|
'<div class="input-composer-bar">' +
|
|
1507
1540
|
'<div class="input-composer-left">' +
|
|
1508
1541
|
'<button id="attach-btn" class="btn-circle btn-circle-attach" type="button" title="附加文件">' +
|
|
1509
1542
|
'<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
1543
|
'</button>' +
|
|
1511
|
-
|
|
1512
|
-
|
|
1544
|
+
// tabindex="-1": 把这些控件移出 iOS Safari 的表单导航链,
|
|
1545
|
+
// 这样 textarea 聚焦时键盘上方就不会出现 ⌃ ⌄ ✓ 表单辅助栏。
|
|
1546
|
+
'<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">' +
|
|
1547
|
+
'<select id="chat-mode-select" class="chat-mode-select" tabindex="-1" title="仅对新建会话生效">' +
|
|
1513
1548
|
renderModeOptions(preferredTool, composerMode) +
|
|
1514
1549
|
'</select>' +
|
|
1515
|
-
'<select id="chat-model-select" class="chat-mode-select chat-model-select" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
|
|
1550
|
+
'<select id="chat-model-select" class="chat-mode-select chat-model-select" tabindex="-1" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
|
|
1516
1551
|
renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
|
|
1517
1552
|
'</select>' +
|
|
1518
1553
|
'<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
|
|
@@ -2992,6 +3027,49 @@
|
|
|
2992
3027
|
}
|
|
2993
3028
|
}
|
|
2994
3029
|
|
|
3030
|
+
// ── Inline SVG icon library for file UI ──
|
|
3031
|
+
// The previous design used unicode/emoji glyphs (⬆ ↻ 👁 ✕ 📋 ✏️ ⬇ ↩ A−) for
|
|
3032
|
+
// toolbar/header buttons. Those render inconsistently across OSes and don't
|
|
3033
|
+
// visually convey their action. These SVG icons are stroke-based, follow
|
|
3034
|
+
// currentColor, and stay crisp at any zoom.
|
|
3035
|
+
var WAND_FILE_ICONS = {
|
|
3036
|
+
"chevron-left": '<path d="M15 18l-6-6 6-6"/>',
|
|
3037
|
+
"arrow-up": '<path d="M12 19V5"/><path d="M5 12l7-7 7 7"/>',
|
|
3038
|
+
"refresh": '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/>',
|
|
3039
|
+
"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"/>',
|
|
3040
|
+
"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"/>',
|
|
3041
|
+
"x": '<path d="M18 6L6 18"/><path d="M6 6l12 12"/>',
|
|
3042
|
+
"search": '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
|
|
3043
|
+
"copy": '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
|
|
3044
|
+
"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"/>',
|
|
3045
|
+
"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"/>',
|
|
3046
|
+
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/>',
|
|
3047
|
+
"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"/>',
|
|
3048
|
+
"rotate-ccw": '<path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/>',
|
|
3049
|
+
"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"/>',
|
|
3050
|
+
"type": '<path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/>',
|
|
3051
|
+
"minus": '<path d="M5 12h14"/>',
|
|
3052
|
+
"plus": '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
|
3053
|
+
"send-to-input": '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>',
|
|
3054
|
+
"terminal": '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
|
|
3055
|
+
"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"/>',
|
|
3056
|
+
"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"/>',
|
|
3057
|
+
};
|
|
3058
|
+
|
|
3059
|
+
// Render a stroke-based 16x16 SVG icon by name. Extra classes get appended
|
|
3060
|
+
// to the outer svg, so callers can target specific icons in CSS.
|
|
3061
|
+
function wandFileIcon(name, opts) {
|
|
3062
|
+
opts = opts || {};
|
|
3063
|
+
var body = WAND_FILE_ICONS[name] || "";
|
|
3064
|
+
var size = opts.size || 16;
|
|
3065
|
+
var extraClass = opts.className ? " " + opts.className : "";
|
|
3066
|
+
return '<svg class="wand-icon wand-icon-' + name + extraClass +
|
|
3067
|
+
'" width="' + size + '" height="' + size +
|
|
3068
|
+
'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"' +
|
|
3069
|
+
' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
3070
|
+
body + '</svg>';
|
|
3071
|
+
}
|
|
3072
|
+
|
|
2995
3073
|
function renderFileExplorer(cwd) {
|
|
2996
3074
|
var root = cwd || getConfigCwd();
|
|
2997
3075
|
if (!root) {
|
|
@@ -3311,7 +3389,7 @@
|
|
|
3311
3389
|
if (btn) {
|
|
3312
3390
|
btn.classList.toggle("active", state.fileExplorerShowHidden);
|
|
3313
3391
|
btn.setAttribute("aria-pressed", state.fileExplorerShowHidden ? "true" : "false");
|
|
3314
|
-
btn.
|
|
3392
|
+
btn.innerHTML = wandFileIcon(state.fileExplorerShowHidden ? "eye" : "eye-off", { size: 15 });
|
|
3315
3393
|
btn.title = state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件";
|
|
3316
3394
|
}
|
|
3317
3395
|
refreshFileExplorer();
|
|
@@ -3454,11 +3532,15 @@
|
|
|
3454
3532
|
'<div class="file-preview-header">' +
|
|
3455
3533
|
'<div class="file-preview-title">' +
|
|
3456
3534
|
'<span class="file-preview-icon">📄</span>' +
|
|
3457
|
-
'<
|
|
3535
|
+
'<div class="file-preview-name-block">' +
|
|
3536
|
+
'<div class="file-preview-name-row">' +
|
|
3537
|
+
'<span class="file-preview-filename">加载中…</span>' +
|
|
3538
|
+
'</div>' +
|
|
3539
|
+
'<span class="file-preview-path" title=""></span>' +
|
|
3540
|
+
'</div>' +
|
|
3458
3541
|
'</div>' +
|
|
3459
|
-
'<div class="file-preview-path" title=""></div>' +
|
|
3460
3542
|
'<div class="file-preview-toolbar"></div>' +
|
|
3461
|
-
'<button class="file-preview-close" title="关闭 (Esc)" aria-label="关闭"
|
|
3543
|
+
'<button class="file-preview-close" title="关闭 (Esc)" aria-label="关闭">' + wandFileIcon("x", { size: 18 }) + '</button>' +
|
|
3462
3544
|
'</div>' +
|
|
3463
3545
|
'<div class="file-preview-body">' +
|
|
3464
3546
|
'<div class="file-preview-loading">加载预览…</div>' +
|
|
@@ -3468,6 +3550,25 @@
|
|
|
3468
3550
|
|
|
3469
3551
|
var closeBtn = overlay.querySelector(".file-preview-close");
|
|
3470
3552
|
var closeModal = function() {
|
|
3553
|
+
// Guard: warn before discarding unsaved edits.
|
|
3554
|
+
if (_activeFilePreview && _activeFilePreview.dirty) {
|
|
3555
|
+
if (typeof openWandDialog === "function") {
|
|
3556
|
+
openWandDialog({
|
|
3557
|
+
type: "warning",
|
|
3558
|
+
title: "放弃未保存的修改?",
|
|
3559
|
+
message: "当前文件有未保存的改动,关闭后会丢失。",
|
|
3560
|
+
buttons: [
|
|
3561
|
+
{ label: "继续编辑", value: false, kind: "ghost" },
|
|
3562
|
+
{ label: "放弃修改", value: true, kind: "danger", autofocus: true },
|
|
3563
|
+
],
|
|
3564
|
+
cancelValue: false,
|
|
3565
|
+
}).then(function(go) { if (go) doClose(); });
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
doClose();
|
|
3570
|
+
};
|
|
3571
|
+
var doClose = function() {
|
|
3471
3572
|
overlay.remove();
|
|
3472
3573
|
document.removeEventListener("keydown", keyHandler);
|
|
3473
3574
|
_activeFilePreview = null;
|
|
@@ -3477,21 +3578,49 @@
|
|
|
3477
3578
|
if (e.target === overlay) closeModal();
|
|
3478
3579
|
});
|
|
3479
3580
|
var keyHandler = function(e) {
|
|
3480
|
-
|
|
3581
|
+
// Ctrl/Cmd+S to save in edit mode.
|
|
3582
|
+
if ((e.key === "s" || e.key === "S") && (e.ctrlKey || e.metaKey)) {
|
|
3583
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3584
|
+
e.preventDefault();
|
|
3585
|
+
saveFileEdit();
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
if (e.key === "Escape") {
|
|
3590
|
+
// Inside edit mode, Esc exits edit instead of closing the modal.
|
|
3591
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3592
|
+
e.preventDefault();
|
|
3593
|
+
exitFileEdit();
|
|
3594
|
+
return;
|
|
3595
|
+
}
|
|
3596
|
+
closeModal();
|
|
3597
|
+
return;
|
|
3598
|
+
}
|
|
3481
3599
|
if (!_activeFilePreview) return;
|
|
3600
|
+
// Don't intercept arrow keys while typing.
|
|
3482
3601
|
if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
|
|
3602
|
+
if (_activeFilePreview.editing) return;
|
|
3483
3603
|
if (e.key === "ArrowLeft") { e.preventDefault(); navigatePreviewSibling(-1); }
|
|
3484
3604
|
else if (e.key === "ArrowRight") { e.preventDefault(); navigatePreviewSibling(1); }
|
|
3485
3605
|
};
|
|
3486
3606
|
document.addEventListener("keydown", keyHandler);
|
|
3487
3607
|
|
|
3488
|
-
_activeFilePreview = { overlay: overlay, close: closeModal, path: filePath, data: null };
|
|
3608
|
+
_activeFilePreview = { overlay: overlay, close: closeModal, path: filePath, data: null, editing: false, dirty: false };
|
|
3489
3609
|
} else {
|
|
3490
3610
|
_activeFilePreview.path = filePath;
|
|
3611
|
+
_activeFilePreview.editing = false;
|
|
3612
|
+
_activeFilePreview.dirty = false;
|
|
3491
3613
|
// Reset header / body for the new file.
|
|
3492
3614
|
var titleEl = overlay.querySelector(".file-preview-title");
|
|
3493
3615
|
if (titleEl) {
|
|
3494
|
-
titleEl.innerHTML =
|
|
3616
|
+
titleEl.innerHTML =
|
|
3617
|
+
'<span class="file-preview-icon">📄</span>' +
|
|
3618
|
+
'<div class="file-preview-name-block">' +
|
|
3619
|
+
'<div class="file-preview-name-row">' +
|
|
3620
|
+
'<span class="file-preview-filename">加载中…</span>' +
|
|
3621
|
+
'</div>' +
|
|
3622
|
+
'<span class="file-preview-path" title=""></span>' +
|
|
3623
|
+
'</div>';
|
|
3495
3624
|
}
|
|
3496
3625
|
var toolbarEl = overlay.querySelector(".file-preview-toolbar");
|
|
3497
3626
|
if (toolbarEl) toolbarEl.innerHTML = "";
|
|
@@ -3681,16 +3810,31 @@
|
|
|
3681
3810
|
var bar = overlay.querySelector(".file-preview-toolbar");
|
|
3682
3811
|
if (!bar) return;
|
|
3683
3812
|
bar.innerHTML = "";
|
|
3813
|
+
bar.classList.remove("editing");
|
|
3814
|
+
|
|
3815
|
+
// ── Edit mode renders its own dedicated toolbar (save / revert / cancel). ──
|
|
3816
|
+
if (_activeFilePreview && _activeFilePreview.editing) {
|
|
3817
|
+
bar.classList.add("editing");
|
|
3818
|
+
renderEditToolbar(overlay, data);
|
|
3819
|
+
return;
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3684
3822
|
var buttons = [];
|
|
3685
3823
|
|
|
3824
|
+
if (data.kind === "text") {
|
|
3825
|
+
buttons.push({ label: "编辑文件 (E)", icon: wandFileIcon("edit"), primary: true, action: function() {
|
|
3826
|
+
enterFileEdit();
|
|
3827
|
+
}});
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3686
3830
|
// Common actions across all kinds
|
|
3687
|
-
buttons.push({ label: "复制路径", icon: "
|
|
3831
|
+
buttons.push({ label: "复制路径", icon: wandFileIcon("clipboard"), action: function() {
|
|
3688
3832
|
copyTextSafely(data.path).then(function() { showToastIfPossible("已复制路径"); });
|
|
3689
3833
|
}});
|
|
3690
|
-
buttons.push({ label: "粘贴到输入框", icon: "
|
|
3834
|
+
buttons.push({ label: "粘贴到输入框", icon: wandFileIcon("send-to-input"), action: function() {
|
|
3691
3835
|
if (appendToComposer(data.path)) showToastIfPossible("已粘贴到输入框");
|
|
3692
3836
|
}});
|
|
3693
|
-
buttons.push({ label: "下载", icon: "
|
|
3837
|
+
buttons.push({ label: "下载", icon: wandFileIcon("download"), action: function() {
|
|
3694
3838
|
var a = document.createElement("a");
|
|
3695
3839
|
a.href = "/api/file-raw?download=1&path=" + encodeURIComponent(data.path);
|
|
3696
3840
|
a.download = data.name || "";
|
|
@@ -3700,10 +3844,10 @@
|
|
|
3700
3844
|
}});
|
|
3701
3845
|
|
|
3702
3846
|
if (data.kind === "text") {
|
|
3703
|
-
buttons.push({ label: "
|
|
3847
|
+
buttons.push({ label: "复制全部内容", icon: wandFileIcon("copy"), action: function() {
|
|
3704
3848
|
copyTextSafely(data.content || "").then(function() { showToastIfPossible("已复制内容"); });
|
|
3705
3849
|
}});
|
|
3706
|
-
buttons.push({ label: "
|
|
3850
|
+
buttons.push({ label: "切换自动换行", icon: wandFileIcon("wrap-text"), toggleClass: "toolbar-active",
|
|
3707
3851
|
getInitial: function() {
|
|
3708
3852
|
var pre = overlay.querySelector(".code-preview-content pre");
|
|
3709
3853
|
return pre && pre.classList.contains("wrap");
|
|
@@ -3715,26 +3859,235 @@
|
|
|
3715
3859
|
btn.classList.toggle("toolbar-active", pre.classList.contains("wrap"));
|
|
3716
3860
|
}
|
|
3717
3861
|
});
|
|
3718
|
-
|
|
3719
|
-
buttons.push({
|
|
3862
|
+
// Font-size adjustments — render as a single grouped chip with two halves.
|
|
3863
|
+
buttons.push({ kind: "group", className: "toolbar-group-fontsize",
|
|
3864
|
+
children: [
|
|
3865
|
+
{ label: "缩小字号", icon: wandFileIcon("minus"), action: function() { adjustPreviewFontSize(overlay, -1); }},
|
|
3866
|
+
{ kind: "label", icon: wandFileIcon("type"), label: "字号" },
|
|
3867
|
+
{ label: "放大字号", icon: wandFileIcon("plus"), action: function() { adjustPreviewFontSize(overlay, +1); }},
|
|
3868
|
+
],
|
|
3869
|
+
});
|
|
3720
3870
|
}
|
|
3721
3871
|
|
|
3872
|
+
renderToolbarButtons(bar, buttons, overlay);
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
// Render a flat list of toolbar buttons (with optional grouped chips).
|
|
3876
|
+
function renderToolbarButtons(bar, buttons, overlay) {
|
|
3722
3877
|
buttons.forEach(function(b) {
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3878
|
+
if (b.kind === "group") {
|
|
3879
|
+
var group = document.createElement("div");
|
|
3880
|
+
group.className = "file-preview-toolbar-group" + (b.className ? " " + b.className : "");
|
|
3881
|
+
b.children.forEach(function(child) {
|
|
3882
|
+
if (child.kind === "label") {
|
|
3883
|
+
var lab = document.createElement("span");
|
|
3884
|
+
lab.className = "file-preview-toolbar-grouplabel";
|
|
3885
|
+
lab.title = child.label || "";
|
|
3886
|
+
lab.innerHTML = child.icon || "";
|
|
3887
|
+
group.appendChild(lab);
|
|
3888
|
+
return;
|
|
3889
|
+
}
|
|
3890
|
+
group.appendChild(buildToolbarButton(child));
|
|
3891
|
+
});
|
|
3892
|
+
bar.appendChild(group);
|
|
3893
|
+
return;
|
|
3894
|
+
}
|
|
3895
|
+
bar.appendChild(buildToolbarButton(b));
|
|
3896
|
+
});
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
function buildToolbarButton(b) {
|
|
3900
|
+
var btn = document.createElement("button");
|
|
3901
|
+
btn.type = "button";
|
|
3902
|
+
btn.className = "file-preview-toolbar-btn";
|
|
3903
|
+
if (b.primary) btn.classList.add("primary");
|
|
3904
|
+
if (b.danger) btn.classList.add("danger");
|
|
3905
|
+
btn.title = b.label;
|
|
3906
|
+
btn.setAttribute("aria-label", b.label);
|
|
3907
|
+
btn.innerHTML = '<span class="toolbar-icon">' + (b.icon || "") + '</span>' +
|
|
3908
|
+
(b.text ? '<span class="toolbar-text">' + escapeHtml(b.text) + '</span>' : '');
|
|
3909
|
+
if (b.getInitial && b.getInitial()) btn.classList.add("toolbar-active");
|
|
3910
|
+
btn.addEventListener("click", function(ev) {
|
|
3911
|
+
ev.stopPropagation();
|
|
3912
|
+
if (typeof b.action === "function") b.action(btn);
|
|
3913
|
+
});
|
|
3914
|
+
return btn;
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
// ── Edit mode ──
|
|
3918
|
+
function renderEditToolbar(overlay, data) {
|
|
3919
|
+
var bar = overlay.querySelector(".file-preview-toolbar");
|
|
3920
|
+
if (!bar) return;
|
|
3921
|
+
bar.innerHTML = "";
|
|
3922
|
+
var saving = _activeFilePreview && _activeFilePreview.saving;
|
|
3923
|
+
var buttons = [
|
|
3924
|
+
{ label: "保存 (Ctrl+S)", icon: wandFileIcon("save"), text: "保存", primary: true,
|
|
3925
|
+
action: function() { saveFileEdit(); } },
|
|
3926
|
+
{ label: "撤销改动", icon: wandFileIcon("rotate-ccw"),
|
|
3927
|
+
action: function() { revertFileEdit(); } },
|
|
3928
|
+
{ label: "退出编辑 (Esc)", icon: wandFileIcon("x"),
|
|
3929
|
+
action: function() { exitFileEdit(); } },
|
|
3930
|
+
];
|
|
3931
|
+
renderToolbarButtons(bar, buttons, overlay);
|
|
3932
|
+
if (saving) {
|
|
3933
|
+
bar.querySelectorAll(".file-preview-toolbar-btn").forEach(function(b) { b.disabled = true; });
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
function enterFileEdit() {
|
|
3938
|
+
if (!_activeFilePreview || !_activeFilePreview.data) return;
|
|
3939
|
+
var data = _activeFilePreview.data;
|
|
3940
|
+
if (data.kind !== "text") return;
|
|
3941
|
+
_activeFilePreview.editing = true;
|
|
3942
|
+
_activeFilePreview.dirty = false;
|
|
3943
|
+
_activeFilePreview.originalContent = data.content || "";
|
|
3944
|
+
var overlay = _activeFilePreview.overlay;
|
|
3945
|
+
var body = overlay.querySelector(".file-preview-body");
|
|
3946
|
+
if (!body) return;
|
|
3947
|
+
body.classList.add("editing");
|
|
3948
|
+
body.innerHTML =
|
|
3949
|
+
'<div class="code-editor-wrapper">' +
|
|
3950
|
+
'<textarea class="code-editor-textarea" spellcheck="false" autocomplete="off"' +
|
|
3951
|
+
' autocorrect="off" autocapitalize="off" wrap="off"></textarea>' +
|
|
3952
|
+
'</div>';
|
|
3953
|
+
var ta = body.querySelector(".code-editor-textarea");
|
|
3954
|
+
if (ta) {
|
|
3955
|
+
ta.value = data.content || "";
|
|
3956
|
+
ta.addEventListener("input", function() {
|
|
3957
|
+
var dirty = ta.value !== (_activeFilePreview.originalContent || "");
|
|
3958
|
+
if (dirty !== _activeFilePreview.dirty) {
|
|
3959
|
+
_activeFilePreview.dirty = dirty;
|
|
3960
|
+
updateDirtyBadge();
|
|
3961
|
+
}
|
|
3733
3962
|
});
|
|
3734
|
-
|
|
3963
|
+
// Tab key inserts spaces (2-space indent) instead of moving focus.
|
|
3964
|
+
ta.addEventListener("keydown", function(e) {
|
|
3965
|
+
if (e.key === "Tab") {
|
|
3966
|
+
e.preventDefault();
|
|
3967
|
+
var start = ta.selectionStart, end = ta.selectionEnd;
|
|
3968
|
+
var indent = " ";
|
|
3969
|
+
ta.value = ta.value.slice(0, start) + indent + ta.value.slice(end);
|
|
3970
|
+
ta.selectionStart = ta.selectionEnd = start + indent.length;
|
|
3971
|
+
ta.dispatchEvent(new Event("input"));
|
|
3972
|
+
}
|
|
3973
|
+
});
|
|
3974
|
+
// Focus and place caret at start so user sees the top of the file.
|
|
3975
|
+
setTimeout(function() {
|
|
3976
|
+
ta.focus();
|
|
3977
|
+
ta.setSelectionRange(0, 0);
|
|
3978
|
+
ta.scrollTop = 0;
|
|
3979
|
+
}, 30);
|
|
3980
|
+
}
|
|
3981
|
+
renderPreviewToolbar(overlay, data);
|
|
3982
|
+
updateDirtyBadge();
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
function exitFileEdit() {
|
|
3986
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
3987
|
+
var doExit = function() {
|
|
3988
|
+
_activeFilePreview.editing = false;
|
|
3989
|
+
_activeFilePreview.dirty = false;
|
|
3990
|
+
var overlay = _activeFilePreview.overlay;
|
|
3991
|
+
var body = overlay.querySelector(".file-preview-body");
|
|
3992
|
+
if (body) body.classList.remove("editing");
|
|
3993
|
+
// Re-render preview from latest data.
|
|
3994
|
+
renderPreviewContent(overlay, _activeFilePreview.data);
|
|
3995
|
+
updateDirtyBadge();
|
|
3996
|
+
};
|
|
3997
|
+
if (_activeFilePreview.dirty && typeof openWandDialog === "function") {
|
|
3998
|
+
openWandDialog({
|
|
3999
|
+
type: "warning",
|
|
4000
|
+
title: "放弃未保存的修改?",
|
|
4001
|
+
message: "当前文件有未保存的改动,退出编辑后会丢失。",
|
|
4002
|
+
buttons: [
|
|
4003
|
+
{ label: "继续编辑", value: false, kind: "ghost" },
|
|
4004
|
+
{ label: "放弃修改", value: true, kind: "danger", autofocus: true },
|
|
4005
|
+
],
|
|
4006
|
+
cancelValue: false,
|
|
4007
|
+
}).then(function(go) { if (go) doExit(); });
|
|
4008
|
+
return;
|
|
4009
|
+
}
|
|
4010
|
+
doExit();
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
function revertFileEdit() {
|
|
4014
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
4015
|
+
var overlay = _activeFilePreview.overlay;
|
|
4016
|
+
var ta = overlay.querySelector(".code-editor-textarea");
|
|
4017
|
+
if (!ta) return;
|
|
4018
|
+
ta.value = _activeFilePreview.originalContent || "";
|
|
4019
|
+
_activeFilePreview.dirty = false;
|
|
4020
|
+
updateDirtyBadge();
|
|
4021
|
+
ta.focus();
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
function saveFileEdit() {
|
|
4025
|
+
if (!_activeFilePreview || !_activeFilePreview.editing) return;
|
|
4026
|
+
if (_activeFilePreview.saving) return;
|
|
4027
|
+
var overlay = _activeFilePreview.overlay;
|
|
4028
|
+
var ta = overlay.querySelector(".code-editor-textarea");
|
|
4029
|
+
if (!ta) return;
|
|
4030
|
+
var newContent = ta.value;
|
|
4031
|
+
if (newContent === (_activeFilePreview.originalContent || "")) {
|
|
4032
|
+
showToastIfPossible("没有改动");
|
|
4033
|
+
return;
|
|
4034
|
+
}
|
|
4035
|
+
_activeFilePreview.saving = true;
|
|
4036
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4037
|
+
fetch("/api/file-write", {
|
|
4038
|
+
method: "POST",
|
|
4039
|
+
credentials: "same-origin",
|
|
4040
|
+
headers: { "Content-Type": "application/json" },
|
|
4041
|
+
body: JSON.stringify({ path: _activeFilePreview.path, content: newContent }),
|
|
4042
|
+
}).then(function(res) {
|
|
4043
|
+
return res.json().then(function(json) { return { ok: res.ok, status: res.status, data: json }; });
|
|
4044
|
+
}).then(function(result) {
|
|
4045
|
+
_activeFilePreview.saving = false;
|
|
4046
|
+
if (!result.ok || (result.data && result.data.error)) {
|
|
4047
|
+
var msg = (result.data && result.data.error) || ("保存失败 (" + result.status + ")");
|
|
4048
|
+
showToastIfPossible(msg);
|
|
4049
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
// Sync local cache so revert points at the new baseline.
|
|
4053
|
+
_activeFilePreview.data.content = newContent;
|
|
4054
|
+
_activeFilePreview.data.size = (result.data && result.data.size) || newContent.length;
|
|
4055
|
+
_activeFilePreview.originalContent = newContent;
|
|
4056
|
+
_activeFilePreview.dirty = false;
|
|
4057
|
+
showToastIfPossible("已保存");
|
|
4058
|
+
updateDirtyBadge();
|
|
4059
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
4060
|
+
// Quietly refresh the file tree so size/git-status update.
|
|
4061
|
+
if (typeof refreshFileExplorer === "function") {
|
|
4062
|
+
try { refreshFileExplorer(); } catch (e) {}
|
|
4063
|
+
}
|
|
4064
|
+
}).catch(function(err) {
|
|
4065
|
+
_activeFilePreview.saving = false;
|
|
4066
|
+
showToastIfPossible("保存失败:" + (err && err.message ? err.message : "网络错误"));
|
|
4067
|
+
renderEditToolbar(overlay, _activeFilePreview.data);
|
|
3735
4068
|
});
|
|
3736
4069
|
}
|
|
3737
4070
|
|
|
4071
|
+
function updateDirtyBadge() {
|
|
4072
|
+
if (!_activeFilePreview) return;
|
|
4073
|
+
var overlay = _activeFilePreview.overlay;
|
|
4074
|
+
if (!overlay) return;
|
|
4075
|
+
var row = overlay.querySelector(".file-preview-name-row");
|
|
4076
|
+
if (!row) return;
|
|
4077
|
+
var existing = row.querySelector(".file-preview-dirty");
|
|
4078
|
+
if (_activeFilePreview.dirty) {
|
|
4079
|
+
if (!existing) {
|
|
4080
|
+
var dot = document.createElement("span");
|
|
4081
|
+
dot.className = "file-preview-dirty";
|
|
4082
|
+
dot.title = "有未保存的修改";
|
|
4083
|
+
dot.textContent = "● 未保存";
|
|
4084
|
+
row.appendChild(dot);
|
|
4085
|
+
}
|
|
4086
|
+
} else if (existing) {
|
|
4087
|
+
existing.remove();
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
|
|
3738
4091
|
function adjustPreviewFontSize(overlay, delta) {
|
|
3739
4092
|
var pre = overlay.querySelector(".code-preview-content pre");
|
|
3740
4093
|
var nums = overlay.querySelector(".code-preview-lines");
|
|
@@ -12508,7 +12861,27 @@
|
|
|
12508
12861
|
// Keyboard just closed — force terminal refit and scroll to bottom
|
|
12509
12862
|
// after a delay so the keyboard dismiss animation and layout settle.
|
|
12510
12863
|
if (keyboardOpen && !isKeyboardOpen) {
|
|
12864
|
+
// iOS Safari quirk: 用户按系统 Done / 下滑收起键盘 / 应用切换回来时,
|
|
12865
|
+
// 经常不会触发 textarea 的 blur 事件,导致 handleInputBoxBlur 里的
|
|
12866
|
+
// window.scrollTo(0,0) 不跑,页面停在键盘抬起时被 iOS 推上去的
|
|
12867
|
+
// 偏移位置,input-panel 看起来"没回到底"。
|
|
12868
|
+
// 这里在 visualViewport 检测到键盘收起的瞬间直接强制复位一次,
|
|
12869
|
+
// 并把 --app-viewport-height 兜底清掉,确保 .app-container 回到
|
|
12870
|
+
// 100dvh、input-panel 重新贴屏幕底部。
|
|
12871
|
+
var rootEl = document.documentElement;
|
|
12872
|
+
rootEl.style.removeProperty('--app-viewport-height');
|
|
12873
|
+
window.scrollTo(0, 0);
|
|
12874
|
+
if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
|
|
12875
|
+
rootEl.scrollTop = 0;
|
|
12876
|
+
if (document.body) document.body.scrollTop = 0;
|
|
12511
12877
|
setTimeout(function() {
|
|
12878
|
+
// 二次复位:等键盘收起动画 + iOS 自身的回滚跑完后再清一次,
|
|
12879
|
+
// 防止 iOS 在动画过程中又把 scrollTop 推上去。
|
|
12880
|
+
window.scrollTo(0, 0);
|
|
12881
|
+
if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
|
|
12882
|
+
rootEl.scrollTop = 0;
|
|
12883
|
+
if (document.body) document.body.scrollTop = 0;
|
|
12884
|
+
syncAppViewportHeight();
|
|
12512
12885
|
ensureTerminalFit("keyboard-close", { forceReplay: true });
|
|
12513
12886
|
maybeScrollTerminalToBottom("force");
|
|
12514
12887
|
}, 200);
|
|
@@ -14317,11 +14690,12 @@
|
|
|
14317
14690
|
var task = document.getElementById("todo-progress-task");
|
|
14318
14691
|
if (task) task.textContent = activeTask;
|
|
14319
14692
|
|
|
14320
|
-
//
|
|
14321
|
-
//
|
|
14693
|
+
// Keep the ring aligned with the counter text: both reflect the 1-indexed
|
|
14694
|
+
// "current step" (completed + 1, capped at total). Otherwise the ring lags
|
|
14695
|
+
// one step behind whenever a task is in_progress (e.g. text "6/6" but ring 5/6).
|
|
14322
14696
|
var ring = document.getElementById("todo-progress-ring");
|
|
14323
14697
|
if (ring) {
|
|
14324
|
-
var ratio = todos.length > 0 ?
|
|
14698
|
+
var ratio = todos.length > 0 ? currentStep / todos.length : 0;
|
|
14325
14699
|
ring.style.setProperty("--progress", ratio.toFixed(3));
|
|
14326
14700
|
}
|
|
14327
14701
|
|