@co0ontty/wand 1.21.12 → 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;
@@ -2852,118 +2905,205 @@
2852
2905
  function renderFileExplorer(cwd) {
2853
2906
  var root = cwd || getConfigCwd();
2854
2907
  if (!root) {
2855
- return '<div class="file-explorer empty">No working directory configured.</div>';
2908
+ return '<div class="file-explorer empty">未配置工作目录。</div>';
2856
2909
  }
2857
2910
  return '<div class="file-tree" id="file-tree" data-cwd="' + escapeHtml(root) + '">' +
2858
- '<div class="tree-loading">Loading...</div>' +
2911
+ '<div class="tree-loading">加载中…</div>' +
2859
2912
  '</div>';
2860
2913
  }
2861
2914
 
2862
- function refreshFileExplorer() {
2863
- var explorer = document.getElementById("file-explorer");
2864
- var cwdEl = document.getElementById("file-explorer-cwd");
2865
- if (!explorer) return;
2866
- // Get cwd from current session or config
2867
- 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;
2868
2984
  if (state.selectedId) {
2869
2985
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
2870
- if (session) cwd = session.cwd || "";
2871
- }
2872
- if (!cwd) {
2873
- cwd = getConfigCwd();
2986
+ if (session && session.cwd) return session.cwd;
2874
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();
2875
2997
  if (!cwd) {
2876
- explorer.innerHTML = '<div class="file-explorer empty">No working directory.</div>';
2998
+ explorer.innerHTML = '<div class="file-explorer empty">没有可显示的工作目录。</div>';
2877
2999
  return;
2878
3000
  }
3001
+ state.fileExplorerCwd = cwd;
2879
3002
  state.fileExplorerLoading = true;
2880
3003
  state.allFiles = [];
2881
- explorer.innerHTML = '<div class="file-explorer"><div class="tree-loading" style="padding:12px;color:var(--text-muted);font-size:0.8125rem;">Loading...</div></div>';
2882
- // Update the cwd display
2883
- if (cwdEl) cwdEl.textContent = cwd;
2884
- // Fetch with git status
2885
- 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" })
2886
3015
  .then(function(res) {
2887
- if (!res.ok) {
2888
- throw new Error("Failed to load directory.");
2889
- }
3016
+ if (!res.ok) throw new Error("Failed to load directory.");
2890
3017
  return res.json();
2891
3018
  })
2892
- .then(function(items) {
3019
+ .then(function(payload) {
2893
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
+ }
2894
3030
  if (!items || items.length === 0) {
2895
- explorer.innerHTML = '<div class="file-explorer empty">Empty directory or inaccessible.</div>';
3031
+ explorer.innerHTML = '<div class="file-explorer empty">空目录或无法访问。</div>';
2896
3032
  return;
2897
3033
  }
2898
3034
  state.allFiles = items;
3035
+ state.fileExplorerTruncated = truncated;
3036
+ state.fileExplorerTotal = total;
2899
3037
  filterFileTree();
2900
3038
  })
2901
3039
  .catch(function() {
2902
3040
  state.fileExplorerLoading = false;
2903
- explorer.innerHTML = '<div class="file-explorer empty">Failed to load files.</div>';
3041
+ explorer.innerHTML = '<div class="file-explorer empty">加载失败,请检查路径或权限。</div>';
2904
3042
  });
2905
3043
  }
2906
3044
 
2907
3045
  function filterFileTree() {
2908
3046
  var explorer = document.getElementById("file-explorer");
2909
- var cwdEl = document.getElementById("file-explorer-cwd");
2910
3047
  if (!explorer) return;
2911
- var cwd = cwdEl ? cwdEl.textContent : "";
3048
+ var cwd = state.fileExplorerCwd || "";
2912
3049
  if (!cwd) return;
2913
3050
 
2914
3051
  var query = state.fileSearchQuery;
2915
3052
  var items = state.allFiles || [];
3053
+ var filtered = items;
2916
3054
 
2917
- // 如果没有搜索词,显示所有文件
2918
- if (!query) {
2919
- explorer.innerHTML = '<div class="file-tree" id="file-tree" data-cwd="' + escapeHtml(cwd) + '">' +
2920
- items.map(function(item) {
2921
- return renderFileTreeItem(item);
2922
- }).join("") +
2923
- '</div>';
2924
- attachFileTreeListeners();
2925
- return;
3055
+ if (query) {
3056
+ var lowerQuery = query.toLowerCase();
3057
+ filtered = items.filter(function(item) {
3058
+ return item.name.toLowerCase().indexOf(lowerQuery) !== -1;
3059
+ });
2926
3060
  }
2927
3061
 
2928
- // 模糊匹配文件名(大小写不敏感)
2929
- var lowerQuery = query.toLowerCase();
2930
- var filtered = items.filter(function(item) {
2931
- return item.name.toLowerCase().indexOf(lowerQuery) !== -1;
2932
- });
2933
-
2934
3062
  if (filtered.length === 0) {
2935
- explorer.innerHTML = '<div class="file-explorer empty">没有找到匹配的文件</div>';
3063
+ explorer.innerHTML = '<div class="file-explorer empty">' + (query ? '没有找到匹配的文件' : '空目录') + '</div>';
2936
3064
  return;
2937
3065
  }
2938
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
+
2939
3073
  explorer.innerHTML = '<div class="file-tree" id="file-tree" data-cwd="' + escapeHtml(cwd) + '">' +
2940
3074
  filtered.map(function(item) {
2941
3075
  return renderFileTreeItem(item);
2942
3076
  }).join("") +
2943
- '</div>';
3077
+ '</div>' + truncatedNotice;
2944
3078
  attachFileTreeListeners();
2945
3079
  }
2946
3080
 
2947
- function renderFileTreeItem(item) {
3081
+ function renderFileTreeItem(item, depth) {
3082
+ depth = depth || 0;
2948
3083
  var name = escapeHtml(item.name);
2949
3084
  var isDir = item.type === "dir";
2950
- // Use clear emoji icons: 📁 for folders, 📄 for files
2951
- var displayIcon = isDir ? "📁" : "📄";
3085
+ var displayIcon = getFileIcon(item);
2952
3086
  var toggleIcon = isDir ? "▸" : "";
2953
3087
  var toggleClass = isDir ? "" : " empty";
2954
3088
  var gitStatus = item.gitStatus;
2955
3089
  var statusBadge = renderGitStatusBadge(gitStatus);
2956
- 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">' +
2957
3097
  '<span class="tree-toggle' + toggleClass + '">' + toggleIcon + '</span>' +
2958
3098
  '<span class="tree-icon">' + displayIcon + '</span>' +
2959
3099
  '<span class="tree-name">' + name + '</span>' +
3100
+ meta +
2960
3101
  (statusBadge ? '<span class="git-status-badge ' + statusBadge.class + '" title="' + statusBadge.title + '">' + statusBadge.text + '</span>' : '') +
2961
3102
  '</div>';
2962
3103
  }
2963
3104
 
2964
3105
  function renderGitStatusBadge(gitStatus) {
2965
3106
  if (!gitStatus) return null;
2966
- // Priority: staged > unstaged > untracked
2967
3107
  if (gitStatus.staged === "added") return { text: "A", class: "git-added", title: "已暂存(新增)" };
2968
3108
  if (gitStatus.staged === "modified") return { text: "M", class: "git-modified", title: "已暂存(修改)" };
2969
3109
  if (gitStatus.staged === "deleted") return { text: "D", class: "git-deleted", title: "已暂存(删除)" };
@@ -2978,19 +3118,44 @@
2978
3118
  var tree = document.getElementById("file-tree");
2979
3119
  if (!tree) return;
2980
3120
  tree.querySelectorAll(".tree-item[data-type='dir']").forEach(function(item) {
2981
- item.addEventListener("click", function() {
3121
+ item.addEventListener("click", function(e) {
3122
+ // Don't toggle when click came from the meta/badge area
2982
3123
  toggleTreeNode(item);
2983
3124
  });
2984
3125
  });
2985
3126
  tree.querySelectorAll(".tree-item[data-type='file']").forEach(function(item) {
2986
- item.addEventListener("dblclick", function() {
2987
- 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; }
2988
3153
  });
2989
3154
  });
2990
3155
  }
2991
3156
 
2992
3157
  function toggleTreeNode(item) {
2993
- var path = item.dataset.path;
3158
+ var p = item.dataset.path;
2994
3159
  var toggle = item.querySelector(".tree-toggle");
2995
3160
  var children = item.nextElementSibling;
2996
3161
 
@@ -2998,14 +3163,24 @@
2998
3163
  var isOpen = children.classList.contains("open");
2999
3164
  children.classList.toggle("open");
3000
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 ? "📁" : "📂";
3001
3169
  return;
3002
3170
  }
3003
3171
 
3004
- // Load children with git status
3005
3172
  if (toggle) toggle.classList.add("open");
3006
- 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" })
3007
3179
  .then(function(res) { return res.json(); })
3008
- .then(function(items) {
3180
+ .then(function(payload) {
3181
+ var items;
3182
+ if (Array.isArray(payload)) items = payload;
3183
+ else items = (payload && payload.items) || [];
3009
3184
  var childrenDiv = document.createElement("div");
3010
3185
  childrenDiv.className = "tree-children open";
3011
3186
  if (!items || items.length === 0) {
@@ -3021,79 +3196,456 @@
3021
3196
  .catch(function() {});
3022
3197
  }
3023
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
+
3024
3349
  function openFilePreview(filePath) {
3025
- var overlay = document.createElement("div");
3026
- overlay.className = "file-preview-overlay";
3027
- overlay.innerHTML =
3028
- '<div class="file-preview-modal">' +
3029
- '<div class="file-preview-header">' +
3030
- '<div class="file-preview-title">' +
3031
- '<span>📄</span>' +
3032
- '<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>' +
3033
3365
  '</div>' +
3034
- '<div class="file-preview-path" title="' + escapeHtml(filePath) + '">' + escapeHtml(filePath) + '</div>' +
3035
- '<button class="file-preview-close" title="Close">✕</button>' +
3036
- '</div>' +
3037
- '<div class="file-preview-body">' +
3038
- '<div class="file-preview-loading">Loading preview...</div>' +
3039
- '</div>' +
3040
- '</div>';
3041
- 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);
3042
3371
 
3043
- var closeBtn = overlay.querySelector(".file-preview-close");
3044
- var closeModal = function() {
3045
- overlay.remove();
3046
- document.removeEventListener("keydown", escHandler);
3047
- };
3048
- closeBtn.addEventListener("click", closeModal);
3049
- overlay.addEventListener("click", function(e) {
3050
- if (e.target === overlay) closeModal();
3051
- });
3052
- var escHandler = function(e) {
3053
- if (e.key === "Escape") closeModal();
3054
- };
3055
- 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
+ }
3056
3412
 
3057
3413
  fetch("/api/file-preview?path=" + encodeURIComponent(filePath), { credentials: "same-origin" })
3058
- .then(function(res) { return res.json(); })
3059
- .then(function(data) {
3060
- if (data.error) {
3061
- var body = overlay.querySelector(".file-preview-body");
3062
- 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
+ }
3063
3435
  return;
3064
3436
  }
3065
- renderPreviewContent(overlay, data);
3437
+ _activeFilePreview.data = result.data;
3438
+ renderPreviewContent(overlay, result.data);
3066
3439
  })
3067
- .catch(function(err) {
3440
+ .catch(function() {
3068
3441
  var body = overlay.querySelector(".file-preview-body");
3069
- 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>';
3070
3443
  });
3071
3444
  }
3072
3445
 
3073
- function renderPreviewContent(overlay, data) {
3074
- var filename = overlay.querySelector(".file-preview-filename");
3075
- 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
+ }
3076
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();
3077
3472
  var langBadge = document.createElement("span");
3078
3473
  langBadge.className = "file-preview-lang";
3079
- langBadge.textContent = data.lang || data.ext.replace(".", "");
3080
- 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);
3081
3481
 
3082
- var body = overlay.querySelector(".file-preview-body");
3482
+ renderPreviewToolbar(overlay, data);
3083
3483
 
3084
- if (data.lang === "markdown") {
3085
- 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>';
3086
3501
  } else {
3087
- var highlighted = highlightCodePreview(data.content, data.lang);
3088
- var lines = highlighted.split("\n");
3089
- var lineNums = lines.map(function(_, i) { return i + 1; });
3090
-
3091
- body.innerHTML =
3092
- '<div class="code-preview-wrapper">' +
3093
- '<div class="code-preview-lines">' + lineNums.join("\n") + '</div>' +
3094
- '<div class="code-preview-content"><pre>' + lines.join("\n") + '</pre></div>' +
3095
- '</div>';
3502
+ renderTextPreview(body, data);
3503
+ }
3504
+ }
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); }});
3096
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";
3097
3649
  }
3098
3650
 
3099
3651
  function highlightCodePreview(code, lang) {
@@ -4420,27 +4972,28 @@
4420
4972
  topbarMoreBtn.classList.remove("active");
4421
4973
  topbarMoreBtn.setAttribute("aria-expanded", "false");
4422
4974
  switch (action) {
4423
- case "settings":
4424
- openSettingsModal();
4975
+ case "copy-claude-session-id":
4976
+ copySelectedSessionField("claudeSessionId", "Claude 会话 ID 已复制");
4425
4977
  break;
4426
- case "refresh":
4427
- window.location.reload();
4978
+ case "copy-cwd":
4979
+ copySelectedSessionField("cwd", "工作目录已复制");
4428
4980
  break;
4429
- case "install":
4430
- if (state.deferredPrompt) {
4431
- state.deferredPrompt.prompt();
4432
- state.deferredPrompt.userChoice.then(function() {
4433
- state.deferredPrompt = null;
4434
- state.showInstallPrompt = false;
4435
- updateInstallPrompt();
4436
- });
4437
- }
4981
+ case "copy-session-id":
4982
+ copySelectedSessionField("id", "会话 ID 已复制");
4438
4983
  break;
4439
- case "logout":
4440
- logout();
4984
+ case "worktree-merge":
4985
+ if (state.selectedId) openWorktreeMergeModal(state.selectedId);
4441
4986
  break;
4442
- case "switch-server":
4443
- switchServer();
4987
+ case "worktree-cleanup":
4988
+ if (state.selectedId) retryWorktreeCleanup(state.selectedId);
4989
+ break;
4990
+ case "delete-session":
4991
+ if (state.selectedId) {
4992
+ var pendingId = state.selectedId;
4993
+ if (confirm("确定要删除当前会话吗?")) {
4994
+ deleteSession(pendingId);
4995
+ }
4996
+ }
4444
4997
  break;
4445
4998
  }
4446
4999
  });
@@ -4501,7 +5054,11 @@
4501
5054
  setChatAutoFollow(true, { scrollNow: true, smooth: true });
4502
5055
  });
4503
5056
  var fileRefresh = document.getElementById("file-explorer-refresh");
4504
- 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);
4505
5062
 
4506
5063
  // File search
4507
5064
  var fileSearchInput = document.getElementById("file-search-input");
@@ -5074,6 +5631,20 @@
5074
5631
  }
5075
5632
  }
5076
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
+
5077
5648
  /** Copy Claude session ID from badge to clipboard */
5078
5649
  function handleClaudeIdCopy(event) {
5079
5650
  var badge = event.target.closest("#claude-session-id-badge");