@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.
@@ -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
- '<span class="file-side-panel-title">文件</span>' +
1415
- '<button id="file-side-panel-close" class="btn btn-ghost btn-sm" type="button" aria-label="关闭">×</button>' +
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="返回上级目录">⬆</button>' +
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
- '<input type="text" id="file-search-input" class="file-search-input" placeholder="搜索文件..." autocomplete="off" />' +
1426
- '<button class="file-search-clear" id="file-search-clear" type="button" aria-label="清除搜索">×</button>' +
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
- '<input type="file" id="file-upload-input" multiple style="position:absolute;width:1px;height:1px;opacity:0;overflow:hidden;clip:rect(0,0,0,0);pointer-events:none">' +
1512
- '<select id="chat-mode-select" class="chat-mode-select" title="仅对新建会话生效">' +
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.textContent = state.fileExplorerShowHidden ? "👁" : "👁‍🗨";
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
- '<span class="file-preview-filename">加载中…</span>' +
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="关闭">✕</button>' +
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
- if (e.key === "Escape") { closeModal(); return; }
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 = '<span class="file-preview-icon">📄</span><span class="file-preview-filename">加载中…</span>';
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: "📋", action: function() {
3831
+ buttons.push({ label: "复制路径", icon: wandFileIcon("clipboard"), action: function() {
3688
3832
  copyTextSafely(data.path).then(function() { showToastIfPossible("已复制路径"); });
3689
3833
  }});
3690
- buttons.push({ label: "粘贴到输入框", icon: "✏️", action: function() {
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: "", action: function() {
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: "复制全部", icon: "📄", action: function() {
3847
+ buttons.push({ label: "复制全部内容", icon: wandFileIcon("copy"), action: function() {
3704
3848
  copyTextSafely(data.content || "").then(function() { showToastIfPossible("已复制内容"); });
3705
3849
  }});
3706
- buttons.push({ label: "自动换行", icon: "", toggleClass: "toolbar-active",
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
- buttons.push({ label: "字号 −", icon: "A−", action: function() { adjustPreviewFontSize(overlay, -1); }});
3719
- buttons.push({ label: "字号 +", icon: "A+", action: function() { adjustPreviewFontSize(overlay, +1); }});
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
- var btn = document.createElement("button");
3724
- btn.type = "button";
3725
- btn.className = "file-preview-toolbar-btn";
3726
- btn.title = b.label;
3727
- btn.setAttribute("aria-label", b.label);
3728
- btn.innerHTML = '<span class="toolbar-icon">' + b.icon + '</span>';
3729
- if (b.getInitial && b.getInitial()) btn.classList.add("toolbar-active");
3730
- btn.addEventListener("click", function(ev) {
3731
- ev.stopPropagation();
3732
- b.action(btn);
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
- bar.appendChild(btn);
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
- // Drive the circular progress ring with the honest "completed / total" fraction
14321
- // (counter text shows the 1-indexed current step, ring shows actual done ratio).
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 ? completed / 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