@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.
@@ -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
- '<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>' +
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="返回上级目录">⬆</button>' +
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
- '<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>' +
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
- '<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="仅对新建会话生效">' +
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.textContent = state.fileExplorerShowHidden ? "👁" : "👁‍🗨";
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
- '<span class="file-preview-filename">加载中…</span>' +
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="关闭">✕</button>' +
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
- if (e.key === "Escape") { closeModal(); return; }
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 = '<span class="file-preview-icon">📄</span><span class="file-preview-filename">加载中…</span>';
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: "📋", action: function() {
3817
+ buttons.push({ label: "复制路径", icon: wandFileIcon("clipboard"), action: function() {
3688
3818
  copyTextSafely(data.path).then(function() { showToastIfPossible("已复制路径"); });
3689
3819
  }});
3690
- buttons.push({ label: "粘贴到输入框", icon: "✏️", action: function() {
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: "", action: function() {
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: "复制全部", icon: "📄", action: function() {
3833
+ buttons.push({ label: "复制全部内容", icon: wandFileIcon("copy"), action: function() {
3704
3834
  copyTextSafely(data.content || "").then(function() { showToastIfPossible("已复制内容"); });
3705
3835
  }});
3706
- buttons.push({ label: "自动换行", icon: "", toggleClass: "toolbar-active",
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
- buttons.push({ label: "字号 −", icon: "A−", action: function() { adjustPreviewFontSize(overlay, -1); }});
3719
- buttons.push({ label: "字号 +", icon: "A+", action: function() { adjustPreviewFontSize(overlay, +1); }});
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
- 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);
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
- bar.appendChild(btn);
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
- // 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).
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 ? completed / 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