diff --git a/PROGRESS.md b/PROGRESS.md index e924081..bd02528 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-20(files API 加 copy/move 跨目录搬动:多选 + 弹框目录选择;move 闸住"顶层目录是某 task working_dir"维持 invariant) +最后更新:2026-05-20(files SPA UX 翻面:主区干掉行 checkbox / 黄 bar / 全选,改成"选入到此处"弹框 — 目的地是主区当前路径,源在弹框里跨目录勾;附拖拽上传 + 修一处 `input[type=checkbox]` 被全局 `width:100%` 撑爆的 layout bug) --- @@ -23,7 +23,8 @@ ### 2026-05-20 -- **`POST /v1/files/copy` + `/v1/files/move` 跨目录批量搬动 + dev SPA 多选 + 目录选择弹框**:用户要"在文件夹间复制/移动文件"。后端两路由共用 `_validate_transfer` 预检 helper(批量原子校验:源存在、不能等于/含 dest、不在 dest 直接子级、批内重名、target 已存 409,任一失败整批 abort,无 FS 副作用)。**move 加额外闸**:任一源是顶层目录且为某 task `working_dir` → 409(维持"working_dir = 顶层目录"invariant — 允许沉到子目录后,rename 顶层只更新当前层 task 的 DB-aware 逻辑会失效,代码复杂度翻倍才能扛住嵌套场景;用户想归档项目目录:先 DELETE task)。**copy 无此闸**,新副本无 task 关联。dev SPA:`.file-row` 加 `` 列 + 顶栏 `#files-selall` 三态(全/半/无),选中 ≥1 出黄底 toolbar(`复制到…` / `移动到…` / `取消选中`)。目录选择弹框 `#dir-picker-modal` 复用 `/v1/files` 浏览(只列目录,面包屑可点回上层,源目录灰禁),底部按钮文案随 mode 切。`state.selectedFiles` 切 task / 切 filesPath 时清,refresh 后剔除已不存在的 rel 保 view 一致。**部分失败**:沿用现有 rename / delete 单向语义,FS 中途失败抛 500 + 已成功项保留(`shutil.move/copytree` 失败几乎只在跨卷断连 / 磁盘满,workspace 同盘罕见)。**没动**:DESIGN(API 添加非语义变更)、RUN(无 CLI / env 变化)、DB schema。 +- **files SPA UX 翻面 + 拖拽上传 + 修 checkbox 全局 width bug**:沿用上条新加的两路由,但前端 UX 整套换。**原模型**(select-then-pick-dest):主区行带 checkbox + 顶栏全选三态 + 黄 bar(复制到 / 移动到 / 取消)→ 弹框选目标目录。**新模型**(at-dest-pull-sources):主区只读浏览,顶栏加 `[选入…]` 按钮 → 弹框内浏览任意目录 + 跨目录勾文件 / 子目录(`Set` 跨切换保留)+ 底部 `[复制到此处]` `[移动到此处]` 两按钮直接落到主区当前 `state.filesPath`。**理由**:用户切任务时主区自动跳 task working_dir,绝大多数操作是"把外面素材喂进当前 working_dir",destination-first 比 source-first 少一次心智切换,且主区干净。**附带**:① 主区 `` 被全局 `input{ width:100%; }` 撑成全行宽 → 把 `.name`(`flex: 1; flex-basis: 0`)挤成 0 宽,行里只剩看不见的文字 + 居中的 checkbox(用户报"看不到文字"),根因不修永远埋雷,改 selector 排除 checkbox/radio/file。② 拖拽上传:`#pane-right` 监听 dragenter/over/leave/drop,有 `Files` 才响应(忽略文本拖拽),`#file-droparea` 红色虚线 overlay,落点 = `state.filesPath`,沿用 `/v1/files/upload`。**删了**:`state.selectedFiles` + `syncBulkBar` + `dirPicker` 模块 + 顶栏 selall + 黄 bar 整块 + 行 checkbox 渲染(按 CLAUDE.md 不留旧 UX)。**没动**:后端 `/v1/files/copy` `/v1/files/move`(同样的 `paths` + `dest_dir`)、DESIGN、RUN。 +- **`POST /v1/files/copy` + `/v1/files/move` 跨目录批量搬动**(原"+ dev SPA 多选 + 目录选择弹框"已被上一条翻面替换):用户要"在文件夹间复制/移动文件"。后端两路由共用 `_validate_transfer` 预检 helper(批量原子校验:源存在、不能等于/含 dest、不在 dest 直接子级、批内重名、target 已存 409,任一失败整批 abort,无 FS 副作用)。**move 加额外闸**:任一源是顶层目录且为某 task `working_dir` → 409(维持"working_dir = 顶层目录"invariant — 允许沉到子目录后,rename 顶层只更新当前层 task 的 DB-aware 逻辑会失效,代码复杂度翻倍才能扛住嵌套场景;用户想归档项目目录:先 DELETE task)。**copy 无此闸**,新副本无 task 关联。dev SPA:`.file-row` 加 `` 列 + 顶栏 `#files-selall` 三态(全/半/无),选中 ≥1 出黄底 toolbar(`复制到…` / `移动到…` / `取消选中`)。目录选择弹框 `#dir-picker-modal` 复用 `/v1/files` 浏览(只列目录,面包屑可点回上层,源目录灰禁),底部按钮文案随 mode 切。`state.selectedFiles` 切 task / 切 filesPath 时清,refresh 后剔除已不存在的 rel 保 view 一致。**部分失败**:沿用现有 rename / delete 单向语义,FS 中途失败抛 500 + 已成功项保留(`shutil.move/copytree` 失败几乎只在跨卷断连 / 磁盘满,workspace 同盘罕见)。**没动**:DESIGN(API 添加非语义变更)、RUN(无 CLI / env 变化)、DB schema。 - **working_dir 视为可重生 FS 视图**:DB 是 source of truth,FS 目录可独立删 / 用户手动 rmtree / 跨机器迁移丢失,**下次跑就自动 mkdir 重建**。三处改:① `DELETE /v1/tasks/{id}` 删完后若同 user 下再无 task 引用此 working_dir 且 FS 目录为空 → best-effort `rmdir` 清孤儿(非空 / 不存在 / 外部 --working-dir 静默跳过)。② `POST /v1/files/delete` 顶层目录去掉「有 task 引用就 409」闸,允许独立删空目录,task.working_dir 字段不动。③ `core/agent_builder.py::build_agent` 把 `working_dir_path.mkdir(parents=True, exist_ok=True)` 从 `if not resume:` 里挪出,resume 也兜底建目录(用户手删 FS 后再 send message 不会炸)。smoke `scripts/smoke_files_rename.py` 增 case 4 (200 + working_dir 不变) / case 8 (DELETE task 空目录自动清) / case 9 (非空目录保留),全 9 pass。**没动**:DB schema、rename 顶层目录的同步 UPDATE 逻辑(rename 是明确改名,和"删后重生"语义不同)、外部 --working-dir(DB 绝对串)的清理(避免误删用户外部项目)。 ### 2026-05-19 diff --git a/web/static/dev.html b/web/static/dev.html index 4bbf9e4..915332a 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -43,10 +43,12 @@ button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } button.primary:hover { filter: brightness(1.08); } button.danger:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } - input, textarea, select { + input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), + textarea, select { background: #fff; border: 1px solid var(--border); padding: 5px 8px; border-radius: 4px; width: 100%; } + input[type="checkbox"], input[type="radio"] { cursor: pointer; } textarea { resize: vertical; min-height: 60px; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } @@ -313,69 +315,82 @@ align-items: center; gap: 8px; } .file-row:hover { background: var(--hover); } - .file-row.selected { background: var(--accent-soft); } - .file-row .row-cb { margin: 0; cursor: pointer; flex-shrink: 0; } .file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .file-row .size { font-size: 11px; color: var(--muted); font-family: monospace; } .ico-dir::before { content: "▸ "; color: var(--accent); } .ico-file::before { content: "· "; color: var(--muted); } - .selall-wrap { display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; } - .selall-wrap input { margin: 0; cursor: pointer; } - /* 多选 toolbar:有选中时出,黄色背景区分 */ - #files-bulkbar { - display: none; padding: 6px 12px; gap: 6px; - border-bottom: 1px solid var(--border); background: #fff8d6; - align-items: center; font-size: 12px; - } - #files-bulkbar.show { display: flex; } - #files-bulkbar .count { color: #6a5; font-weight: 500; } - #files-bulkbar .spacer { flex: 1; } - #btn-bulk-copy { color: #1565c0; border-color: #aed6f1; } - #btn-bulk-copy:hover { background: #ebf5fb; } - #btn-bulk-move { color: #c77800; border-color: #f5cba7; } - #btn-bulk-move:hover { background: #fef5e7; } - /* ───── dir picker modal(复制/移动目标选择) ───── */ - #dir-picker-modal { + /* 拖拽上传 overlay:hover 整个 pane-right 时铺一层提示 */ + #pane-right { position: relative; } + #file-droparea { + position: absolute; inset: 0; pointer-events: none; + display: none; align-items: center; justify-content: center; + background: rgba(192,57,43,0.06); border: 2px dashed var(--accent); + color: var(--accent); font-size: 14px; font-weight: 500; + z-index: 10; + } + #file-droparea.show { display: flex; } + + /* ───── source picker modal(选入文件:勾源 + 复制/移动到主区当前目录) ───── */ + #src-picker-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: none; align-items: center; justify-content: center; z-index: 95; } - #dir-picker-modal.show { display: flex; } - #dir-picker-modal .card { + #src-picker-modal.show { display: flex; } + #src-picker-modal .card { background: var(--panel); border-radius: 6px; - width: 520px; max-height: 80vh; + width: 560px; max-height: 82vh; display: flex; flex-direction: column; box-shadow: 0 8px 24px rgba(0,0,0,.15); } - #dir-picker-modal h3 { + #src-picker-modal h3 { margin: 0; padding: 14px 18px; font-size: 16px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 8px; } - #dir-picker-modal .hint { + #src-picker-modal h3 .dest { + font-size: 12px; color: var(--muted); font-weight: 400; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + #src-picker-modal .hint { padding: 8px 18px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); } - #dp-crumbs { + #sp-crumbs { padding: 8px 14px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; } - #dp-crumbs a { margin-right: 4px; } - #dp-list { + #sp-crumbs a { margin-right: 4px; } + #sp-list { flex: 1; overflow: auto; - min-height: 220px; max-height: 50vh; + min-height: 240px; max-height: 50vh; } - #dp-list .dir-row { - padding: 8px 14px; border-bottom: 1px solid var(--border); cursor: pointer; + #sp-list .sp-row { + padding: 6px 14px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 8px; font-size: 13px; } - #dp-list .dir-row:hover { background: var(--hover); } - #dp-list .dir-row.disabled { color: var(--muted); cursor: not-allowed; } - #dp-list .dir-row.disabled:hover { background: transparent; } - #dp-list .empty { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; } - #dir-picker-modal .actions { - padding: 12px 18px; border-top: 1px solid var(--border); - display: flex; gap: 8px; justify-content: flex-end; + #sp-list .sp-row:hover { background: var(--hover); } + #sp-list .sp-row .sp-cb { flex-shrink: 0; margin: 0; } + #sp-list .sp-row .sp-name { + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + cursor: pointer; } + #sp-list .sp-row.disabled .sp-name { color: var(--muted); cursor: not-allowed; } + #sp-list .sp-row .sp-size { font-size: 11px; color: var(--muted); font-family: monospace; } + #sp-list .empty { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; } + #src-picker-modal .actions { + padding: 12px 18px; border-top: 1px solid var(--border); + display: flex; gap: 8px; align-items: center; + } + #src-picker-modal .actions .count { + flex: 1; font-size: 12px; color: var(--muted); + } + #sp-copy { color: #1565c0; border-color: #aed6f1; } + #sp-copy:hover:not(:disabled) { background: #ebf5fb; } + #sp-move { color: #c77800; border-color: #f5cba7; } + #sp-move:hover:not(:disabled) { background: #fef5e7; } + #sp-copy:disabled, #sp-move:disabled { opacity: 0.4; cursor: not-allowed; } /* ───── new task modal ───── */ #new-task-modal { @@ -587,36 +602,32 @@ 文件 - - + +
加载中…
-
- 已选 0 - - - - -
+
松开以上传到当前目录
- -
+ +
-

