diff --git a/CHANGELOG.md b/CHANGELOG.md index 8736f265..8bf81a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ > 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## Unreleased + +- 项目管理集中到左侧:支持直接创建、重命名、查看文件和安全移除空项目;右侧入口改为“当前项目 / 全部文件”,专注项目内及跨项目文件浏览。 + ## 0.72.1 — 2026-09-04 - 左侧导航改为“项目→对话”分组,并保留最近对话和筛选结果;右侧文件范围改用“当前项目 / 全部项目”表达,更容易看清同一项目下的多个对话和共享资料。 diff --git a/tests/frontend_files.test.mjs b/tests/frontend_files.test.mjs index bca3449c..8cc88d32 100644 --- a/tests/frontend_files.test.mjs +++ b/tests/frontend_files.test.mjs @@ -6,6 +6,7 @@ const filesJs = fs.readFileSync("web/static/js/files.js", "utf8"); const layoutJs = fs.readFileSync("web/static/js/layout.js", "utf8"); const jobsJs = fs.readFileSync("web/static/js/software_jobs.js", "utf8"); const previewJs = fs.readFileSync("web/static/js/preview.js", "utf8"); +const newTaskJs = fs.readFileSync("web/static/js/newtask.js", "utf8"); const pageHtml = fs.readFileSync("web/static/dev.html", "utf8"); test("file menu exposes an explicit confirmed replacement action", () => { @@ -24,7 +25,7 @@ test("ordinary uploads default to rename conflict policy", () => { test("file workspace exposes explicit task and all-files contexts", () => { assert.match(pageHtml, /id="files-scope-task"[^>]*>当前项目]*>全部项目]*>全部文件 assert.match(chatJs, /working_dir: projectName/); assert.match(chatJs, /state\.taskHasMore = filtered &&/); assert.match(chatJs, /task-project-more/); + assert.match(chatJs, /task-project-create/); + assert.doesNotMatch(chatJs, /if \(!folder\.n_tasks\) continue/); + assert.match(chatJs, /renameProjectFolder/); + assert.match(chatJs, /removeProjectFolder/); + assert.match(chatJs, /openProjectFiles/); + assert.match(filesJs, /function createProjectFolder/); + assert.match(filesJs, /function removeProjectFolder/); + assert.match(chatJs, /folders-refresh-requested/); + assert.match(newTaskJs, /folders-refresh-requested/); + assert.match(filesJs, /setRightPanelTab\("files"\)/); + assert.match(filesJs, /recursive: entries\.length > 0/); assert.match(pageHtml, /\.task-nav-icon \{ width: 15px; height: 15px;/); assert.match(pageHtml, /\.task-project-head \.task-nav-icon \{ width: 16px; height: 16px;/); }); diff --git a/web/static/dev.html b/web/static/dev.html index ab6a5004..4ca460ab 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -685,11 +685,20 @@ .task-nav-recent { padding: 8px 0 6px; border-bottom: 1px solid var(--border-soft); } .task-nav-section-title { min-height: 28px; padding: 4px 12px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .02em; } + .task-nav-section-title .spacer { flex: 1; } + .task-project-create { width: 24px; height: 24px; padding: 0; border: 0; border-radius: 5px; + background: transparent; color: var(--muted); font-size: 17px; line-height: 1; cursor: pointer; } + .task-project-create:hover { background: var(--hover); color: var(--text); } .task-projects { padding: 6px 0 10px; } .task-project { margin: 1px 0; } - .task-project-head { width: 100%; min-height: 34px; padding: 4px 12px 4px 8px; border: 0; border-radius: 0; + .task-project-row { display: flex; align-items: center; } + .task-project-head { flex: 1; min-width: 0; min-height: 34px; padding: 4px 4px 4px 8px; border: 0; border-radius: 0; background: transparent; color: var(--text); display: flex; align-items: center; gap: 6px; cursor: pointer; text-align: left; } - .task-project-head:hover { background: var(--hover); } + .task-project-row:hover { background: var(--hover); } + .task-project-menu { width: 28px; height: 28px; margin-right: 6px; padding: 0; flex: none; border: 0; + background: transparent; color: var(--muted); opacity: 0; cursor: pointer; } + .task-project-row:hover .task-project-menu, .task-project-menu:focus-visible { opacity: 1; } + @media (hover: none) { .task-project-menu { opacity: .65; } } .task-project-chevron { width: 12px; height: 18px; display: inline-flex; align-items: center; justify-content: center; flex: none; color: var(--muted); font-size: 15px; line-height: 1; transition: transform .14s ease; } .task-project.open .task-project-chevron { transform: rotate(90deg); } @@ -2379,7 +2388,7 @@
- +
diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 28262b05..8052c79a 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -46,7 +46,16 @@ import { renderWebPreviewCardHtml, stopWebPreview, } from "./preview.js"; -import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js"; +import { + createProjectFolder, + formatUploadProgress, + loadFiles, + openProjectFiles, + removeProjectFolder, + renameProjectFolder, + scheduleFilesRefresh, + uploadFiles, +} from "./files.js"; import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, renderArtifactChipContent, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js"; import { parseUserAttachments } from "./attachments.js"; import { @@ -290,7 +299,6 @@ function taskProjectGroups(tasks) { const groups = []; const seen = new Set(); for (const folder of state.folders || []) { - if (!folder.n_tasks) continue; // 空目录仍在右栏「全部项目」中管理 groups.push({ key: folder.name, name: folder.name, @@ -341,16 +349,26 @@ function groupedTaskListHtml(tasks) { const cachedPage = _projectTaskPages.get(group.key); const exactCount = cachedPage && cachedPage.page > 0 ? cachedPage.count : group.count; return `
- +
+ + ${group.key === "__ungrouped__" ? "" : ``} +
${projectItemsHtml(group)}
`; }).join(""); - return `${recentHtml}
${projectsHtml}
`; + const projectTitle = `
+ 项目 + +
`; + const projectBody = projectsHtml || `
暂无项目
`; + return `${recentHtml}${projectTitle}
${projectBody}
`; } async function loadProjectTasks(projectName, { append = false } = {}) { @@ -408,12 +426,11 @@ async function loadProjectTasks(projectName, { append = false } = {}) { function renderTaskNavigation() { const listEl = $("task-list"); - if (!_globalTaskResults.length) { - listEl.innerHTML = `
(暂无对话)
`; - return; - } if (isTaskListFiltered()) { - listEl.innerHTML = `
筛选结果
${_globalTaskResults.map((t) => taskRowHtml(t)).join("")}
`; + const results = _globalTaskResults.length + ? _globalTaskResults.map((t) => taskRowHtml(t)).join("") + : `
(无匹配对话)
`; + listEl.innerHTML = `
筛选结果
${results}
`; } else { listEl.innerHTML = groupedTaskListHtml(_globalTaskResults); } @@ -421,10 +438,30 @@ function renderTaskNavigation() { } export function refreshTaskProjectIndex() { - if (!isTaskListFiltered() && _globalTaskResults.length) renderTaskNavigation(); + if (!isTaskListFiltered()) renderTaskNavigation(); } function bindTaskListInteractions(root) { + const createButton = root.querySelector("#task-project-create"); + if (createButton) createButton.onclick = () => createProjectFolder(); + root.querySelectorAll(".task-project-menu").forEach((button) => { + button.onclick = (event) => { + event.stopPropagation(); + const name = button.dataset.projectName || ""; + const count = Number(button.dataset.projectCount) || 0; + showMenu(button, [ + { act: "open-files", label: "查看项目文件", onclick: () => openProjectFiles(name) }, + { act: "rename", label: "重命名项目", cls: "act-rename", onclick: () => renameProjectFolder(name) }, + { + act: "delete", + label: count ? `移除项目(尚有 ${count} 个对话)` : "移除项目", + cls: "act-delete", + disabled: count > 0, + onclick: () => removeProjectFolder(name), + }, + ]); + }; + }); root.querySelectorAll(".task-row").forEach((el) => { el.onclick = (e) => { if (e.target.closest(".dd-toggle")) return; @@ -3281,6 +3318,7 @@ async function createTaskFromDraft() { applyChannelComposerLock(t); $("chat-attach-btn").disabled = false; loadTaskList(); // 新 task 立即进左栏;不等待列表 RTT 再发首条消息 + document.dispatchEvent(new CustomEvent("folders-refresh-requested")); loadFiles(); return t; } @@ -4045,6 +4083,7 @@ async function deleteTask(tid, name, nMsg) { await showNewConversationDraft(); } loadTaskList(); + document.dispatchEvent(new CustomEvent("folders-refresh-requested")); loadFiles(); } catch (e) { if (e.status === 401) { logout(); return; } diff --git a/web/static/js/files.js b/web/static/js/files.js index 88238441..5a694277 100644 --- a/web/static/js/files.js +++ b/web/static/js/files.js @@ -91,6 +91,72 @@ async function createFolder() { } } +export async function createProjectFolder() { + const rawName = await dialogPrompt({ + title: "创建项目", label: "项目名称", placeholder: "输入项目名称", maxLength: 255, + okText: "创建", + }); + if (rawName == null) return; + const name = rawName.trim(); + if (!name) return; + try { + await api("POST", "/v1/files/mkdir", { path: "", name }); + await loadFolderSuggestions(); + if (state.filesScope === "all" && !state.filesPath) await loadFiles(); + message(`已创建项目「${name}」`, "success"); + } catch (e) { + if (e.status === 401) { logout(); return; } + message("创建项目失败:" + e.message, "error"); + } +} + +export async function renameProjectFolder(name) { + if (!name) return; + await renameFile(name, name, true); +} + +export function openProjectFiles(name) { + if (!name) return; + state.filesScope = "all"; + state.filesPath = name; + resetFileSearch(); + setRightPanelTab("files"); + loadFiles(); +} + +export async function removeProjectFolder(name) { + if (!name) return; + let entries; + try { + const data = await api("GET", "/v1/files?path=" + encodeURIComponent(name)); + entries = data.entries || []; + } catch (e) { + if (e.status === 401) { logout(); return; } + message("读取项目失败:" + e.message, "error"); + return; + } + const detail = entries.length + ? `项目「${name}」包含 ${entries.length} 项文件或目录,移除后不可恢复。` + : `确认移除空项目「${name}」?`; + if (!await dialogConfirm({ + title: "移除项目", message: detail, okText: "确认移除", danger: true, + })) return; + try { + await api("POST", "/v1/files/delete", { path: name, recursive: entries.length > 0 }); + if (state.filesPath === name || state.filesPath.startsWith(name + "/")) { + state.filesScope = "all"; + state.filesPath = ""; + resetFileSearch(); + } + await loadFolderSuggestions(); + await loadFiles(); + message(`已移除项目「${name}」`, "success"); + } catch (e) { + if (e.status === 401) { logout(); return; } + message("移除项目失败:" + e.message, "error"); + } +} + // ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)───── // 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B // 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set 全程保留;切换浏览路径不清空。 diff --git a/web/static/js/newtask.js b/web/static/js/newtask.js index f9d74807..50004519 100644 --- a/web/static/js/newtask.js +++ b/web/static/js/newtask.js @@ -60,6 +60,7 @@ $("nt-go").onclick = async () => { const t = await api("POST", "/v1/tasks", body); localStorage.setItem(LS_RECENT_WORKING_DIR, working_dir); $("new-task-modal").classList.remove("show"); + await loadFolderSuggestions(); await loadTaskList(); selectTask(t.task_id); } catch (e) { @@ -81,6 +82,10 @@ export async function loadFolderSuggestions() { refreshTaskProjectIndex(); } +// task 的创建/软删除发生在 chat.js;用事件保持目录下拉和左侧项目计数同步, +// 避免在 chat.js ↔ newtask.js 的既有依赖环上再增加反向 import。 +document.addEventListener("folders-refresh-requested", () => { void loadFolderSuggestions(); }); + // 灌顶部 filter-wd;自定义创建弹框使用与快速新对话一致的组合框。 function populateFolderSelects() { const folders = state.folders || [];