// 文件面板:右栏列表浏览/导航/删除/重命名、刷新、"选入"弹框(跨目录勾选复制/移动)、 // 拖拽上传 overlay + 上传(XHR 带进度)+ 上传状态条。 // 导出 loadFiles / scheduleFilesRefresh(SSE 文件事件触发刷新)/ closeSrcPicker(main Esc 关栈) // / uploadFiles(聊天区粘贴或拖拽文件复用)。其余入口在本模块顶层自绑。 // 反向依赖:openFilePreview(preview)、logout(auth)、以及 main 的 glue // downloadFile / selectTask / loadTaskList / loadFolderSuggestions(后续随 media/tasks/newtask 模块化再迁)。 import { state } from "./state.js"; import { $, showMenu } from "./dom.js"; import { api } from "./api.js"; import { escapeHtml, humanSize } from "./format.js"; import { openFilePreview } from "./preview.js"; import { logout } from "./auth.js"; import { downloadFile } from "./media.js"; import { selectTask, loadTaskList } from "./chat.js"; import { loadFolderSuggestions } from "./newtask.js"; // ───── files(user-rooted,不绑 task) ───── $("btn-refresh-files").onclick = () => loadFiles(); $("btn-upload").onclick = () => $("upload-input").click(); $("upload-input").addEventListener("change", uploadSelected); // ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)───── // 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B // 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set 全程保留;切换浏览路径不清空。 const srcPicker = { path: "", selected: new Set() }; async function openSrcPicker() { srcPicker.path = ""; srcPicker.selected.clear(); const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)"; $("sp-dest").textContent = destLabel; $("sp-dest").title = destLabel; syncSrcCount(); $("src-picker-modal").classList.add("show"); await loadSrcPicker(); } export function closeSrcPicker() { $("src-picker-modal").classList.remove("show"); srcPicker.path = ""; srcPicker.selected.clear(); } async function loadSrcPicker() { try { const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : ""; const data = await api("GET", "/v1/files" + qs); renderSrcPicker(data); } catch (e) { if (e.status === 401) { logout(); return; } $("sp-list").innerHTML = `
${escapeHtml(e.message)}
`; } } function renderSrcPicker(data) { const cr = data.crumbs.map((c, i) => { const label = i === 0 ? "我的" : c.label; const isLast = i === data.crumbs.length - 1; if (isLast) return `${escapeHtml(label)}`; return `${escapeHtml(label)} /`; }).join(" "); $("sp-crumbs").innerHTML = cr || `/`; $("sp-crumbs").querySelectorAll("a").forEach((a) => { a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); }; }); const entries = data.entries || []; if (!data.exists) { $("sp-list").innerHTML = `
(目录尚未创建)
`; return; } if (!entries.length) { $("sp-list").innerHTML = `
(空目录)
`; return; } // 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled const destPath = state.filesPath || ""; const sameAsDest = srcPicker.path === destPath; $("sp-list").innerHTML = entries.map((e) => { const cls = e.is_dir ? "ico-dir" : "ico-file"; const checked = srcPicker.selected.has(e.rel) ? " checked" : ""; const disabled = sameAsDest ? " disabled" : ""; const fullTitle = e.rel || e.name; return `
${escapeHtml(e.name)} ${humanSize(e.size)}
`; }).join(""); $("sp-list").querySelectorAll(".sp-name").forEach((el) => { el.onclick = () => { if (el.dataset.isdir === "true") { srcPicker.path = el.dataset.rel; loadSrcPicker(); } }; }); $("sp-list").querySelectorAll(".sp-cb").forEach((cb) => { cb.onchange = () => { const rel = cb.dataset.rel; if (cb.checked) srcPicker.selected.add(rel); else srcPicker.selected.delete(rel); syncSrcCount(); }; }); } function syncSrcCount() { const n = srcPicker.selected.size; $("sp-count").textContent = String(n); $("sp-copy").disabled = n === 0; $("sp-move").disabled = n === 0; } async function doSrcTransfer(mode) { const sources = [...srcPicker.selected]; if (!sources.length) return; const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move"; const verb = mode === "copy" ? "复制" : "移动"; try { await api("POST", endpoint, { paths: sources, dest_dir: state.filesPath || "", }); closeSrcPicker(); await loadFiles(); await loadFolderSuggestions(); } catch (e) { if (e.status === 401) { logout(); return; } alert(verb + "失败:" + e.message); } } $("btn-src-pick").onclick = openSrcPicker; $("sp-cancel").onclick = closeSrcPicker; $("sp-copy").onclick = () => doSrcTransfer("copy"); $("sp-move").onclick = () => doSrcTransfer("move"); $("src-picker-modal").addEventListener("click", (e) => { if (e.target.id === "src-picker-modal") closeSrcPicker(); }); // ───── 拖拽上传到主区(目的地 = state.filesPath)───── // 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。 let _dragDepth = 0; function _hasFiles(ev) { const t = ev.dataTransfer; if (!t) return false; if (t.types && [...t.types].includes("Files")) return true; return false; } $("pane-right").addEventListener("dragenter", (e) => { if (!_hasFiles(e)) return; e.preventDefault(); _dragDepth++; $("file-droparea").classList.add("show"); }); $("pane-right").addEventListener("dragover", (e) => { if (!_hasFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }); $("pane-right").addEventListener("dragleave", (e) => { if (!_hasFiles(e)) return; _dragDepth = Math.max(0, _dragDepth - 1); if (_dragDepth === 0) $("file-droparea").classList.remove("show"); }); $("pane-right").addEventListener("drop", async (e) => { if (!_hasFiles(e)) return; e.preventDefault(); _dragDepth = 0; $("file-droparea").classList.remove("show"); const files = Array.from(e.dataTransfer.files || []); if (!files.length) return; // 落点轻回弹脉冲:一次性,动画结束自摘(避免再次拖入不触发) const pane = $("pane-right"); pane.classList.remove("drop-pulse"); void pane.offsetWidth; // 强制 reflow 让动画可重放 pane.classList.add("drop-pulse"); pane.addEventListener("animationend", () => pane.classList.remove("drop-pulse"), { once: true }); await uploadFilesWithPaneStatus(files); }); // 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API let _filesRefreshTimer = null; export function scheduleFilesRefresh() { clearTimeout(_filesRefreshTimer); _filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500); } export async function loadFiles() { try { const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : ""; const data = await api("GET", "/v1/files" + qs); renderFiles(data); } catch (e) { if (e.status === 401) { logout(); return; } $("file-crumbs").innerHTML = `${escapeHtml(e.message)}`; $("file-list").innerHTML = ""; } } // 切换文件面板浏览路径 function navFiles(newPath) { state.filesPath = newPath || ""; loadFiles(); } function renderFiles(data) { // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 const segs = (data.current || "").split("/").filter(Boolean); const projName = segs[0] || ""; // 名称过长时显示前 11 字符 + …,完整名留 title 提示(避免顶栏挤压"文件"换行) const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName; $("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)"; $("files-proj").title = projName || data.root || ""; // crumbs root 标"我的"(user_root),更直观;其余原样 const cr = data.crumbs.map((c, i) => { const label = i === 0 ? "我的" : c.label; const isLast = i === data.crumbs.length - 1; if (isLast) return `${escapeHtml(label)}`; return `${escapeHtml(label)} /`; }).join(" "); $("file-crumbs").innerHTML = cr || `/`; $("file-crumbs").querySelectorAll("a").forEach((a) => { a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); }; }); if (!data.exists) { $("file-list").innerHTML = `
(目录尚未创建)
`; state.entriesByRel = {}; return; } if (!data.entries.length) { $("file-list").innerHTML = `
(空目录)
`; state.entriesByRel = {}; return; } state.entriesByRel = {}; for (const e of data.entries) state.entriesByRel[e.rel] = e; $("file-list").innerHTML = data.entries.map((e) => { const cls = e.is_dir ? "ico-dir" : "ico-file"; const fullTitle = e.rel || e.name; return `
${escapeHtml(e.name)} ${humanSize(e.size)}
`; }).join(""); $("file-list").querySelectorAll(".name").forEach((el) => { el.style.cursor = "pointer"; el.onclick = () => { const rel = el.dataset.rel; if (el.dataset.isdir === "true") { navFiles(rel); } else { openFilePreview(rel); } }; }); $("file-list").querySelectorAll(".file-menu").forEach((btn) => { btn.onclick = (ev) => { ev.stopPropagation(); const e = state.entriesByRel[btn.dataset.rel]; if (!e) return; showMenu(btn, fileMenuItems(e)); }; }); } function fileMenuItems(e) { const items = [ { act: "rename", label: "重命名", cls: "act-rename", onclick: () => renameFile(e.rel, e.name, e.is_dir) }, ]; if (!e.is_dir) { items.push({ act: "download", label: "下载", cls: "act-download", onclick: () => downloadFile(e.rel) }); } items.push({ act: "delete", label: "删除", cls: "act-delete", onclick: () => deleteFile(e.rel, e.name, e.is_dir) }); return items; } async function deleteFile(rel, name, isDir) { let recursive = false; if (!isDir) { if (!confirm(`确认删除文件 "${name}"?`)) return; } else { // 探一下目录内容:空目录走普通 rmdir;非空才递归,二次确认显示条目数 let entries; try { const data = await api("GET", "/v1/files?path=" + encodeURIComponent(rel)); entries = data.entries || []; } catch (e) { if (e.status === 401) { logout(); return; } alert("读目录失败:" + e.message); return; } if (entries.length === 0) { if (!confirm(`确认删除空目录 "${name}"?`)) return; } else { const hasSub = entries.some((x) => x.is_dir); const tip = hasSub ? "(含子目录)" : ""; if (!confirm( `目录 "${name}" 含 ${entries.length} 项${tip},` + `将递归删除全部内容,不可恢复。\n` + `(若为顶层目录且仍被 task 引用,需先删 task)\n确认?` )) return; recursive = true; } } try { await api("POST", "/v1/files/delete", { path: rel, recursive }); await loadFiles(); // 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用 await loadFolderSuggestions(); } catch (e) { if (e.status === 401) { logout(); return; } alert("删除失败:" + e.message); } } async function renameFile(rel, name, isDir) { const what = isDir ? "目录" : "文件"; const newName = prompt(`将${what} "${name}" 重命名为:`, name); if (newName == null) return; const trimmed = newName.trim(); if (!trimmed || trimmed === name) return; try { const res = await api("POST", "/v1/files/rename", { path: rel, new_name: trimmed }); // 面板若停在被改名的子树里,做前缀替换继续停留在等价位置 if (state.filesPath === rel) { state.filesPath = res.new; } else if (state.filesPath && state.filesPath.startsWith(rel + "/")) { state.filesPath = res.new + state.filesPath.slice(rel.length); } await loadFolderSuggestions(); // 顶层目录改名 → tasks_updated>0,任务列表 / 当前 task 头里的 working_dir 都得刷 if (res && res.tasks_updated > 0) { await loadTaskList(); if (state.taskId) { await selectTask(state.taskId); return; } } await loadFiles(); } catch (e) { if (e.status === 401) { logout(); return; } alert("重命名失败:" + e.message); } } function uploadTotalBytes(files) { return (files || []).reduce((sum, f) => sum + (f.size || 0), 0); } function uploadFilesLabel(files) { if (!files || !files.length) return ""; return files.length === 1 ? files[0].name : `${files[0].name} 等 ${files.length} 个文件`; } export function formatUploadProgress(files, loaded, total) { const denom = total || uploadTotalBytes(files); const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; const sizeText = denom ? ` · ${humanSize(Math.min(loaded, denom))}/${humanSize(denom)}` : ""; return `上传中 ${pct}% · ${uploadFilesLabel(files)}${sizeText}`; } function setPaneUploadStatus(files, loaded, total) { const el = $("file-upload-status"); const denom = total || uploadTotalBytes(files); const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; el.classList.add("show"); el.innerHTML = `${escapeHtml(formatUploadProgress(files, loaded, total))}
`; } function finishPaneUploadStatus(ok, files) { const el = $("file-upload-status"); el.classList.add("show"); el.innerHTML = ok ? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}
` : `上传失败 · ${escapeHtml(uploadFilesLabel(files))}`; setTimeout(() => { if (el.textContent.startsWith(ok ? "上传完成" : "上传失败")) { el.classList.remove("show"); el.innerHTML = ""; } }, 3500); } async function uploadFilesWithPaneStatus(files) { if (!files || !files.length) return null; setPaneUploadStatus(files, 0, uploadTotalBytes(files)); const saved = await uploadFiles(files, { onProgress: (loaded, total) => setPaneUploadStatus(files, loaded, total), }); finishPaneUploadStatus(!!(saved && saved.length), files); return saved; } export async function uploadFiles(files, opts = {}) { if (!files || !files.length) return null; const fd = new FormData(); fd.append("path", state.filesPath || ""); for (const f of files) fd.append("files", f); try { const data = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/v1/files/upload"); xhr.setRequestHeader("Authorization", "Bearer " + state.token); xhr.upload.onprogress = (ev) => { if (opts.onProgress && ev.lengthComputable) opts.onProgress(ev.loaded, ev.total); }; xhr.onerror = () => reject(new Error("网络错误,上传失败")); xhr.onload = () => { let payload = {}; try { payload = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch (_) { payload = {}; } if (xhr.status < 200 || xhr.status >= 300) { const err = new Error(payload.detail || (xhr.status + " 上传失败")); err.status = xhr.status; reject(err); return; } resolve(payload); }; if (opts.onProgress) opts.onProgress(0, uploadTotalBytes(files)); xhr.send(fd); }); await loadFiles(); return data.saved || []; } catch (e) { if (e.status === 401) { logout(); return null; } alert("上传失败:" + e.message); return null; } } async function uploadSelected() { const inp = $("upload-input"); const files = Array.from(inp.files || []); try { await uploadFilesWithPaneStatus(files); } finally { inp.value = ""; // 允许重新选同名文件 } }