选择目标目录

-
浏览到目标目录,然后点击底部按钮确认
-
-
+

+ 选入到 + +

+
勾选要带入的文件 / 目录(可跨目录,选择跨切换保留);底部按钮把它们复制或移动到此处。
+
+
- - + 已选 0 + + +
@@ -675,8 +686,6 @@ const state = { taskId: null, taskMeta: null, filesPath: "", - // 文件多选状态:rel 路径集合;路径切换 / 复制移动成功后清空,refresh 保留(仅剔除已不存在的) - selectedFiles: new Set(), evtSrc: null, streaming: false, // 当前是否在流式中;true 时显示 stop 按钮 // task list 分页 + 筛选 @@ -1068,7 +1077,6 @@ async function selectTask(tid) { // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录 const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : ""; state.filesPath = wdName || ""; - state.selectedFiles.clear(); // 切 task 跨 view 重置选中 await loadFiles(); } catch (e) { if (e.status === 401) { logout(); return; } @@ -1441,116 +1449,110 @@ $("btn-upload").onclick = () => $("upload-input").click(); $("chat-upload").onclick = () => $("upload-input").click(); $("upload-input").addEventListener("change", uploadSelected); -// 顶栏全选 checkbox:三态 — 全未选 → 全选;部分/全选 → 清空 -$("files-selall").onchange = () => { - const rels = Object.keys(state.entriesByRel || {}); - if (!rels.length) return; - const allSelected = rels.every((r) => state.selectedFiles.has(r)); - if (allSelected) rels.forEach((r) => state.selectedFiles.delete(r)); - else rels.forEach((r) => state.selectedFiles.add(r)); - // 不需全量重渲染(行已在 DOM),逐行反映 selection - document.querySelectorAll("#file-list .file-row").forEach((row) => { - const sel = state.selectedFiles.has(row.dataset.rel); - row.classList.toggle("selected", sel); - const cb = row.querySelector(".row-cb"); - if (cb) cb.checked = sel; - }); - syncBulkBar(); -}; +// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)───── +// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B +// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set 全程保留;切换浏览路径不清空。 +const srcPicker = { path: "", selected: new Set() }; -// 多选 toolbar -$("btn-bulk-copy").onclick = () => openDirPicker("copy"); -$("btn-bulk-move").onclick = () => openDirPicker("move"); -$("btn-bulk-clear").onclick = () => { - state.selectedFiles.clear(); - document.querySelectorAll("#file-list .file-row").forEach((row) => { - row.classList.remove("selected"); - const cb = row.querySelector(".row-cb"); - if (cb) cb.checked = false; - }); - syncBulkBar(); -}; - -// ───── 目录选择 modal(复制 / 移动 目标)───── -const dirPicker = { mode: null, sources: [], path: "" }; - -async function openDirPicker(mode) { - const sources = [...state.selectedFiles]; - if (!sources.length) return; - dirPicker.mode = mode; - dirPicker.sources = sources; - dirPicker.path = ""; - const isCopy = mode === "copy"; - $("dp-title").textContent = isCopy ? "复制到…" : "移动到…"; - $("dp-confirm").textContent = isCopy ? "在此处复制" : "在此处移动"; - $("dp-confirm").className = isCopy ? "primary" : "primary"; // 都用 primary;若想区分色再改 - $("dp-hint").textContent = - (isCopy ? "将 " : "将 ") + sources.length + " 项" + - (isCopy ? "复制" : "移动") + - "到下面浏览到的目录;源目录灰禁不可选(不能放到自己里)"; - $("dir-picker-modal").classList.add("show"); - await loadDirPicker(); +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(); } -function closeDirPicker() { - $("dir-picker-modal").classList.remove("show"); - dirPicker.mode = null; - dirPicker.sources = []; - dirPicker.path = ""; +function closeSrcPicker() { + $("src-picker-modal").classList.remove("show"); + srcPicker.path = ""; + srcPicker.selected.clear(); } -async function loadDirPicker() { +async function loadSrcPicker() { try { - const qs = dirPicker.path ? "?path=" + encodeURIComponent(dirPicker.path) : ""; + const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : ""; const data = await api("GET", "/v1/files" + qs); - renderDirPicker(data); + renderSrcPicker(data); } catch (e) { if (e.status === 401) { logout(); return; } - $("dp-list").innerHTML = `
${escapeHtml(e.message)}
`; + $("sp-list").innerHTML = `
${escapeHtml(e.message)}
`; } } -function renderDirPicker(data) { - // crumbs(可点回上层) +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(" "); - $("dp-crumbs").innerHTML = cr || `/`; - $("dp-crumbs").querySelectorAll("a").forEach((a) => { - a.onclick = (e) => { e.preventDefault(); dirPicker.path = a.dataset.rel; loadDirPicker(); }; + $("sp-crumbs").innerHTML = cr || `/`; + $("sp-crumbs").querySelectorAll("a").forEach((a) => { + a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); }; }); - // 只列目录;源 dir 本身灰禁(server 也会拒,UI 提前拦更友好) - const dirs = ((data.entries || []).filter((e) => e.is_dir)); - const srcSet = new Set(dirPicker.sources); - if (!dirs.length) { - $("dp-list").innerHTML = `
(无子目录)
`; - } else { - $("dp-list").innerHTML = dirs.map((e) => { - const isSrc = srcSet.has(e.rel); - const cls = "dir-row" + (isSrc ? " disabled" : ""); - const title = isSrc ? "源目录,不能放进自己" : "进入"; - return `
${escapeHtml(e.name)}
`; - }).join(""); - $("dp-list").querySelectorAll(".dir-row:not(.disabled)").forEach((row) => { - row.onclick = () => { dirPicker.path = row.dataset.rel; loadDirPicker(); }; - }); + 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" : ""; + 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(); + }; + }); } -async function confirmDirPicker() { - if (!dirPicker.mode) return; - const endpoint = dirPicker.mode === "copy" ? "/v1/files/copy" : "/v1/files/move"; - const verb = dirPicker.mode === "copy" ? "复制" : "移动"; +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 { - const res = await api("POST", endpoint, { - paths: dirPicker.sources, - dest_dir: dirPicker.path, + await api("POST", endpoint, { + paths: sources, + dest_dir: state.filesPath || "", }); - closeDirPicker(); - state.selectedFiles.clear(); + closeSrcPicker(); await loadFiles(); await loadFolderSuggestions(); } catch (e) { @@ -1559,11 +1561,47 @@ async function confirmDirPicker() { } } -$("dp-cancel").onclick = closeDirPicker; -$("dp-confirm").onclick = confirmDirPicker; -$("dir-picker-modal").addEventListener("click", (e) => { - // 点遮罩(card 外)关闭 - if (e.target.id === "dir-picker-modal") closeDirPicker(); +$("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; + await uploadFiles(files); }); // 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API @@ -1585,10 +1623,9 @@ async function loadFiles() { } } -// 切换文件面板浏览路径:重置多选(选中是 per-view 概念),然后加载 +// 切换文件面板浏览路径 function navFiles(newPath) { state.filesPath = newPath || ""; - state.selectedFiles.clear(); loadFiles(); } @@ -1614,28 +1651,19 @@ function renderFiles(data) { if (!data.exists) { $("file-list").innerHTML = `
(目录尚未创建)
`; state.entriesByRel = {}; - syncBulkBar(); return; } if (!data.entries.length) { $("file-list").innerHTML = `
(空目录)
`; state.entriesByRel = {}; - syncBulkBar(); return; } state.entriesByRel = {}; for (const e of data.entries) state.entriesByRel[e.rel] = e; - // refresh 后剔除已不在当前视图的选中项(避免幽灵选中跨视图残留) - for (const r of [...state.selectedFiles]) { - if (!(r in state.entriesByRel)) state.selectedFiles.delete(r); - } $("file-list").innerHTML = data.entries.map((e) => { const cls = e.is_dir ? "ico-dir" : "ico-file"; - const sel = state.selectedFiles.has(e.rel); - const rowCls = "file-row" + (sel ? " selected" : ""); return ` -
- +
${escapeHtml(e.name)} @@ -1660,35 +1688,6 @@ function renderFiles(data) { showMenu(btn, fileMenuItems(e)); }; }); - $("file-list").querySelectorAll(".row-cb").forEach((cb) => { - cb.onclick = (ev) => ev.stopPropagation(); // 防 row 命中导航 - cb.onchange = (ev) => { - const rel = cb.dataset.rel; - if (cb.checked) state.selectedFiles.add(rel); - else state.selectedFiles.delete(rel); - const row = cb.closest(".file-row"); - if (row) row.classList.toggle("selected", cb.checked); - syncBulkBar(); - }; - }); - syncBulkBar(); -} - -// 同步多选 toolbar 显隐 + 计数 + 顶栏全选 checkbox(全选 / 半选 / 未选三态) -function syncBulkBar() { - const n = state.selectedFiles.size; - $("files-bulk-count").textContent = String(n); - $("files-bulkbar").classList.toggle("show", n > 0); - const rels = Object.keys(state.entriesByRel || {}); - const cb = $("files-selall"); - if (!rels.length) { - cb.checked = false; cb.indeterminate = false; cb.disabled = true; - } else { - cb.disabled = false; - const selN = rels.reduce((acc, r) => acc + (state.selectedFiles.has(r) ? 1 : 0), 0); - cb.checked = selN === rels.length; - cb.indeterminate = selN > 0 && selN < rels.length; - } } function fileMenuItems(e) { @@ -1996,15 +1995,13 @@ $("file-preview-modal").addEventListener("click", (e) => { }); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; - // 多模态共存:优先关靠前栈顶 — 目录选择(z 95)→ 文件预览(z 90)→ 新任务(z 80) - if ($("dir-picker-modal").classList.contains("show")) { closeDirPicker(); return; } + // 多模态共存:优先关靠前栈顶 — 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80) + if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } }); -async function uploadSelected() { - const inp = $("upload-input"); - const files = Array.from(inp.files || []); - if (!files.length) return; +async function uploadFiles(files) { + if (!files || !files.length) return; const fd = new FormData(); fd.append("path", state.filesPath || ""); for (const f of files) fd.append("files", f); @@ -2021,6 +2018,14 @@ async function uploadSelected() { await loadFiles(); } catch (e) { alert("上传失败:" + e.message); + } +} + +async function uploadSelected() { + const inp = $("upload-input"); + const files = Array.from(inp.files || []); + try { + await uploadFiles(files); } finally { inp.value = ""; // 允许重新选同名文件 }