From 6bffd491ee40d75a29d75a7a1a61efb5e1e62ee9 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:01:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20=E6=96=B0=E5=A2=9E=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E7=A9=BA=E9=97=B4=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=99=A8(=E9=92=88=E5=AF=B9=20workDir)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 server/workspace.go(全部在 requireAuth 之后,路径限定在 workDir): - list/read/write/mkdir/delete/download/upload 七个端点 - wsResolve 用 Clean("/"+rel)+前缀校验防 .. 遍历;上传经 filepath.Base 再二次校验;读文本 2MB 上限(超限/二进制仅下载);上传 512MB 上限; 下载文件名过滤 + filename* UTF-8 编码 前端: - 侧边栏「工作空间」入口(/function/workspace) - api/types:workspaceList/read/write/mkdir/delete + upload(FormData) + download(blob 带 token) - page:面包屑导航 + 目录表格 + 上传/新建文件夹/刷新 + 每行下载/删除 + 右侧 Sheet 文本编辑器(二进制/超大仅下载) Co-Authored-By: Claude Opus 4.8 --- server/server.go | 9 + server/workspace.go | 290 ++++++++++++++ .../app/(main)/function/workspace/page.tsx | 364 ++++++++++++++++++ web/src/lib/api.ts | 41 ++ web/src/lib/types.ts | 20 + web/src/navigation/sidebar/sidebar-items.ts | 2 + 6 files changed, 726 insertions(+) create mode 100644 server/workspace.go create mode 100644 web/src/app/(main)/function/workspace/page.tsx diff --git a/server/server.go b/server/server.go index 73773f2..514d3d0 100644 --- a/server/server.go +++ b/server/server.go @@ -485,6 +485,15 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage) mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph) mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs) + + // 工作空间文件管理器(针对 workDir) + mux.HandleFunc("GET /api/workspace/list", s.wsList) + mux.HandleFunc("GET /api/workspace/read", s.wsRead) + mux.HandleFunc("POST /api/workspace/write", s.wsWrite) + mux.HandleFunc("POST /api/workspace/mkdir", s.wsMkdir) + mux.HandleFunc("DELETE /api/workspace/delete", s.wsDelete) + mux.HandleFunc("GET /api/workspace/download", s.wsDownload) + mux.HandleFunc("POST /api/workspace/upload", s.wsUpload) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("POST /api/active", s.setActive) diff --git a/server/workspace.go b/server/workspace.go new file mode 100644 index 0000000..a5d8a66 --- /dev/null +++ b/server/workspace.go @@ -0,0 +1,290 @@ +package server + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf8" +) + +// Workspace file manager — browse / view / edit / download / upload / delete the +// shared work dir (s.m.dir), where all agents write their artifacts. Every path is +// confined to the work dir root (traversal via ".." is neutralised). All routes sit +// behind requireAuth (see Handler()). + +const ( + maxWorkspaceRead = 2 << 20 // 2 MiB: files bigger than this aren't inlined for view/edit (download instead) + maxWorkspaceUpload = 512 << 20 // 512 MiB per upload request +) + +// wsResolve maps a user-supplied relative path to an absolute path INSIDE the work +// dir. It returns ok=false if the path would escape the root. filepath.Clean on a +// rooted copy collapses any ".." so nothing can climb above the root. +func (s *Server) wsResolve(rel string) (string, bool) { + base := filepath.Clean(s.m.dir) + rel = strings.TrimPrefix(strings.TrimSpace(rel), "/") + clean := filepath.Clean("/" + rel) // e.g. "/a/../../etc" → "/etc" (still rooted at "/") + abs := filepath.Clean(filepath.Join(base, clean)) + if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) { + return "", false + } + return abs, true +} + +// wsRel renders an absolute path back as a workspace-relative path (forward slashes). +func (s *Server) wsRel(abs string) string { + base := filepath.Clean(s.m.dir) + rel, err := filepath.Rel(base, abs) + if err != nil || rel == "." { + return "" + } + return filepath.ToSlash(rel) +} + +type wsEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Dir bool `json:"dir"` + Size int64 `json:"size"` + MTime int64 `json:"mtime"` // unix millis +} + +// GET /api/workspace/list?path= +func (s *Server) wsList(w http.ResponseWriter, r *http.Request) { + abs, ok := s.wsResolve(r.URL.Query().Get("path")) + if !ok { + writeErr(w, 400, "非法路径") + return + } + fi, err := os.Stat(abs) + if err != nil { + writeErr(w, 404, "路径不存在") + return + } + if !fi.IsDir() { + writeErr(w, 400, "不是目录") + return + } + ents, err := os.ReadDir(abs) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + out := make([]wsEntry, 0, len(ents)) + for _, e := range ents { + info, err := e.Info() + if err != nil { + continue + } + out = append(out, wsEntry{ + Name: e.Name(), + Path: s.wsRel(filepath.Join(abs, e.Name())), + Dir: e.IsDir(), + Size: info.Size(), + MTime: info.ModTime().UnixMilli(), + }) + } + // 目录在前,各自按名称排序。 + sort.Slice(out, func(i, j int) bool { + if out[i].Dir != out[j].Dir { + return out[i].Dir + } + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "entries": out}) +} + +// GET /api/workspace/read?path= — inline text for view/edit. Binary or oversize +// files return {binary:true}/{too_large:true} with no content (use download instead). +func (s *Server) wsRead(w http.ResponseWriter, r *http.Request) { + abs, ok := s.wsResolve(r.URL.Query().Get("path")) + if !ok { + writeErr(w, 400, "非法路径") + return + } + fi, err := os.Stat(abs) + if err != nil { + writeErr(w, 404, "文件不存在") + return + } + if fi.IsDir() { + writeErr(w, 400, "是目录,不能作为文件读取") + return + } + if fi.Size() > maxWorkspaceRead { + writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "too_large": true, "binary": true}) + return + } + data, err := os.ReadFile(abs) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) { + writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": true}) + return + } + writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": false, "content": string(data)}) +} + +// POST /api/workspace/write {path, content} — create/overwrite a text file. +func (s *Server) wsWrite(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := decode(r, &req); err != nil { + writeErr(w, 400, err.Error()) + return + } + abs, ok := s.wsResolve(req.Path) + if !ok || abs == filepath.Clean(s.m.dir) { + writeErr(w, 400, "非法路径") + return + } + if fi, err := os.Stat(abs); err == nil && fi.IsDir() { + writeErr(w, 400, "目标是目录") + return + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + writeErr(w, 500, err.Error()) + return + } + if err := os.WriteFile(abs, []byte(req.Content), 0o644); err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "path": s.wsRel(abs)}) +} + +// POST /api/workspace/mkdir {path} +func (s *Server) wsMkdir(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := decode(r, &req); err != nil { + writeErr(w, 400, err.Error()) + return + } + abs, ok := s.wsResolve(req.Path) + if !ok || abs == filepath.Clean(s.m.dir) { + writeErr(w, 400, "非法路径") + return + } + if err := os.MkdirAll(abs, 0o755); err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "path": s.wsRel(abs)}) +} + +// DELETE /api/workspace/delete?path= — removes a file or a directory tree +// (confined to the work dir; the root itself can't be deleted). +func (s *Server) wsDelete(w http.ResponseWriter, r *http.Request) { + abs, ok := s.wsResolve(r.URL.Query().Get("path")) + if !ok { + writeErr(w, 400, "非法路径") + return + } + if abs == filepath.Clean(s.m.dir) { + writeErr(w, 400, "不能删除工作区根目录") + return + } + if _, err := os.Stat(abs); err != nil { + writeErr(w, 404, "路径不存在") + return + } + if err := os.RemoveAll(abs); err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"ok": true}) +} + +// GET /api/workspace/download?path= — stream a file as an attachment. +func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) { + abs, ok := s.wsResolve(r.URL.Query().Get("path")) + if !ok { + writeErr(w, 400, "非法路径") + return + } + fi, err := os.Stat(abs) + if err != nil || fi.IsDir() { + writeErr(w, 404, "文件不存在") + return + } + name := filepath.Base(abs) + // RFC 5987 filename* keeps non-ASCII names intact; plain filename is the fallback. + w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(name)+"\"; filename*=UTF-8''"+url.PathEscape(name)) + http.ServeFile(w, r, abs) +} + +// POST /api/workspace/upload?path= — multipart form field "file" (one or more). +func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) { + dirAbs, ok := s.wsResolve(r.URL.Query().Get("path")) + if !ok { + writeErr(w, 400, "非法路径") + return + } + if fi, err := os.Stat(dirAbs); err != nil || !fi.IsDir() { + writeErr(w, 400, "目标目录不存在") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxWorkspaceUpload) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeErr(w, 400, "解析上传失败或超出大小限制:"+err.Error()) + return + } + files := r.MultipartForm.File["file"] + if len(files) == 0 { + writeErr(w, 400, "缺少上传文件(表单字段 file)") + return + } + saved := 0 + for _, hdr := range files { + name := filepath.Base(hdr.Filename) // strip any path component + if name == "" || name == "." || name == ".." { + continue + } + destAbs, okd := s.wsResolve(filepath.Join(s.wsRel(dirAbs), name)) + if !okd { + continue + } + if err := saveUpload(hdr, destAbs); err != nil { + writeErr(w, 500, err.Error()) + return + } + saved++ + } + writeJSON(w, 200, map[string]any{"uploaded": saved}) +} + +func saveUpload(hdr *multipart.FileHeader, dest string) error { + src, err := hdr.Open() + if err != nil { + return err + } + defer src.Close() + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, src) + return err +} + +// sanitizeFilename strips characters unsafe for a Content-Disposition filename token. +func sanitizeFilename(name string) string { + name = strings.ReplaceAll(name, "\"", "") + name = strings.ReplaceAll(name, "\\", "") + name = strings.ReplaceAll(name, "\n", "") + name = strings.ReplaceAll(name, "\r", "") + return name +} diff --git a/web/src/app/(main)/function/workspace/page.tsx b/web/src/app/(main)/function/workspace/page.tsx new file mode 100644 index 0000000..03ede8a --- /dev/null +++ b/web/src/app/(main)/function/workspace/page.tsx @@ -0,0 +1,364 @@ +"use client"; + +import * as React from "react"; +import { + DownloadIcon, + FileIcon, + FolderIcon, + FolderPlusIcon, + HardDriveIcon, + RefreshCwIcon, + SaveIcon, + Trash2Icon, + UploadIcon, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { api } from "@/lib/api"; +import type { WorkspaceEntry, WorkspaceFile } from "@/lib/types"; + +function fmtSize(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`; +} +function fmtTime(ms: number): string { + return new Date(ms).toLocaleString("zh-CN", { + year: "2-digit", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +type EditState = { + file: WorkspaceFile; + content: string; + dirty: boolean; + saving: boolean; +}; + +export default function WorkspacePage() { + const [path, setPath] = React.useState(""); + const [entries, setEntries] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [edit, setEdit] = React.useState(null); + const [mkdirOpen, setMkdirOpen] = React.useState(false); + const [mkdirName, setMkdirName] = React.useState(""); + const uploadRef = React.useRef(null); + + const load = React.useCallback( + (p: string) => { + setLoading(true); + api + .workspaceList(p) + .then((r) => { + setEntries(r.entries); + setPath(r.path); + }) + .catch((e) => toast.error(`读取目录失败:${(e as Error).message}`)) + .finally(() => setLoading(false)); + }, + [], + ); + + React.useEffect(() => { + load(""); + }, [load]); + + const crumbs = React.useMemo(() => { + const parts = path ? path.split("/") : []; + const acc: { name: string; path: string }[] = [{ name: "工作空间", path: "" }]; + let cur = ""; + for (const part of parts) { + cur = cur ? `${cur}/${part}` : part; + acc.push({ name: part, path: cur }); + } + return acc; + }, [path]); + + const openFile = (e: WorkspaceEntry) => { + api + .workspaceRead(e.path) + .then((f) => setEdit({ file: f, content: f.content ?? "", dirty: false, saving: false })) + .catch((err) => toast.error(`打开文件失败:${(err as Error).message}`)); + }; + + const saveFile = () => { + if (!edit) return; + setEdit({ ...edit, saving: true }); + api + .workspaceWrite(edit.file.path, edit.content) + .then(() => { + toast.success("已保存"); + setEdit((cur) => (cur ? { ...cur, dirty: false, saving: false } : cur)); + load(path); + }) + .catch((err) => { + toast.error(`保存失败:${(err as Error).message}`); + setEdit((cur) => (cur ? { ...cur, saving: false } : cur)); + }); + }; + + const del = (e: WorkspaceEntry) => { + if (!window.confirm(`确认删除 ${e.dir ? "目录" : "文件"} “${e.name}”?${e.dir ? "(含其下所有内容)" : ""}`)) return; + api + .workspaceDelete(e.path) + .then(() => { + toast.success("已删除"); + load(path); + }) + .catch((err) => toast.error(`删除失败:${(err as Error).message}`)); + }; + + const doUpload = (files: FileList | null) => { + if (!files || files.length === 0) return; + api + .workspaceUpload(path, Array.from(files)) + .then((r) => { + toast.success(`已上传 ${r.uploaded} 个文件`); + load(path); + }) + .catch((err) => toast.error(`上传失败:${(err as Error).message}`)) + .finally(() => { + if (uploadRef.current) uploadRef.current.value = ""; + }); + }; + + const doMkdir = () => { + const name = mkdirName.trim(); + if (!name) return; + const target = path ? `${path}/${name}` : name; + api + .workspaceMkdir(target) + .then(() => { + toast.success("已创建目录"); + setMkdirOpen(false); + setMkdirName(""); + load(path); + }) + .catch((err) => toast.error(`创建失败:${(err as Error).message}`)); + }; + + return ( +
+ {/* 头部:面包屑 + 操作 */} +
+
+ + {crumbs.map((c, i) => ( + + {i > 0 && /} + + + ))} +
+
+ + + + doUpload(e.target.files)} + /> +
+
+ + + + + + + 名称 + 大小 + 修改时间 + 操作 + + + + {entries.length === 0 && ( + + + {loading ? "加载中…" : "空目录"} + + + )} + {entries.map((e) => ( + + + + + + {e.dir ? "—" : fmtSize(e.size)} + + {fmtTime(e.mtime)} + +
+ {!e.dir && ( + + )} + +
+
+
+ ))} +
+
+
+
+ + {/* 文件查看 / 编辑 */} + !o && setEdit(null)}> + + {edit && ( + <> + + + + + {edit.file.path} + + + {fmtSize(edit.file.size)} + + + {edit.file.binary || edit.file.too_large ? ( +
+

+ {edit.file.too_large ? "文件过大,不支持在线预览/编辑。" : "二进制文件,不支持在线预览/编辑。"} +

+ +
+ ) : ( + <> +
+