@co0ontty/wand 1.21.11 → 1.21.13

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.
@@ -215,6 +215,12 @@
215
215
  fileSearchQuery: "",
216
216
  fileExplorerLoading: false,
217
217
  allFiles: [],
218
+ fileExplorerCwd: "",
219
+ fileExplorerTruncated: false,
220
+ fileExplorerTotal: 0,
221
+ fileExplorerShowHidden: (function() {
222
+ try { return localStorage.getItem("wand-file-show-hidden") === "1"; } catch (e) { return false; }
223
+ })(),
218
224
  claudeHistory: [],
219
225
  claudeHistoryLoaded: false,
220
226
  claudeHistoryExpanded: true,
@@ -1346,16 +1352,7 @@
1346
1352
  '<div class="topbar-right">' +
1347
1353
  (selectedSession && selectedSession.cwd ? '<button id="topbar-file-button" class="topbar-btn square' + (state.filePanelOpen ? ' active' : '') + '" type="button" aria-label="文件" title="文件"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>' : '') +
1348
1354
  '<span id="topbar-git-slot" class="topbar-git-slot">' + renderTopbarGitBadgeHtml() + '</span>' +
1349
- '<div class="topbar-more-wrap">' +
1350
- '<button id="topbar-more-button" class="topbar-btn square' + (state.topbarMoreOpen ? ' active' : '') + '" type="button" aria-label="更多" aria-haspopup="menu" aria-expanded="' + (state.topbarMoreOpen ? 'true' : 'false') + '" title="更多"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>' +
1351
- '<div id="topbar-more-menu" class="topbar-more-menu' + (state.topbarMoreOpen ? '' : ' hidden') + '" role="menu">' +
1352
- '<button class="topbar-more-item" data-action="settings" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span>设置</span></button>' +
1353
- '<button class="topbar-more-item" data-action="refresh" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg><span>刷新</span></button>' +
1354
- '<button class="topbar-more-item' + (state.showInstallPrompt && state.deferredPrompt ? '' : ' hidden') + '" id="topbar-install-item" data-action="install" type="button" role="menuitem"><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 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>安装应用</span></button>' +
1355
- (hasNativeSwitchServer() ? '<button class="topbar-more-item" data-action="switch-server" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg><span>切换服务器</span></button>' : '') +
1356
- '<button class="topbar-more-item topbar-more-item-danger" data-action="logout" type="button" role="menuitem"><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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg><span>退出</span></button>' +
1357
- '</div>' +
1358
- '</div>' +
1355
+ (selectedSession ? renderTopbarMoreMenuHtml(selectedSession) : '') +
1359
1356
  '</div>' +
1360
1357
  '</div>' +
1361
1358
  // File panel backdrop (mobile)
@@ -1368,7 +1365,9 @@
1368
1365
  '</div>' +
1369
1366
  '<div class="file-side-panel-body">' +
1370
1367
  '<div class="file-explorer-header">' +
1371
- '<span class="file-explorer-path" id="file-explorer-cwd">' + escapeHtml(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '</span>' +
1368
+ '<button class="file-explorer-up" id="file-explorer-up" type="button" title="返回上级目录" aria-label="返回上级目录">⬆</button>' +
1369
+ '<span class="file-explorer-path" id="file-explorer-cwd" title="' + escapeHtml(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '">' + escapeHtml(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '</span>' +
1370
+ '<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>' +
1372
1371
  '<button class="file-explorer-refresh" id="file-explorer-refresh" title="刷新" aria-label="刷新文件列表">↻</button>' +
1373
1372
  '</div>' +
1374
1373
  '<div class="file-search-box">' +
@@ -1549,6 +1548,60 @@
1549
1548
  }
1550
1549
  }
1551
1550
 
1551
+ /**
1552
+ * Render the topbar three-dot menu. Items are scoped to the currently
1553
+ * selected session — global actions (settings/install/switch-server/
1554
+ * logout) live in the sidebar footer, so we don't duplicate them here.
1555
+ */
1556
+ function renderTopbarMoreMenuHtml(session) {
1557
+ if (!session) return "";
1558
+ var open = state.topbarMoreOpen;
1559
+ var hasClaudeId = !!session.claudeSessionId;
1560
+ var hasCwd = !!session.cwd;
1561
+ var canOpenMerge = session.worktreeEnabled && session.worktree && session.worktree.branch && session.worktree.path;
1562
+ var needsCleanup = session.worktreeMergeStatus === "merged" && session.worktreeMergeInfo && session.worktreeMergeInfo.cleanupDone === false;
1563
+ var mergeDisabled = session.status === "running" || session.worktreeMergeStatus === "merging";
1564
+ var showMerge = canOpenMerge && session.worktreeMergeStatus !== "merged";
1565
+ var showCleanup = needsCleanup;
1566
+ var hasInfoGroup = hasClaudeId || hasCwd || true; // session-id button always renders
1567
+ var hasActionGroup = showMerge || showCleanup || true; // delete button always renders
1568
+
1569
+ var copyIconSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
1570
+ var cloudIconSvg = '<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="M17.5 19a4.5 4.5 0 1 0-1.5-8.74A6 6 0 1 0 6 14h11.5z"/></svg>';
1571
+ var folderIconSvg = '<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
1572
+ var hashIconSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>';
1573
+ var mergeIconSvg = '<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="M7 7h10"/><path d="M7 12h10"/><path d="M7 17h10"/><path d="M5 7l-2 2 2 2"/><path d="M19 15l2 2-2 2"/></svg>';
1574
+ var trashIconSvg = '<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="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg>';
1575
+
1576
+ var infoItems = "";
1577
+ if (hasClaudeId) {
1578
+ infoItems += '<button class="topbar-more-item" data-action="copy-claude-session-id" type="button" role="menuitem">' + cloudIconSvg + '<span>复制 Claude 会话 ID</span></button>';
1579
+ }
1580
+ if (hasCwd) {
1581
+ infoItems += '<button class="topbar-more-item" data-action="copy-cwd" type="button" role="menuitem">' + folderIconSvg + '<span>复制工作目录</span></button>';
1582
+ }
1583
+ infoItems += '<button class="topbar-more-item" data-action="copy-session-id" type="button" role="menuitem">' + hashIconSvg + '<span>复制会话 ID</span></button>';
1584
+
1585
+ var actionItems = "";
1586
+ if (showMerge) {
1587
+ actionItems += '<button class="topbar-more-item" data-action="worktree-merge" type="button" role="menuitem"' + (mergeDisabled ? ' disabled' : '') + '>' + mergeIconSvg + '<span>合并到主分支…</span></button>';
1588
+ } else if (showCleanup) {
1589
+ actionItems += '<button class="topbar-more-item" data-action="worktree-cleanup" type="button" role="menuitem">' + mergeIconSvg + '<span>重试 worktree 清理</span></button>';
1590
+ }
1591
+ actionItems += '<button class="topbar-more-item topbar-more-item-danger" data-action="delete-session" type="button" role="menuitem">' + trashIconSvg + '<span>删除当前会话</span></button>';
1592
+
1593
+ var divider = (hasInfoGroup && hasActionGroup) ? '<div class="topbar-more-divider" role="separator"></div>' : '';
1594
+
1595
+ return '<div class="topbar-more-wrap">' +
1596
+ '<button id="topbar-more-button" class="topbar-btn square' + (open ? ' active' : '') + '" type="button" aria-label="当前会话操作" aria-haspopup="menu" aria-expanded="' + (open ? 'true' : 'false') + '" title="当前会话操作"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>' +
1597
+ '<div id="topbar-more-menu" class="topbar-more-menu' + (open ? '' : ' hidden') + '" role="menu" aria-label="当前会话">' +
1598
+ infoItems +
1599
+ divider +
1600
+ actionItems +
1601
+ '</div>' +
1602
+ '</div>';
1603
+ }
1604
+
1552
1605
  function loadGitStatus(sessionId, options) {
1553
1606
  if (!sessionId) return Promise.resolve(null);
1554
1607
  var force = options && options.force;
@@ -2169,10 +2222,13 @@
2169
2222
  '<label class="settings-toggle-title" for="cfg-inherit-env">继承环境变量</label>' +
2170
2223
  '<span class="settings-toggle-desc">启动 PTY / 结构化子进程时,把当前服务进程的环境变量传给 claude / codex。关闭后子进程仅获得最小可用环境(PATH/HOME/SHELL/LANG/TERM 等),可用于隔离 API key 等敏感凭据。</span>' +
2171
2224
  '</div>' +
2172
- '<label class="settings-switch">' +
2173
- '<input id="cfg-inherit-env" type="checkbox" class="switch-toggle" />' +
2174
- '<span class="switch-slider"></span>' +
2175
- '</label>' +
2225
+ '<div class="settings-toggle-aside">' +
2226
+ '<button type="button" id="cfg-view-env-btn" class="btn btn-secondary btn-sm" title="查看实际会注入到子进程的环境变量">查看</button>' +
2227
+ '<label class="settings-switch">' +
2228
+ '<input id="cfg-inherit-env" type="checkbox" class="switch-toggle" />' +
2229
+ '<span class="switch-slider"></span>' +
2230
+ '</label>' +
2231
+ '</div>' +
2176
2232
  '</div>' +
2177
2233
  '<div class="field">' +
2178
2234
  '<label class="field-label" for="cfg-default-model">默认模型</label>' +
@@ -2849,118 +2905,205 @@
2849
2905
  function renderFileExplorer(cwd) {
2850
2906
  var root = cwd || getConfigCwd();
2851
2907
  if (!root) {
2852
- return '<div class="file-explorer empty">No working directory configured.</div>';
2908
+ return '<div class="file-explorer empty">未配置工作目录。</div>';
2853
2909
  }
2854
2910
  return '<div class="file-tree" id="file-tree" data-cwd="' + escapeHtml(root) + '">' +
2855
- '<div class="tree-loading">Loading...</div>' +
2911
+ '<div class="tree-loading">加载中…</div>' +
2856
2912
  '</div>';
2857
2913
  }
2858
2914
 
2859
- function refreshFileExplorer() {
2860
- var explorer = document.getElementById("file-explorer");
2861
- var cwdEl = document.getElementById("file-explorer-cwd");
2862
- if (!explorer) return;
2863
- // Get cwd from current session or config
2864
- var cwd = "";
2915
+ // ── File tree helpers ──
2916
+
2917
+ var FILE_ICON_MAP = {
2918
+ // images
2919
+ png: "🖼️", jpg: "🖼️", jpeg: "🖼️", gif: "🖼️", webp: "🖼️", svg: "🖼️",
2920
+ avif: "🖼️", bmp: "🖼️", ico: "🖼️", heic: "🖼️", heif: "🖼️",
2921
+ // pdf / doc
2922
+ pdf: "📕", doc: "📘", docx: "📘", odt: "📘",
2923
+ xls: "📊", xlsx: "📊", csv: "📊", tsv: "📊",
2924
+ ppt: "📙", pptx: "📙",
2925
+ // video / audio
2926
+ mp4: "🎬", webm: "🎬", mov: "🎬", mkv: "🎬", m4v: "🎬", ogv: "🎬",
2927
+ mp3: "🎵", wav: "🎵", ogg: "🎵", m4a: "🎵", flac: "🎵", aac: "🎵", opus: "🎵",
2928
+ // archives
2929
+ zip: "📦", tar: "📦", gz: "📦", tgz: "📦", bz2: "📦", "7z": "📦", rar: "📦", xz: "📦",
2930
+ // markup / docs
2931
+ md: "📝", markdown: "📝", mdx: "📝", rst: "📝", txt: "📝", log: "📝",
2932
+ // web / styles
2933
+ html: "🌐", htm: "🌐", xml: "🌐",
2934
+ css: "🎨", scss: "🎨", less: "🎨",
2935
+ // configs
2936
+ json: "⚙️", jsonc: "⚙️", yaml: "⚙️", yml: "⚙️", toml: "⚙️",
2937
+ ini: "⚙️", cfg: "⚙️", conf: "⚙️", env: "⚙️", editorconfig: "⚙️",
2938
+ // code (default 📜)
2939
+ ts: "📜", tsx: "📜", js: "📜", jsx: "📜", mjs: "📜", cjs: "📜",
2940
+ py: "📜", rb: "📜", go: "📜", rs: "📜", java: "📜", c: "📜", cpp: "📜",
2941
+ h: "📜", hpp: "📜", cs: "📜", swift: "📜", kt: "📜", scala: "📜",
2942
+ php: "📜", sh: "📜", bash: "📜", zsh: "📜", fish: "📜", lua: "📜",
2943
+ sql: "📜", graphql: "📜", proto: "📜", vue: "📜", svelte: "📜",
2944
+ diff: "📜", patch: "📜",
2945
+ // fonts / binary
2946
+ ttf: "🔤", otf: "🔤", woff: "🔤", woff2: "🔤", eot: "🔤",
2947
+ };
2948
+
2949
+ function getFileIcon(item) {
2950
+ if (!item) return "📄";
2951
+ if (item.type === "dir") return "📁";
2952
+ var name = (item.name || "").toLowerCase();
2953
+ // basename-only matches first
2954
+ if (name === "dockerfile") return "🐳";
2955
+ if (name === "makefile") return "🛠️";
2956
+ if (name === "license") return "📜";
2957
+ if (name === "readme") return "📝";
2958
+ var dot = name.lastIndexOf(".");
2959
+ if (dot < 0 || dot === name.length - 1) return "📄";
2960
+ var ext = name.slice(dot + 1);
2961
+ return FILE_ICON_MAP[ext] || "📄";
2962
+ }
2963
+
2964
+ function formatFileSize(bytes) {
2965
+ if (typeof bytes !== "number" || !isFinite(bytes) || bytes < 0) return "";
2966
+ if (bytes < 1024) return bytes + " B";
2967
+ var kb = bytes / 1024;
2968
+ if (kb < 1024) return (kb >= 10 ? Math.round(kb) : kb.toFixed(1)) + " KB";
2969
+ var mb = kb / 1024;
2970
+ if (mb < 1024) return (mb >= 10 ? Math.round(mb) : mb.toFixed(1)) + " MB";
2971
+ var gb = mb / 1024;
2972
+ return (gb >= 10 ? Math.round(gb) : gb.toFixed(1)) + " GB";
2973
+ }
2974
+
2975
+ function formatRelativeTime(iso) {
2976
+ if (!iso) return "";
2977
+ var t = Date.parse(iso);
2978
+ if (isNaN(t)) return "";
2979
+ return new Date(t).toLocaleString();
2980
+ }
2981
+
2982
+ function getEffectiveExplorerCwd() {
2983
+ if (state.fileExplorerCwd) return state.fileExplorerCwd;
2865
2984
  if (state.selectedId) {
2866
2985
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
2867
- if (session) cwd = session.cwd || "";
2868
- }
2869
- if (!cwd) {
2870
- cwd = getConfigCwd();
2986
+ if (session && session.cwd) return session.cwd;
2871
2987
  }
2988
+ return getConfigCwd();
2989
+ }
2990
+
2991
+ function refreshFileExplorer(opts) {
2992
+ opts = opts || {};
2993
+ var explorer = document.getElementById("file-explorer");
2994
+ var cwdEl = document.getElementById("file-explorer-cwd");
2995
+ if (!explorer) return;
2996
+ var cwd = opts.cwd || getEffectiveExplorerCwd();
2872
2997
  if (!cwd) {
2873
- explorer.innerHTML = '<div class="file-explorer empty">No working directory.</div>';
2998
+ explorer.innerHTML = '<div class="file-explorer empty">没有可显示的工作目录。</div>';
2874
2999
  return;
2875
3000
  }
3001
+ state.fileExplorerCwd = cwd;
2876
3002
  state.fileExplorerLoading = true;
2877
3003
  state.allFiles = [];
2878
- explorer.innerHTML = '<div class="file-explorer"><div class="tree-loading" style="padding:12px;color:var(--text-muted);font-size:0.8125rem;">Loading...</div></div>';
2879
- // Update the cwd display
2880
- if (cwdEl) cwdEl.textContent = cwd;
2881
- // Fetch with git status
2882
- fetch("/api/directory?q=" + encodeURIComponent(cwd) + "&gitStatus=true", { credentials: "same-origin" })
3004
+ state.fileExplorerTruncated = false;
3005
+ state.fileExplorerTotal = 0;
3006
+ explorer.innerHTML = '<div class="file-explorer"><div class="tree-loading" style="padding:12px;color:var(--text-muted);font-size:0.8125rem;">加载中…</div></div>';
3007
+ if (cwdEl) {
3008
+ cwdEl.textContent = cwd;
3009
+ cwdEl.title = cwd;
3010
+ }
3011
+ var url = "/api/directory?q=" + encodeURIComponent(cwd) +
3012
+ "&gitStatus=true" +
3013
+ (state.fileExplorerShowHidden ? "&showHidden=true" : "");
3014
+ fetch(url, { credentials: "same-origin" })
2883
3015
  .then(function(res) {
2884
- if (!res.ok) {
2885
- throw new Error("Failed to load directory.");
2886
- }
3016
+ if (!res.ok) throw new Error("Failed to load directory.");
2887
3017
  return res.json();
2888
3018
  })
2889
- .then(function(items) {
3019
+ .then(function(payload) {
2890
3020
  state.fileExplorerLoading = false;
3021
+ // Backend returns { items, truncated, total }; tolerate the legacy array shape too.
3022
+ var items, truncated, total;
3023
+ if (Array.isArray(payload)) {
3024
+ items = payload; truncated = false; total = payload.length;
3025
+ } else {
3026
+ items = (payload && payload.items) || [];
3027
+ truncated = !!(payload && payload.truncated);
3028
+ total = (payload && payload.total) || items.length;
3029
+ }
2891
3030
  if (!items || items.length === 0) {
2892
- explorer.innerHTML = '<div class="file-explorer empty">Empty directory or inaccessible.</div>';
3031
+ explorer.innerHTML = '<div class="file-explorer empty">空目录或无法访问。</div>';
2893
3032
  return;
2894
3033
  }
2895
3034
  state.allFiles = items;
3035
+ state.fileExplorerTruncated = truncated;
3036
+ state.fileExplorerTotal = total;
2896
3037
  filterFileTree();
2897
3038
  })
2898
3039
  .catch(function() {
2899
3040
  state.fileExplorerLoading = false;
2900
- explorer.innerHTML = '<div class="file-explorer empty">Failed to load files.</div>';
3041
+ explorer.innerHTML = '<div class="file-explorer empty">加载失败,请检查路径或权限。</div>';
2901
3042
  });
2902
3043
  }
2903
3044
 
2904
3045
  function filterFileTree() {
2905
3046
  var explorer = document.getElementById("file-explorer");
2906
- var cwdEl = document.getElementById("file-explorer-cwd");
2907
3047
  if (!explorer) return;
2908
- var cwd = cwdEl ? cwdEl.textContent : "";
3048
+ var cwd = state.fileExplorerCwd || "";
2909
3049
  if (!cwd) return;
2910
3050
 
2911
3051
  var query = state.fileSearchQuery;
2912
3052
  var items = state.allFiles || [];
3053
+ var filtered = items;
2913
3054
 
2914
- // 如果没有搜索词,显示所有文件
2915
- if (!query) {
2916
- explorer.innerHTML = '<div class="file-tree" id="file-tree" data-cwd="' + escapeHtml(cwd) + '">' +
2917
- items.map(function(item) {
2918
- return renderFileTreeItem(item);
2919
- }).join("") +
2920
- '</div>';
2921
- attachFileTreeListeners();
2922
- return;
3055
+ if (query) {
3056
+ var lowerQuery = query.toLowerCase();
3057
+ filtered = items.filter(function(item) {
3058
+ return item.name.toLowerCase().indexOf(lowerQuery) !== -1;
3059
+ });
2923
3060
  }
2924
3061
 
2925
- // 模糊匹配文件名(大小写不敏感)
2926
- var lowerQuery = query.toLowerCase();
2927
- var filtered = items.filter(function(item) {
2928
- return item.name.toLowerCase().indexOf(lowerQuery) !== -1;
2929
- });
2930
-
2931
3062
  if (filtered.length === 0) {
2932
- explorer.innerHTML = '<div class="file-explorer empty">没有找到匹配的文件</div>';
3063
+ explorer.innerHTML = '<div class="file-explorer empty">' + (query ? '没有找到匹配的文件' : '空目录') + '</div>';
2933
3064
  return;
2934
3065
  }
2935
3066
 
3067
+ var truncatedNotice = "";
3068
+ if (!query && state.fileExplorerTruncated) {
3069
+ var shown = items.length;
3070
+ truncatedNotice = '<div class="tree-truncated" title="后端按字母序最多返回 ' + shown + ' 项">显示前 ' + shown + ' 项 / 共 ' + state.fileExplorerTotal + ' 项</div>';
3071
+ }
3072
+
2936
3073
  explorer.innerHTML = '<div class="file-tree" id="file-tree" data-cwd="' + escapeHtml(cwd) + '">' +
2937
3074
  filtered.map(function(item) {
2938
3075
  return renderFileTreeItem(item);
2939
3076
  }).join("") +
2940
- '</div>';
3077
+ '</div>' + truncatedNotice;
2941
3078
  attachFileTreeListeners();
2942
3079
  }
2943
3080
 
2944
- function renderFileTreeItem(item) {
3081
+ function renderFileTreeItem(item, depth) {
3082
+ depth = depth || 0;
2945
3083
  var name = escapeHtml(item.name);
2946
3084
  var isDir = item.type === "dir";
2947
- // Use clear emoji icons: 📁 for folders, 📄 for files
2948
- var displayIcon = isDir ? "📁" : "📄";
3085
+ var displayIcon = getFileIcon(item);
2949
3086
  var toggleIcon = isDir ? "▸" : "";
2950
3087
  var toggleClass = isDir ? "" : " empty";
2951
3088
  var gitStatus = item.gitStatus;
2952
3089
  var statusBadge = renderGitStatusBadge(gitStatus);
2953
- return '<div class="tree-item" data-path="' + escapeHtml(item.path) + '" data-type="' + escapeHtml(item.type) + '">' +
3090
+ var meta = "";
3091
+ if (!isDir && typeof item.size === "number") {
3092
+ meta = '<span class="tree-meta" title="大小:' + escapeHtml(formatFileSize(item.size)) +
3093
+ (item.mtime ? '\n修改时间:' + escapeHtml(formatRelativeTime(item.mtime)) : '') +
3094
+ '">' + escapeHtml(formatFileSize(item.size)) + '</span>';
3095
+ }
3096
+ return '<div class="tree-item" data-path="' + escapeHtml(item.path) + '" data-type="' + escapeHtml(item.type) + '" data-name="' + escapeHtml(item.name) + '" tabindex="0">' +
2954
3097
  '<span class="tree-toggle' + toggleClass + '">' + toggleIcon + '</span>' +
2955
3098
  '<span class="tree-icon">' + displayIcon + '</span>' +
2956
3099
  '<span class="tree-name">' + name + '</span>' +
3100
+ meta +
2957
3101
  (statusBadge ? '<span class="git-status-badge ' + statusBadge.class + '" title="' + statusBadge.title + '">' + statusBadge.text + '</span>' : '') +
2958
3102
  '</div>';
2959
3103
  }
2960
3104
 
2961
3105
  function renderGitStatusBadge(gitStatus) {
2962
3106
  if (!gitStatus) return null;
2963
- // Priority: staged > unstaged > untracked
2964
3107
  if (gitStatus.staged === "added") return { text: "A", class: "git-added", title: "已暂存(新增)" };
2965
3108
  if (gitStatus.staged === "modified") return { text: "M", class: "git-modified", title: "已暂存(修改)" };
2966
3109
  if (gitStatus.staged === "deleted") return { text: "D", class: "git-deleted", title: "已暂存(删除)" };
@@ -2975,19 +3118,44 @@
2975
3118
  var tree = document.getElementById("file-tree");
2976
3119
  if (!tree) return;
2977
3120
  tree.querySelectorAll(".tree-item[data-type='dir']").forEach(function(item) {
2978
- item.addEventListener("click", function() {
3121
+ item.addEventListener("click", function(e) {
3122
+ // Don't toggle when click came from the meta/badge area
2979
3123
  toggleTreeNode(item);
2980
3124
  });
2981
3125
  });
2982
3126
  tree.querySelectorAll(".tree-item[data-type='file']").forEach(function(item) {
2983
- item.addEventListener("dblclick", function() {
2984
- openFilePreview(item.dataset.path);
3127
+ var openHandler = function() { openFilePreview(item.dataset.path); };
3128
+ item.addEventListener("click", openHandler);
3129
+ // Keep dblclick for old muscle memory; both work.
3130
+ item.addEventListener("dblclick", openHandler);
3131
+ });
3132
+ // Long-press / right-click context menu (path actions)
3133
+ var pressTimer = null;
3134
+ var pressFired = false;
3135
+ tree.querySelectorAll(".tree-item").forEach(function(item) {
3136
+ item.addEventListener("contextmenu", function(e) {
3137
+ e.preventDefault();
3138
+ showFileContextMenu(e.clientX, e.clientY, item);
3139
+ });
3140
+ item.addEventListener("touchstart", function(e) {
3141
+ pressFired = false;
3142
+ pressTimer = setTimeout(function() {
3143
+ pressFired = true;
3144
+ var t = e.touches && e.touches[0];
3145
+ showFileContextMenu(t ? t.clientX : 0, t ? t.clientY : 0, item);
3146
+ }, 500);
3147
+ }, { passive: true });
3148
+ item.addEventListener("touchend", function() {
3149
+ if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
3150
+ });
3151
+ item.addEventListener("touchmove", function() {
3152
+ if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
2985
3153
  });
2986
3154
  });
2987
3155
  }
2988
3156
 
2989
3157
  function toggleTreeNode(item) {
2990
- var path = item.dataset.path;
3158
+ var p = item.dataset.path;
2991
3159
  var toggle = item.querySelector(".tree-toggle");
2992
3160
  var children = item.nextElementSibling;
2993
3161
 
@@ -2995,14 +3163,24 @@
2995
3163
  var isOpen = children.classList.contains("open");
2996
3164
  children.classList.toggle("open");
2997
3165
  if (toggle) toggle.classList.toggle("open", !isOpen);
3166
+ // swap folder icon between 📁 and 📂
3167
+ var iconEl = item.querySelector(".tree-icon");
3168
+ if (iconEl) iconEl.textContent = isOpen ? "📁" : "📂";
2998
3169
  return;
2999
3170
  }
3000
3171
 
3001
- // Load children with git status
3002
3172
  if (toggle) toggle.classList.add("open");
3003
- fetch("/api/directory?q=" + encodeURIComponent(path) + "&gitStatus=true", { credentials: "same-origin" })
3173
+ var iconEl2 = item.querySelector(".tree-icon");
3174
+ if (iconEl2) iconEl2.textContent = "📂";
3175
+ var url = "/api/directory?q=" + encodeURIComponent(p) +
3176
+ "&gitStatus=true" +
3177
+ (state.fileExplorerShowHidden ? "&showHidden=true" : "");
3178
+ fetch(url, { credentials: "same-origin" })
3004
3179
  .then(function(res) { return res.json(); })
3005
- .then(function(items) {
3180
+ .then(function(payload) {
3181
+ var items;
3182
+ if (Array.isArray(payload)) items = payload;
3183
+ else items = (payload && payload.items) || [];
3006
3184
  var childrenDiv = document.createElement("div");
3007
3185
  childrenDiv.className = "tree-children open";
3008
3186
  if (!items || items.length === 0) {
@@ -3018,81 +3196,458 @@
3018
3196
  .catch(function() {});
3019
3197
  }
3020
3198
 
3199
+ // Walk up to the parent directory and re-render the tree.
3200
+ function navigateExplorerUp() {
3201
+ var cwd = getEffectiveExplorerCwd();
3202
+ if (!cwd) return;
3203
+ var parent = cwd.replace(/\/+$/, "").replace(/\/[^\/]+$/, "");
3204
+ if (!parent) parent = "/";
3205
+ if (parent === cwd) return;
3206
+ refreshFileExplorer({ cwd: parent });
3207
+ }
3208
+
3209
+ // Toggle the show-hidden flag and persist it.
3210
+ function toggleExplorerHidden() {
3211
+ state.fileExplorerShowHidden = !state.fileExplorerShowHidden;
3212
+ try { localStorage.setItem("wand-file-show-hidden", state.fileExplorerShowHidden ? "1" : "0"); } catch (e) {}
3213
+ var btn = document.getElementById("file-explorer-toggle-hidden");
3214
+ if (btn) {
3215
+ btn.classList.toggle("active", state.fileExplorerShowHidden);
3216
+ btn.setAttribute("aria-pressed", state.fileExplorerShowHidden ? "true" : "false");
3217
+ btn.textContent = state.fileExplorerShowHidden ? "👁" : "👁‍🗨";
3218
+ btn.title = state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件";
3219
+ }
3220
+ refreshFileExplorer();
3221
+ }
3222
+
3223
+ function appendToComposer(text) {
3224
+ var inputBox = document.getElementById("input-box");
3225
+ if (!inputBox) return false;
3226
+ var current = inputBox.value || "";
3227
+ var sep = current && !current.endsWith(" ") && !current.endsWith("\n") ? " " : "";
3228
+ inputBox.value = current + sep + text;
3229
+ inputBox.dispatchEvent(new Event("input", { bubbles: true }));
3230
+ try { inputBox.focus(); inputBox.setSelectionRange(inputBox.value.length, inputBox.value.length); } catch (e) {}
3231
+ return true;
3232
+ }
3233
+
3234
+ function copyTextSafely(text) {
3235
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3236
+ return navigator.clipboard.writeText(text).then(function() { return true; }).catch(function() { return fallback(); });
3237
+ }
3238
+ return Promise.resolve(fallback());
3239
+ function fallback() {
3240
+ try {
3241
+ var ta = document.createElement("textarea");
3242
+ ta.value = text;
3243
+ ta.style.position = "fixed";
3244
+ ta.style.left = "-9999px";
3245
+ document.body.appendChild(ta);
3246
+ ta.select();
3247
+ var ok = document.execCommand("copy");
3248
+ document.body.removeChild(ta);
3249
+ return ok;
3250
+ } catch (e) { return false; }
3251
+ }
3252
+ }
3253
+
3254
+ function showToastIfPossible(msg) {
3255
+ if (typeof window.showToast === "function") { window.showToast(msg); return; }
3256
+ // Lightweight transient toast.
3257
+ var t = document.createElement("div");
3258
+ t.className = "wand-mini-toast";
3259
+ t.textContent = msg;
3260
+ document.body.appendChild(t);
3261
+ setTimeout(function() { t.classList.add("show"); }, 10);
3262
+ setTimeout(function() {
3263
+ t.classList.remove("show");
3264
+ setTimeout(function() { t.remove(); }, 220);
3265
+ }, 1600);
3266
+ }
3267
+
3268
+ function dismissFileContextMenu() {
3269
+ var menu = document.getElementById("file-context-menu");
3270
+ if (menu) menu.remove();
3271
+ document.removeEventListener("click", dismissFileContextMenu, true);
3272
+ document.removeEventListener("scroll", dismissFileContextMenu, true);
3273
+ }
3274
+
3275
+ function showFileContextMenu(x, y, item) {
3276
+ dismissFileContextMenu();
3277
+ var fullPath = item.dataset.path || "";
3278
+ var type = item.dataset.type || "file";
3279
+ var cwd = state.fileExplorerCwd || "";
3280
+ var relativePath = fullPath;
3281
+ if (cwd && fullPath.indexOf(cwd) === 0) {
3282
+ relativePath = fullPath.slice(cwd.length).replace(/^\/+/, "") || ".";
3283
+ }
3284
+
3285
+ var menu = document.createElement("div");
3286
+ menu.id = "file-context-menu";
3287
+ menu.className = "file-context-menu";
3288
+ var actions = [];
3289
+ if (type === "file") {
3290
+ actions.push({ label: "打开预览", icon: "👁", run: function() { openFilePreview(fullPath); } });
3291
+ } else {
3292
+ actions.push({ label: "进入此目录", icon: "📂", run: function() { refreshFileExplorer({ cwd: fullPath }); } });
3293
+ }
3294
+ actions.push({ label: "复制完整路径", icon: "📋", run: function() {
3295
+ copyTextSafely(fullPath).then(function() { showToastIfPossible("已复制路径"); });
3296
+ }});
3297
+ if (relativePath && relativePath !== fullPath) {
3298
+ actions.push({ label: "复制相对路径", icon: "📋", run: function() {
3299
+ copyTextSafely(relativePath).then(function() { showToastIfPossible("已复制相对路径"); });
3300
+ }});
3301
+ }
3302
+ actions.push({ label: "粘贴路径到输入框", icon: "✏️", run: function() {
3303
+ if (appendToComposer(fullPath)) showToastIfPossible("已粘贴到输入框");
3304
+ }});
3305
+ if (type === "file") {
3306
+ actions.push({ label: "下载文件", icon: "⬇", run: function() {
3307
+ var a = document.createElement("a");
3308
+ a.href = "/api/file-raw?download=1&path=" + encodeURIComponent(fullPath);
3309
+ a.rel = "noopener";
3310
+ document.body.appendChild(a);
3311
+ a.click();
3312
+ a.remove();
3313
+ }});
3314
+ }
3315
+
3316
+ menu.innerHTML = actions.map(function(act, i) {
3317
+ return '<button type="button" class="file-context-menu-item" data-idx="' + i + '"><span class="ctx-icon">' + act.icon + '</span><span class="ctx-label">' + escapeHtml(act.label) + '</span></button>';
3318
+ }).join("");
3319
+ document.body.appendChild(menu);
3320
+
3321
+ // Position with viewport-bound clamp
3322
+ var vw = window.innerWidth;
3323
+ var vh = window.innerHeight;
3324
+ var rect = menu.getBoundingClientRect();
3325
+ var left = Math.min(x, vw - rect.width - 8);
3326
+ var top = Math.min(y, vh - rect.height - 8);
3327
+ menu.style.left = Math.max(8, left) + "px";
3328
+ menu.style.top = Math.max(8, top) + "px";
3329
+
3330
+ menu.querySelectorAll(".file-context-menu-item").forEach(function(btn) {
3331
+ btn.addEventListener("click", function(ev) {
3332
+ ev.stopPropagation();
3333
+ var idx = parseInt(btn.dataset.idx, 10);
3334
+ dismissFileContextMenu();
3335
+ if (actions[idx]) actions[idx].run();
3336
+ });
3337
+ });
3338
+
3339
+ // Close on outside click / scroll
3340
+ setTimeout(function() {
3341
+ document.addEventListener("click", dismissFileContextMenu, true);
3342
+ document.addEventListener("scroll", dismissFileContextMenu, true);
3343
+ }, 0);
3344
+ }
3345
+
3346
+ // Module-level handle for keyboard nav between siblings.
3347
+ var _activeFilePreview = null;
3348
+
3021
3349
  function openFilePreview(filePath) {
3022
- var overlay = document.createElement("div");
3023
- overlay.className = "file-preview-overlay";
3024
- overlay.innerHTML =
3025
- '<div class="file-preview-modal">' +
3026
- '<div class="file-preview-header">' +
3027
- '<div class="file-preview-title">' +
3028
- '<span>📄</span>' +
3029
- '<span class="file-preview-filename">Loading...</span>' +
3350
+ // If a modal is already open, just swap content (used by ←/→ navigation).
3351
+ var overlay = _activeFilePreview && _activeFilePreview.overlay;
3352
+ if (!overlay) {
3353
+ overlay = document.createElement("div");
3354
+ overlay.className = "file-preview-overlay";
3355
+ overlay.innerHTML =
3356
+ '<div class="file-preview-modal" tabindex="-1">' +
3357
+ '<div class="file-preview-header">' +
3358
+ '<div class="file-preview-title">' +
3359
+ '<span class="file-preview-icon">📄</span>' +
3360
+ '<span class="file-preview-filename">加载中…</span>' +
3361
+ '</div>' +
3362
+ '<div class="file-preview-path" title=""></div>' +
3363
+ '<div class="file-preview-toolbar"></div>' +
3364
+ '<button class="file-preview-close" title="关闭 (Esc)" aria-label="关闭">✕</button>' +
3030
3365
  '</div>' +
3031
- '<div class="file-preview-path" title="' + escapeHtml(filePath) + '">' + escapeHtml(filePath) + '</div>' +
3032
- '<button class="file-preview-close" title="Close">✕</button>' +
3033
- '</div>' +
3034
- '<div class="file-preview-body">' +
3035
- '<div class="file-preview-loading">Loading preview...</div>' +
3036
- '</div>' +
3037
- '</div>';
3038
- document.body.appendChild(overlay);
3366
+ '<div class="file-preview-body">' +
3367
+ '<div class="file-preview-loading">加载预览…</div>' +
3368
+ '</div>' +
3369
+ '</div>';
3370
+ document.body.appendChild(overlay);
3039
3371
 
3040
- var closeBtn = overlay.querySelector(".file-preview-close");
3041
- var closeModal = function() {
3042
- overlay.remove();
3043
- document.removeEventListener("keydown", escHandler);
3044
- };
3045
- closeBtn.addEventListener("click", closeModal);
3046
- overlay.addEventListener("click", function(e) {
3047
- if (e.target === overlay) closeModal();
3048
- });
3049
- var escHandler = function(e) {
3050
- if (e.key === "Escape") closeModal();
3051
- };
3052
- document.addEventListener("keydown", escHandler);
3372
+ var closeBtn = overlay.querySelector(".file-preview-close");
3373
+ var closeModal = function() {
3374
+ overlay.remove();
3375
+ document.removeEventListener("keydown", keyHandler);
3376
+ _activeFilePreview = null;
3377
+ };
3378
+ closeBtn.addEventListener("click", closeModal);
3379
+ overlay.addEventListener("click", function(e) {
3380
+ if (e.target === overlay) closeModal();
3381
+ });
3382
+ var keyHandler = function(e) {
3383
+ if (e.key === "Escape") { closeModal(); return; }
3384
+ if (!_activeFilePreview) return;
3385
+ if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
3386
+ if (e.key === "ArrowLeft") { e.preventDefault(); navigatePreviewSibling(-1); }
3387
+ else if (e.key === "ArrowRight") { e.preventDefault(); navigatePreviewSibling(1); }
3388
+ };
3389
+ document.addEventListener("keydown", keyHandler);
3390
+
3391
+ _activeFilePreview = { overlay: overlay, close: closeModal, path: filePath, data: null };
3392
+ } else {
3393
+ _activeFilePreview.path = filePath;
3394
+ // Reset header / body for the new file.
3395
+ var titleEl = overlay.querySelector(".file-preview-title");
3396
+ if (titleEl) {
3397
+ titleEl.innerHTML = '<span class="file-preview-icon">📄</span><span class="file-preview-filename">加载中…</span>';
3398
+ }
3399
+ var toolbarEl = overlay.querySelector(".file-preview-toolbar");
3400
+ if (toolbarEl) toolbarEl.innerHTML = "";
3401
+ var pathEl = overlay.querySelector(".file-preview-path");
3402
+ if (pathEl) { pathEl.textContent = ""; pathEl.title = ""; }
3403
+ var bodyReset = overlay.querySelector(".file-preview-body");
3404
+ if (bodyReset) bodyReset.innerHTML = '<div class="file-preview-loading">加载预览…</div>';
3405
+ }
3406
+
3407
+ var pathDisplayEl = overlay.querySelector(".file-preview-path");
3408
+ if (pathDisplayEl) {
3409
+ pathDisplayEl.textContent = filePath;
3410
+ pathDisplayEl.title = filePath;
3411
+ }
3053
3412
 
3054
3413
  fetch("/api/file-preview?path=" + encodeURIComponent(filePath), { credentials: "same-origin" })
3055
- .then(function(res) { return res.json(); })
3056
- .then(function(data) {
3057
- if (data.error) {
3058
- var body = overlay.querySelector(".file-preview-body");
3059
- body.innerHTML = '<div class="file-preview-error"><span class="preview-error-icon">⚠</span><span>' + escapeHtml(data.error) + '</span></div>';
3414
+ .then(function(res) {
3415
+ return res.json().then(function(data) { return { ok: res.ok, status: res.status, data: data }; });
3416
+ })
3417
+ .then(function(result) {
3418
+ var body = overlay.querySelector(".file-preview-body");
3419
+ if (!result.ok || (result.data && result.data.error)) {
3420
+ var msg = (result.data && result.data.error) || "加载失败";
3421
+ if (result.status === 413 && result.data && result.data.size) {
3422
+ msg += "(文件大小:" + formatFileSize(result.data.size) + ")";
3423
+ }
3424
+ body.innerHTML = '<div class="file-preview-error"><span class="preview-error-icon">⚠</span><span>' + escapeHtml(msg) + '</span></div>';
3425
+ // Even when text preview is rejected for size, still allow download.
3426
+ if (result.status === 413) {
3427
+ renderPreviewToolbar(overlay, {
3428
+ kind: "binary",
3429
+ path: filePath,
3430
+ name: filePath.split("/").pop() || filePath,
3431
+ ext: "",
3432
+ size: (result.data && result.data.size) || 0,
3433
+ });
3434
+ }
3060
3435
  return;
3061
3436
  }
3062
- renderPreviewContent(overlay, data);
3437
+ _activeFilePreview.data = result.data;
3438
+ renderPreviewContent(overlay, result.data);
3063
3439
  })
3064
- .catch(function(err) {
3440
+ .catch(function() {
3065
3441
  var body = overlay.querySelector(".file-preview-body");
3066
- body.innerHTML = '<div class="file-preview-error"><span class="preview-error-icon">⚠</span><span>Failed to load preview</span></div>';
3442
+ body.innerHTML = '<div class="file-preview-error"><span class="preview-error-icon">⚠</span><span>加载预览失败</span></div>';
3067
3443
  });
3068
3444
  }
3069
3445
 
3070
- function renderPreviewContent(overlay, data) {
3071
- var filename = overlay.querySelector(".file-preview-filename");
3072
- filename.textContent = data.name;
3446
+ // Move to the previous/next file sibling in the current explorer view.
3447
+ function navigatePreviewSibling(direction) {
3448
+ if (!_activeFilePreview) return;
3449
+ var siblings = (state.allFiles || []).filter(function(item) { return item.type === "file"; });
3450
+ if (!siblings.length) return;
3451
+ var currentPath = _activeFilePreview.path;
3452
+ var idx = -1;
3453
+ for (var i = 0; i < siblings.length; i++) {
3454
+ if (siblings[i].path === currentPath) { idx = i; break; }
3455
+ }
3456
+ if (idx < 0) return;
3457
+ var nextIdx = (idx + direction + siblings.length) % siblings.length;
3458
+ var nextPath = siblings[nextIdx].path;
3459
+ if (nextPath && nextPath !== currentPath) openFilePreview(nextPath);
3460
+ }
3073
3461
 
3462
+ function renderPreviewContent(overlay, data) {
3463
+ var filenameEl = overlay.querySelector(".file-preview-filename");
3464
+ if (filenameEl) filenameEl.textContent = data.name;
3465
+ var iconEl = overlay.querySelector(".file-preview-icon");
3466
+ if (iconEl) iconEl.textContent = getFileIcon({ name: data.name, type: "file" });
3467
+
3468
+ // Title-bar badge: language for text, kind label for media/binary
3469
+ var titleEl = overlay.querySelector(".file-preview-title");
3470
+ var existingBadge = overlay.querySelector(".file-preview-lang");
3471
+ if (existingBadge) existingBadge.remove();
3074
3472
  var langBadge = document.createElement("span");
3075
3473
  langBadge.className = "file-preview-lang";
3076
- langBadge.textContent = data.lang || data.ext.replace(".", "");
3077
- overlay.querySelector(".file-preview-title").appendChild(langBadge);
3474
+ var labelMap = { image: "图片", pdf: "PDF", video: "视频", audio: "音频", binary: "二进制" };
3475
+ if (data.kind === "text") {
3476
+ langBadge.textContent = data.lang || (data.ext || "").replace(".", "") || "text";
3477
+ } else {
3478
+ langBadge.textContent = labelMap[data.kind] || (data.ext || "").replace(".", "") || data.kind;
3479
+ }
3480
+ if (titleEl) titleEl.appendChild(langBadge);
3078
3481
 
3079
- var body = overlay.querySelector(".file-preview-body");
3482
+ renderPreviewToolbar(overlay, data);
3080
3483
 
3081
- if (data.lang === "markdown") {
3082
- body.innerHTML = '<div class="markdown-preview">' + renderMarkdownPreview(data.content) + '</div>';
3484
+ var body = overlay.querySelector(".file-preview-body");
3485
+ body.innerHTML = "";
3486
+ body.classList.remove("kind-text", "kind-image", "kind-pdf", "kind-video", "kind-audio", "kind-binary");
3487
+ body.classList.add("kind-" + (data.kind || "text"));
3488
+
3489
+ if (data.kind === "image") {
3490
+ renderImagePreview(body, data);
3491
+ } else if (data.kind === "pdf") {
3492
+ renderPdfPreview(body, data);
3493
+ } else if (data.kind === "video") {
3494
+ renderVideoPreview(body, data);
3495
+ } else if (data.kind === "audio") {
3496
+ renderAudioPreview(body, data);
3497
+ } else if (data.kind === "binary") {
3498
+ renderBinaryPreview(body, data);
3499
+ } else if ((data.lang === "markdown") || /\.(md|markdown|mdx)$/i.test(data.name || "")) {
3500
+ body.innerHTML = '<div class="markdown-preview">' + renderMarkdownPreview(data.content || "") + '</div>';
3083
3501
  } else {
3084
- var highlighted = highlightCodePreview(data.content, data.lang);
3085
- var lines = highlighted.split("\n");
3086
- var lineNums = lines.map(function(_, i) { return i + 1; });
3087
-
3088
- body.innerHTML =
3089
- '<div class="code-preview-wrapper">' +
3090
- '<div class="code-preview-lines">' + lineNums.join("\n") + '</div>' +
3091
- '<div class="code-preview-content"><pre>' + lines.join("\n") + '</pre></div>' +
3092
- '</div>';
3502
+ renderTextPreview(body, data);
3093
3503
  }
3094
3504
  }
3095
3505
 
3506
+ function renderTextPreview(body, data) {
3507
+ var highlighted = highlightCodePreview(data.content || "", data.lang);
3508
+ var lines = highlighted.split("\n");
3509
+ var lineNums = lines.map(function(_, i) { return i + 1; });
3510
+ body.innerHTML =
3511
+ '<div class="code-preview-wrapper">' +
3512
+ '<div class="code-preview-lines">' + lineNums.join("\n") + '</div>' +
3513
+ '<div class="code-preview-content"><pre>' + lines.join("\n") + '</pre></div>' +
3514
+ '</div>';
3515
+ }
3516
+
3517
+ function renderImagePreview(body, data) {
3518
+ var src = "/api/file-raw?path=" + encodeURIComponent(data.path);
3519
+ body.innerHTML =
3520
+ '<div class="image-preview-wrapper">' +
3521
+ '<img class="image-preview-img" src="' + src + '" alt="' + escapeHtml(data.name) + '" />' +
3522
+ '</div>';
3523
+ var img = body.querySelector(".image-preview-img");
3524
+ if (!img) return;
3525
+ var zoomed = false;
3526
+ img.addEventListener("click", function() {
3527
+ zoomed = !zoomed;
3528
+ img.classList.toggle("zoomed", zoomed);
3529
+ });
3530
+ }
3531
+
3532
+ function renderPdfPreview(body, data) {
3533
+ var src = "/api/file-raw?path=" + encodeURIComponent(data.path);
3534
+ body.innerHTML =
3535
+ '<iframe class="pdf-preview-frame" src="' + src + '" title="' + escapeHtml(data.name) + '"></iframe>';
3536
+ }
3537
+
3538
+ function renderVideoPreview(body, data) {
3539
+ var src = "/api/file-raw?path=" + encodeURIComponent(data.path);
3540
+ body.innerHTML =
3541
+ '<div class="media-preview-wrapper">' +
3542
+ '<video class="media-preview-video" controls preload="metadata" src="' + src + '">您的浏览器不支持 video 标签。</video>' +
3543
+ '<div class="media-preview-meta">' + escapeHtml(formatFileSize(data.size)) + '</div>' +
3544
+ '</div>';
3545
+ }
3546
+
3547
+ function renderAudioPreview(body, data) {
3548
+ var src = "/api/file-raw?path=" + encodeURIComponent(data.path);
3549
+ body.innerHTML =
3550
+ '<div class="media-preview-wrapper audio">' +
3551
+ '<div class="media-preview-icon">🎵</div>' +
3552
+ '<div class="media-preview-name">' + escapeHtml(data.name) + '</div>' +
3553
+ '<audio class="media-preview-audio" controls preload="metadata" src="' + src + '">您的浏览器不支持 audio 标签。</audio>' +
3554
+ '<div class="media-preview-meta">' + escapeHtml(formatFileSize(data.size)) + '</div>' +
3555
+ '</div>';
3556
+ }
3557
+
3558
+ function renderBinaryPreview(body, data) {
3559
+ var rawUrl = "/api/file-raw?download=1&path=" + encodeURIComponent(data.path);
3560
+ body.innerHTML =
3561
+ '<div class="binary-preview-card">' +
3562
+ '<div class="binary-preview-icon">📦</div>' +
3563
+ '<div class="binary-preview-name">' + escapeHtml(data.name) + '</div>' +
3564
+ '<div class="binary-preview-meta">' +
3565
+ '<span>' + escapeHtml((data.ext || "").replace(/^\./, "") || "未知格式") + '</span>' +
3566
+ '<span>·</span>' +
3567
+ '<span>' + escapeHtml(formatFileSize(data.size)) + '</span>' +
3568
+ '</div>' +
3569
+ '<div class="binary-preview-path" title="' + escapeHtml(data.path) + '">' + escapeHtml(data.path) + '</div>' +
3570
+ '<div class="binary-preview-actions">' +
3571
+ '<a class="binary-preview-btn" href="' + rawUrl + '" download="' + escapeHtml(data.name) + '">下载文件</a>' +
3572
+ '<button class="binary-preview-btn" type="button" data-action="view-cat">在终端中查看</button>' +
3573
+ '</div>' +
3574
+ '</div>';
3575
+ var catBtn = body.querySelector('[data-action="view-cat"]');
3576
+ if (catBtn) catBtn.addEventListener("click", function() {
3577
+ if (appendToComposer('cat -- "' + data.path + '"')) {
3578
+ showToastIfPossible("命令已粘贴到输入框");
3579
+ }
3580
+ });
3581
+ }
3582
+
3583
+ function renderPreviewToolbar(overlay, data) {
3584
+ var bar = overlay.querySelector(".file-preview-toolbar");
3585
+ if (!bar) return;
3586
+ bar.innerHTML = "";
3587
+ var buttons = [];
3588
+
3589
+ // Common actions across all kinds
3590
+ buttons.push({ label: "复制路径", icon: "📋", action: function() {
3591
+ copyTextSafely(data.path).then(function() { showToastIfPossible("已复制路径"); });
3592
+ }});
3593
+ buttons.push({ label: "粘贴到输入框", icon: "✏️", action: function() {
3594
+ if (appendToComposer(data.path)) showToastIfPossible("已粘贴到输入框");
3595
+ }});
3596
+ buttons.push({ label: "下载", icon: "⬇", action: function() {
3597
+ var a = document.createElement("a");
3598
+ a.href = "/api/file-raw?download=1&path=" + encodeURIComponent(data.path);
3599
+ a.download = data.name || "";
3600
+ document.body.appendChild(a);
3601
+ a.click();
3602
+ a.remove();
3603
+ }});
3604
+
3605
+ if (data.kind === "text") {
3606
+ buttons.push({ label: "复制全部", icon: "📄", action: function() {
3607
+ copyTextSafely(data.content || "").then(function() { showToastIfPossible("已复制内容"); });
3608
+ }});
3609
+ buttons.push({ label: "自动换行", icon: "↩", toggleClass: "toolbar-active",
3610
+ getInitial: function() {
3611
+ var pre = overlay.querySelector(".code-preview-content pre");
3612
+ return pre && pre.classList.contains("wrap");
3613
+ },
3614
+ action: function(btn) {
3615
+ var pre = overlay.querySelector(".code-preview-content pre");
3616
+ if (!pre) return;
3617
+ pre.classList.toggle("wrap");
3618
+ btn.classList.toggle("toolbar-active", pre.classList.contains("wrap"));
3619
+ }
3620
+ });
3621
+ buttons.push({ label: "字号 −", icon: "A−", action: function() { adjustPreviewFontSize(overlay, -1); }});
3622
+ buttons.push({ label: "字号 +", icon: "A+", action: function() { adjustPreviewFontSize(overlay, +1); }});
3623
+ }
3624
+
3625
+ buttons.forEach(function(b) {
3626
+ var btn = document.createElement("button");
3627
+ btn.type = "button";
3628
+ btn.className = "file-preview-toolbar-btn";
3629
+ btn.title = b.label;
3630
+ btn.setAttribute("aria-label", b.label);
3631
+ btn.innerHTML = '<span class="toolbar-icon">' + b.icon + '</span>';
3632
+ if (b.getInitial && b.getInitial()) btn.classList.add("toolbar-active");
3633
+ btn.addEventListener("click", function(ev) {
3634
+ ev.stopPropagation();
3635
+ b.action(btn);
3636
+ });
3637
+ bar.appendChild(btn);
3638
+ });
3639
+ }
3640
+
3641
+ function adjustPreviewFontSize(overlay, delta) {
3642
+ var pre = overlay.querySelector(".code-preview-content pre");
3643
+ var nums = overlay.querySelector(".code-preview-lines");
3644
+ if (!pre) return;
3645
+ var current = parseFloat(getComputedStyle(pre).fontSize) || 13;
3646
+ var next = Math.max(10, Math.min(22, current + delta));
3647
+ pre.style.fontSize = next + "px";
3648
+ if (nums) nums.style.fontSize = next + "px";
3649
+ }
3650
+
3096
3651
  function highlightCodePreview(code, lang) {
3097
3652
  // Escape HTML first
3098
3653
  var escaped = code
@@ -4020,6 +4575,8 @@
4020
4575
  if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
4021
4576
  var defaultModelRefreshBtn = document.getElementById("cfg-default-model-refresh");
4022
4577
  if (defaultModelRefreshBtn) defaultModelRefreshBtn.addEventListener("click", refreshAvailableModels);
4578
+ var viewEnvBtn = document.getElementById("cfg-view-env-btn");
4579
+ if (viewEnvBtn) viewEnvBtn.addEventListener("click", openEnvPreviewModal);
4023
4580
  var saveDisplayBtn = document.getElementById("save-display-button");
4024
4581
  if (saveDisplayBtn) saveDisplayBtn.addEventListener("click", saveDisplaySettings);
4025
4582
  // App icon picker (APK only)
@@ -4415,27 +4972,28 @@
4415
4972
  topbarMoreBtn.classList.remove("active");
4416
4973
  topbarMoreBtn.setAttribute("aria-expanded", "false");
4417
4974
  switch (action) {
4418
- case "settings":
4419
- openSettingsModal();
4975
+ case "copy-claude-session-id":
4976
+ copySelectedSessionField("claudeSessionId", "Claude 会话 ID 已复制");
4420
4977
  break;
4421
- case "refresh":
4422
- window.location.reload();
4978
+ case "copy-cwd":
4979
+ copySelectedSessionField("cwd", "工作目录已复制");
4423
4980
  break;
4424
- case "install":
4425
- if (state.deferredPrompt) {
4426
- state.deferredPrompt.prompt();
4427
- state.deferredPrompt.userChoice.then(function() {
4428
- state.deferredPrompt = null;
4429
- state.showInstallPrompt = false;
4430
- updateInstallPrompt();
4431
- });
4432
- }
4981
+ case "copy-session-id":
4982
+ copySelectedSessionField("id", "会话 ID 已复制");
4983
+ break;
4984
+ case "worktree-merge":
4985
+ if (state.selectedId) openWorktreeMergeModal(state.selectedId);
4433
4986
  break;
4434
- case "logout":
4435
- logout();
4987
+ case "worktree-cleanup":
4988
+ if (state.selectedId) retryWorktreeCleanup(state.selectedId);
4436
4989
  break;
4437
- case "switch-server":
4438
- switchServer();
4990
+ case "delete-session":
4991
+ if (state.selectedId) {
4992
+ var pendingId = state.selectedId;
4993
+ if (confirm("确定要删除当前会话吗?")) {
4994
+ deleteSession(pendingId);
4995
+ }
4996
+ }
4439
4997
  break;
4440
4998
  }
4441
4999
  });
@@ -4496,7 +5054,11 @@
4496
5054
  setChatAutoFollow(true, { scrollNow: true, smooth: true });
4497
5055
  });
4498
5056
  var fileRefresh = document.getElementById("file-explorer-refresh");
4499
- if (fileRefresh) fileRefresh.addEventListener("click", refreshFileExplorer);
5057
+ if (fileRefresh) fileRefresh.addEventListener("click", function() { refreshFileExplorer(); });
5058
+ var fileUp = document.getElementById("file-explorer-up");
5059
+ if (fileUp) fileUp.addEventListener("click", navigateExplorerUp);
5060
+ var fileToggleHidden = document.getElementById("file-explorer-toggle-hidden");
5061
+ if (fileToggleHidden) fileToggleHidden.addEventListener("click", toggleExplorerHidden);
4500
5062
 
4501
5063
  // File search
4502
5064
  var fileSearchInput = document.getElementById("file-search-input");
@@ -5069,6 +5631,20 @@
5069
5631
  }
5070
5632
  }
5071
5633
 
5634
+ /** Copy a string field of the currently selected session to clipboard. */
5635
+ function copySelectedSessionField(field, successMsg) {
5636
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
5637
+ if (!session) return;
5638
+ var value = session[field];
5639
+ if (!value) {
5640
+ showToast("当前会话没有可复制的内容。", "error");
5641
+ return;
5642
+ }
5643
+ copyToClipboard(String(value), null, function() {
5644
+ showToast(successMsg || "已复制", "info");
5645
+ });
5646
+ }
5647
+
5072
5648
  /** Copy Claude session ID from badge to clipboard */
5073
5649
  function handleClaudeIdCopy(event) {
5074
5650
  var badge = event.target.closest("#claude-session-id-badge");
@@ -6002,31 +6578,14 @@
6002
6578
  }
6003
6579
 
6004
6580
  function getComposerPlaceholder(session, terminalInteractive) {
6005
- if (terminalInteractive) {
6006
- return "终端交互模式开启中,请直接在终端中输入";
6007
- }
6008
- if (session && isStructuredSession(session)) {
6009
- return session.provider === "codex"
6010
- ? "向 Codex 发送消息;chat 为结构化对话视图"
6011
- : "向 Claude 发送消息;chat 为结构化对话视图";
6012
- }
6013
- if (session && session.provider === "codex") {
6014
- if (session.status !== "running") {
6015
- return "Codex 会话已结束,无法继续发送";
6016
- }
6017
- return state.currentView === "terminal"
6018
- ? "向 Codex 发送输入;terminal 为原始 TUI 输出"
6019
- : "向 Codex 发送输入;chat 为解析后的阅读视图";
6581
+ // Keep placeholders short so they don't wrap on portrait mobile screens.
6582
+ // Only show informative state hints; drop the redundant "send to X" labels.
6583
+ if (terminalInteractive) return "终端交互中";
6584
+ if (session && session.status !== "running") {
6585
+ if (canAutoResumeSession(session)) return "";
6586
+ return "会话已结束";
6020
6587
  }
6021
- if (session && !isStructuredSession(session) && session.status !== "running") {
6022
- if (canAutoResumeSession(session)) {
6023
- return "输入消息...";
6024
- }
6025
- return "会话已结束,无法继续发送";
6026
- }
6027
- return session && isStructuredSession(session) && session.structuredState && session.structuredState.inFlight
6028
- ? "思考中 · 发送新消息将中断当前回复"
6029
- : "输入消息...";
6588
+ return "";
6030
6589
  }
6031
6590
 
6032
6591
  function getToolModeHint(tool, mode) {
@@ -6207,6 +6766,143 @@
6207
6766
  });
6208
6767
  }
6209
6768
 
6769
+ // ── Environment-variable preview modal ──
6770
+ // Lazily creates a modal showing the exact env vars wand will inject
6771
+ // into PTY / structured child processes (mirrors buildChildEnv()).
6772
+ function openEnvPreviewModal() {
6773
+ var modal = document.getElementById("env-preview-modal");
6774
+ if (!modal) {
6775
+ modal = document.createElement("section");
6776
+ modal.id = "env-preview-modal";
6777
+ modal.className = "modal-backdrop hidden";
6778
+ modal.innerHTML =
6779
+ '<div class="modal env-preview-modal" role="dialog" aria-labelledby="env-preview-title" aria-modal="true">' +
6780
+ '<div class="modal-header">' +
6781
+ '<div>' +
6782
+ '<h2 class="modal-title" id="env-preview-title">将注入子进程的环境变量</h2>' +
6783
+ '<p class="modal-subtitle" id="env-preview-subtitle">这些变量会被传给 claude / codex(PTY 与结构化运行器一致)。</p>' +
6784
+ '</div>' +
6785
+ '<button id="env-preview-close" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭">' +
6786
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">' +
6787
+ '<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>' +
6788
+ '</svg>' +
6789
+ '</button>' +
6790
+ '</div>' +
6791
+ '<div class="modal-body env-preview-body">' +
6792
+ '<div class="env-preview-toolbar">' +
6793
+ '<div class="env-preview-meta" id="env-preview-meta">加载中…</div>' +
6794
+ '<div class="env-preview-controls">' +
6795
+ '<input id="env-preview-search" class="env-preview-search" type="search" placeholder="搜索变量名…" />' +
6796
+ '<label class="env-preview-reveal">' +
6797
+ '<input id="env-preview-reveal-toggle" type="checkbox" />' +
6798
+ '<span>显示敏感值</span>' +
6799
+ '</label>' +
6800
+ '</div>' +
6801
+ '</div>' +
6802
+ '<div class="env-preview-list" id="env-preview-list" tabindex="0">' +
6803
+ '<div class="env-preview-loading">加载中…</div>' +
6804
+ '</div>' +
6805
+ '</div>' +
6806
+ '<div class="modal-footer env-preview-footer">' +
6807
+ '<span class="env-preview-hint">敏感字段(含 KEY/TOKEN/SECRET 等)默认掩码,可勾选「显示敏感值」临时还原。</span>' +
6808
+ '<button id="env-preview-close-2" class="btn btn-secondary btn-sm" type="button">关闭</button>' +
6809
+ '</div>' +
6810
+ '</div>';
6811
+ document.body.appendChild(modal);
6812
+
6813
+ // Click outside to close
6814
+ modal.addEventListener("click", function(e) {
6815
+ if (e.target === modal) closeEnvPreviewModal();
6816
+ });
6817
+ var closeBtn = modal.querySelector("#env-preview-close");
6818
+ if (closeBtn) closeBtn.addEventListener("click", closeEnvPreviewModal);
6819
+ var closeBtn2 = modal.querySelector("#env-preview-close-2");
6820
+ if (closeBtn2) closeBtn2.addEventListener("click", closeEnvPreviewModal);
6821
+ var searchEl = modal.querySelector("#env-preview-search");
6822
+ if (searchEl) searchEl.addEventListener("input", function() { renderEnvPreviewList(); });
6823
+ var revealEl = modal.querySelector("#env-preview-reveal-toggle");
6824
+ if (revealEl) revealEl.addEventListener("change", function() { loadEnvPreview(revealEl.checked); });
6825
+ }
6826
+
6827
+ modal.classList.remove("closing");
6828
+ modal.classList.remove("hidden");
6829
+ var revealEl = modal.querySelector("#env-preview-reveal-toggle");
6830
+ if (revealEl) revealEl.checked = false;
6831
+ var searchEl = modal.querySelector("#env-preview-search");
6832
+ if (searchEl) searchEl.value = "";
6833
+ loadEnvPreview(false);
6834
+ }
6835
+
6836
+ function closeEnvPreviewModal() {
6837
+ var modal = document.getElementById("env-preview-modal");
6838
+ if (!modal) return;
6839
+ animateModalClose(modal);
6840
+ }
6841
+
6842
+ function loadEnvPreview(reveal) {
6843
+ var listEl = document.getElementById("env-preview-list");
6844
+ var metaEl = document.getElementById("env-preview-meta");
6845
+ if (listEl) listEl.innerHTML = '<div class="env-preview-loading">加载中…</div>';
6846
+ if (metaEl) metaEl.textContent = "加载中…";
6847
+ var url = "/api/settings/env-preview" + (reveal ? "?reveal=1" : "");
6848
+ fetch(url, { credentials: "same-origin" })
6849
+ .then(function(res) { return res.json(); })
6850
+ .then(function(data) {
6851
+ if (!data || !Array.isArray(data.entries)) {
6852
+ if (listEl) listEl.innerHTML = '<div class="env-preview-empty">读取失败。</div>';
6853
+ if (metaEl) metaEl.textContent = "读取失败";
6854
+ return;
6855
+ }
6856
+ state._envPreview = data;
6857
+ if (metaEl) {
6858
+ var inheritLabel = data.inheritEnv ? "继承父进程" : "最小白名单";
6859
+ metaEl.innerHTML =
6860
+ '<span class="env-preview-pill ' + (data.inheritEnv ? "is-inherit" : "is-minimal") + '">' + inheritLabel + '</span>' +
6861
+ '<span class="env-preview-count">共 ' + data.total + ' 项</span>';
6862
+ }
6863
+ renderEnvPreviewList();
6864
+ })
6865
+ .catch(function() {
6866
+ if (listEl) listEl.innerHTML = '<div class="env-preview-empty">读取失败,请稍后重试。</div>';
6867
+ if (metaEl) metaEl.textContent = "读取失败";
6868
+ });
6869
+ }
6870
+
6871
+ function renderEnvPreviewList() {
6872
+ var listEl = document.getElementById("env-preview-list");
6873
+ if (!listEl) return;
6874
+ var data = state._envPreview;
6875
+ if (!data || !Array.isArray(data.entries)) {
6876
+ listEl.innerHTML = '<div class="env-preview-empty">尚未加载。</div>';
6877
+ return;
6878
+ }
6879
+ var searchEl = document.getElementById("env-preview-search");
6880
+ var query = (searchEl && searchEl.value || "").trim().toLowerCase();
6881
+ var html = "";
6882
+ var shown = 0;
6883
+ for (var i = 0; i < data.entries.length; i++) {
6884
+ var entry = data.entries[i];
6885
+ if (query && entry.name.toLowerCase().indexOf(query) === -1) continue;
6886
+ shown++;
6887
+ var isPlaceholder = typeof entry.value === "string" && entry.value.charAt(0) === "<" && entry.value.charAt(entry.value.length - 1) === ">";
6888
+ html += '<div class="env-preview-row' + (entry.sensitive ? " is-sensitive" : "") + '">' +
6889
+ '<div class="env-preview-name">' +
6890
+ escapeHtml(entry.name) +
6891
+ (entry.sensitive ? '<span class="env-preview-badge" title="被识别为敏感字段">敏感</span>' : '') +
6892
+ (isPlaceholder ? '<span class="env-preview-badge env-preview-badge-runtime" title="按会话动态注入">运行时</span>' : '') +
6893
+ '</div>' +
6894
+ '<div class="env-preview-value' + (isPlaceholder ? " is-runtime" : "") + '" title="' + escapeHtml(String(entry.value)) + '">' +
6895
+ escapeHtml(String(entry.value)) +
6896
+ '</div>' +
6897
+ '<div class="env-preview-len">' + entry.length + ' 字符</div>' +
6898
+ '</div>';
6899
+ }
6900
+ if (shown === 0) {
6901
+ html = '<div class="env-preview-empty">没有匹配的变量。</div>';
6902
+ }
6903
+ listEl.innerHTML = html;
6904
+ }
6905
+
6210
6906
  function updateSettingsDefaultModelSelect(data) {
6211
6907
  var select = document.getElementById("cfg-default-model");
6212
6908
  if (!select) return;
@@ -9253,7 +9949,7 @@
9253
9949
  var todoEl = document.getElementById("todo-progress");
9254
9950
  if (todoEl) todoEl.classList.add("hidden");
9255
9951
  welcomeInput.value = "";
9256
- welcomeInput.placeholder = "正在启动会话...";
9952
+ welcomeInput.placeholder = "正在启动…";
9257
9953
  welcomeInput.disabled = true;
9258
9954
  var mode = state.chatMode || "managed";
9259
9955
  var defaultCwd = getEffectiveCwd();
@@ -9274,7 +9970,7 @@
9274
9970
  .then(function(data) {
9275
9971
  if (data.error) {
9276
9972
  showToast(data.error, "error");
9277
- welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
9973
+ welcomeInput.placeholder = "输入消息";
9278
9974
  welcomeInput.disabled = false;
9279
9975
  return;
9280
9976
  }
@@ -9287,7 +9983,7 @@
9287
9983
  switchToSessionView(data.id);
9288
9984
  subscribeToSession(data.id);
9289
9985
  loadOutput(data.id).then(function() {
9290
- welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
9986
+ welcomeInput.placeholder = "输入消息";
9291
9987
  welcomeInput.disabled = false;
9292
9988
  focusInputBox(true);
9293
9989
  });
@@ -9296,7 +9992,7 @@
9296
9992
  showToast((error && error.message) || (preferredTool === "codex"
9297
9993
  ? "无法启动 Codex 会话。"
9298
9994
  : "无法启动 Claude 会话。"), "error");
9299
- welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
9995
+ welcomeInput.placeholder = "输入消息";
9300
9996
  welcomeInput.disabled = false;
9301
9997
  });
9302
9998
  }
@@ -10672,7 +11368,7 @@
10672
11368
  function createSessionFromWelcomeInput(value) {
10673
11369
  var welcomeInput = document.getElementById("welcome-input");
10674
11370
  if (!welcomeInput) return;
10675
- welcomeInput.placeholder = "Claude 正在思考,请稍候...";
11371
+ welcomeInput.placeholder = "正在思考…";
10676
11372
  welcomeInput.disabled = true;
10677
11373
  var mode = state.chatMode || "managed";
10678
11374
  var defaultCwd = getEffectiveCwd();
@@ -10694,7 +11390,7 @@
10694
11390
  .then(function(data) {
10695
11391
  if (data.error) {
10696
11392
  showToast(data.error, "error");
10697
- welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
11393
+ welcomeInput.placeholder = "输入消息";
10698
11394
  welcomeInput.disabled = false;
10699
11395
  return null;
10700
11396
  }
@@ -10702,11 +11398,11 @@
10702
11398
  })
10703
11399
  .catch(function(error) {
10704
11400
  showToast((error && error.message) || "无法启动会话。", "error");
10705
- welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
11401
+ welcomeInput.placeholder = "输入消息";
10706
11402
  welcomeInput.disabled = false;
10707
11403
  })
10708
11404
  .finally(function() {
10709
- welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
11405
+ welcomeInput.placeholder = "输入消息";
10710
11406
  welcomeInput.disabled = false;
10711
11407
  });
10712
11408
  }
@@ -15972,43 +16668,87 @@
15972
16668
  playNotificationSound();
15973
16669
 
15974
16670
  var id = ++notificationIdCounter;
15975
- var bubble = document.createElement("div");
15976
- bubble.className = "notification-bubble";
15977
- bubble.setAttribute("data-nid", id);
15978
-
15979
- bubble.innerHTML =
15980
- '<div class="notification-bubble-header">' +
15981
- '<span class="notification-bubble-icon info">\u2191</span>' +
15982
- '<span class="notification-bubble-title">\u53d1\u73b0\u65b0\u7248\u672c</span>' +
15983
- '<button class="notification-bubble-close" title="\u5173\u95ed">\u00d7</button>' +
16671
+ var card = document.createElement("div");
16672
+ // Reuse the notification stacking system but with a richer card style.
16673
+ card.className = "notification-bubble update-card";
16674
+ card.setAttribute("data-nid", id);
16675
+
16676
+ card.innerHTML =
16677
+ '<div class="update-card-shine" aria-hidden="true"></div>' +
16678
+ '<div class="update-card-header">' +
16679
+ '<div class="update-card-icon" aria-hidden="true">' +
16680
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' +
16681
+ '<path d="M12 19V5"/><path d="M5 12l7-7 7 7"/>' +
16682
+ '</svg>' +
16683
+ '</div>' +
16684
+ '<div class="update-card-heading">' +
16685
+ '<div class="update-card-title">\u53d1\u73b0\u65b0\u7248\u672c</div>' +
16686
+ '<div class="update-card-subtitle" id="update-card-subtitle">\u70b9\u51fb\u4e0b\u65b9\u6309\u94ae\u4e00\u952e\u66f4\u65b0</div>' +
16687
+ '</div>' +
16688
+ '<button class="update-card-close" title="\u7a0d\u540e\u63d0\u9192" aria-label="\u5173\u95ed">' +
16689
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' +
16690
+ '<path d="M18 6L6 18"/><path d="M6 6l12 12"/>' +
16691
+ '</svg>' +
16692
+ '</button>' +
16693
+ '</div>' +
16694
+ '<div class="update-card-version">' +
16695
+ '<span class="update-card-version-chip update-card-version-current">v' + escapeHtml(String(currentVer).replace(/^v/, "")) + '</span>' +
16696
+ '<svg class="update-card-version-arrow" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
16697
+ '<path d="M5 12h14"/><path d="M13 5l7 7-7 7"/>' +
16698
+ '</svg>' +
16699
+ '<span class="update-card-version-chip update-card-version-latest">v' + escapeHtml(String(latestVer).replace(/^v/, "")) + '</span>' +
15984
16700
  '</div>' +
15985
- '<div class="notification-bubble-body">' +
15986
- escapeHtml(currentVer) + ' \u2192 ' + escapeHtml(latestVer) +
16701
+ '<div class="update-card-progress" id="update-card-progress" aria-hidden="true">' +
16702
+ '<div class="update-card-progress-track"><div class="update-card-progress-fill"></div></div>' +
15987
16703
  '</div>' +
15988
- '<div class="notification-bubble-actions">' +
15989
- '<button class="primary" id="update-bubble-action">\u7acb\u5373\u66f4\u65b0</button>' +
16704
+ '<div class="update-card-status hidden" id="update-card-status"></div>' +
16705
+ '<div class="update-card-actions">' +
16706
+ '<button class="update-card-action update-card-action-primary" id="update-bubble-action" type="button">' +
16707
+ '<span class="update-card-action-label">\u7acb\u5373\u66f4\u65b0</span>' +
16708
+ '</button>' +
15990
16709
  '</div>';
15991
16710
 
15992
- document.body.appendChild(bubble);
16711
+ document.body.appendChild(card);
15993
16712
 
15994
- var entry = { id: id, el: bubble };
16713
+ var entry = { id: id, el: card };
15995
16714
  notificationStack.push(entry);
15996
16715
  repositionNotifications();
15997
16716
 
15998
- var closeBtn = bubble.querySelector(".notification-bubble-close");
16717
+ var closeBtn = card.querySelector(".update-card-close");
15999
16718
  if (closeBtn) closeBtn.onclick = function() {
16000
16719
  dismissNotification(id);
16001
16720
  state._updateBubbleShown = false;
16002
16721
  };
16003
16722
 
16004
- var actionBtn = bubble.querySelector("#update-bubble-action");
16005
- var bodyEl = bubble.querySelector(".notification-bubble-body");
16723
+ var actionBtn = card.querySelector("#update-bubble-action");
16724
+ var actionLabel = card.querySelector(".update-card-action-label");
16725
+ var subtitleEl = card.querySelector("#update-card-subtitle");
16726
+ var statusEl = card.querySelector("#update-card-status");
16727
+ var progressEl = card.querySelector("#update-card-progress");
16728
+
16729
+ function setStatus(text, kind) {
16730
+ if (!statusEl) return;
16731
+ statusEl.textContent = text || "";
16732
+ statusEl.classList.remove("hidden", "error", "success");
16733
+ if (!text) { statusEl.classList.add("hidden"); return; }
16734
+ if (kind) statusEl.classList.add(kind);
16735
+ }
16736
+ function setSubtitle(text) {
16737
+ if (subtitleEl) subtitleEl.textContent = text || "";
16738
+ }
16739
+ function setProgress(active) {
16740
+ if (!progressEl) return;
16741
+ progressEl.classList.toggle("active", !!active);
16742
+ }
16006
16743
 
16007
16744
  if (actionBtn) actionBtn.onclick = function() {
16008
16745
  // Phase 1: Performing update
16009
16746
  actionBtn.disabled = true;
16010
- actionBtn.textContent = "\u66f4\u65b0\u4e2d\u2026";
16011
- if (bodyEl) bodyEl.textContent = "\u6b63\u5728\u4e0b\u8f7d\u5e76\u5b89\u88c5\u65b0\u7248\u672c\u2026";
16747
+ card.classList.add("is-busy");
16748
+ if (actionLabel) actionLabel.textContent = "\u66f4\u65b0\u4e2d\u2026";
16749
+ setSubtitle("\u6b63\u5728\u4e0b\u8f7d\u5e76\u5b89\u88c5\u65b0\u7248\u672c\u2026");
16750
+ setProgress(true);
16751
+ setStatus("");
16012
16752
 
16013
16753
  fetch("/api/update", {
16014
16754
  method: "POST",
@@ -16017,39 +16757,58 @@
16017
16757
  })
16018
16758
  .then(function(res) { return res.json(); })
16019
16759
  .then(function(data) {
16760
+ setProgress(false);
16761
+ card.classList.remove("is-busy");
16020
16762
  if (data.error) {
16021
16763
  // Update failed
16022
- if (bodyEl) {
16023
- bodyEl.textContent = data.error;
16024
- bodyEl.style.color = "var(--error)";
16025
- }
16764
+ setSubtitle("\u66f4\u65b0\u672a\u5b8c\u6210");
16765
+ setStatus(data.error, "error");
16026
16766
  actionBtn.disabled = false;
16027
- actionBtn.textContent = "\u91cd\u8bd5";
16767
+ if (actionLabel) actionLabel.textContent = "\u91cd\u8bd5";
16028
16768
  return;
16029
16769
  }
16030
16770
  // Phase 2: Update succeeded, show restart button
16031
- if (bodyEl) {
16032
- bodyEl.textContent = data.message || "\u66f4\u65b0\u5b8c\u6210";
16033
- bodyEl.style.color = "var(--success)";
16034
- }
16035
- actionBtn.textContent = "\u91cd\u542f\u751f\u6548";
16771
+ setSubtitle(data.message || "\u66f4\u65b0\u5b8c\u6210\uff0c\u91cd\u542f\u540e\u751f\u6548");
16772
+ setStatus("");
16773
+ card.classList.add("is-success");
16774
+ if (actionLabel) actionLabel.textContent = "\u91cd\u542f\u751f\u6548";
16036
16775
  actionBtn.disabled = false;
16037
- actionBtn.className = "primary success";
16038
16776
  actionBtn.onclick = function() {
16039
- performRestart(actionBtn, bodyEl);
16777
+ actionBtn.disabled = true;
16778
+ if (actionLabel) actionLabel.textContent = "\u6b63\u5728\u91cd\u542f\u2026";
16779
+ setSubtitle("\u670d\u52a1\u6b63\u5728\u91cd\u542f\u2026");
16780
+ setProgress(true);
16781
+ performRestartCard(actionBtn, actionLabel, subtitleEl, statusEl, progressEl);
16040
16782
  };
16041
16783
  })
16042
16784
  .catch(function() {
16043
- if (bodyEl) {
16044
- bodyEl.textContent = "\u66f4\u65b0\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u7f51\u7edc\u8fde\u63a5\u3002";
16045
- bodyEl.style.color = "var(--error)";
16046
- }
16785
+ setProgress(false);
16786
+ card.classList.remove("is-busy");
16787
+ setSubtitle("\u66f4\u65b0\u672a\u5b8c\u6210");
16788
+ setStatus("\u8bf7\u68c0\u67e5\u7f51\u7edc\u8fde\u63a5\u540e\u91cd\u8bd5", "error");
16047
16789
  actionBtn.disabled = false;
16048
- actionBtn.textContent = "\u91cd\u8bd5";
16790
+ if (actionLabel) actionLabel.textContent = "\u91cd\u8bd5";
16049
16791
  });
16050
16792
  };
16051
16793
  }
16052
16794
 
16795
+ // Restart driver used by the new update card.
16796
+ function performRestartCard(btn, labelEl, subtitleEl, statusEl, progressEl) {
16797
+ fetch("/api/restart", {
16798
+ method: "POST",
16799
+ headers: { "Content-Type": "application/json" },
16800
+ credentials: "same-origin"
16801
+ })
16802
+ .then(function(res) { return res.json(); })
16803
+ .then(function() {
16804
+ showRestartOverlay();
16805
+ })
16806
+ .catch(function() {
16807
+ // Network error likely means the server already shut down \u2014 show overlay anyway
16808
+ showRestartOverlay();
16809
+ });
16810
+ }
16811
+
16053
16812
  /**
16054
16813
  * Call POST /api/restart and show the restart overlay.
16055
16814
  */