// web UI 的路径选择器:给设置里的目录 / 文件框提供「浏览」 package main import ( "encoding/json" "io/fs" "net/http" "os" "path/filepath" "runtime" "sort" "strings" ) // 选择器里的一个条目 type browseEntry struct { Name string `json:"name"` Path string `json:"path"` // 完整路径,直接回填到设置里 Dir bool `json:"dir"` Size int64 `json:"size"` } // 展开目录的结果 type browseResp struct { Path string `json:"path"` // 当前展开的目录,空表示只给根列表 Parent string `json:"parent"` // 上级目录,已经到根时为空 Roots []browseEntry `json:"roots"` // 盘符 / 常用挂载点 Entries []browseEntry `json:"entries"` // 子文件夹(选文件时还含文件) Truncated bool `json:"truncated"` // 条目太多只返回了一部分 Error string `json:"error"` // 展开失败的原因 } // 单个目录最多返回多少项,避免几万个文件的目录把页面卡住 const browseMaxEntries = 2000 // 以点开头的当成隐藏项(Linux 下的 .config,Windows 下各类工具留下的 .cache 之类) func browseHidden(name string) bool { return strings.HasPrefix(name, ".") } // 上级目录,已经是根(盘符或 /)时返回空 func browseParent(abs string) string { parent := filepath.Dir(abs) if parent == abs { return "" } return parent } // 列出 path 下的内容,wantFiles 为真时连文件一起列(选文件用) // truncated 表示条目太多被截断了 func browseList(path string, wantFiles, showHidden bool) (entries []browseEntry, parent string, truncated bool, err error) { abs, err := filepath.Abs(path) if err != nil { return nil, "", false, err } parent = browseParent(abs) items, err := os.ReadDir(abs) if err != nil { return nil, parent, false, err } out := make([]browseEntry, 0, len(items)) for _, item := range items { name := item.Name() if !showHidden && browseHidden(name) { continue } full := filepath.Join(abs, name) isDir := item.IsDir() // ReadDir 用的是 lstat,指向文件夹的符号链接会被当成文件,这里补一次判断 if !isDir && item.Type()&fs.ModeSymlink != 0 { if info, err := os.Stat(full); err == nil { isDir = info.IsDir() } } if isDir { out = append(out, browseEntry{Name: name, Path: full, Dir: true}) continue } if !wantFiles { continue } info, err := item.Info() if err != nil { continue } out = append(out, browseEntry{Name: name, Path: full, Size: info.Size()}) } // 文件夹排前面,同类型按名字排(Windows 上的大小写不敏感) sort.SliceStable(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) }) if len(out) > browseMaxEntries { out, truncated = out[:browseMaxEntries], true } if out == nil { out = []browseEntry{} } return out, parent, truncated, nil } // 根位置:Windows 下列盘符,其它平台给几个常用的挂载点 func browseRoots() []browseEntry { out := make([]browseEntry, 0, 10) if runtime.GOOS == "windows" { for c := 'A'; c <= 'Z'; c++ { drive := string(c) + ":\\" if info, err := os.Stat(drive); err == nil && info.IsDir() { out = append(out, browseEntry{Name: string(c) + ":", Path: drive, Dir: true}) } } return out } for _, dir := range []string{"/", "/home", "/mnt", "/media", "/srv", "/data", "/opt", "/var/lib", "/tmp"} { if info, err := os.Stat(dir); err == nil && info.IsDir() { out = append(out, browseEntry{Name: dir, Path: dir, Dir: true}) } } if home, err := os.UserHomeDir(); err == nil { if info, err := os.Stat(home); err == nil && info.IsDir() { out = append(out, browseEntry{Name: "主目录 " + home, Path: home, Dir: true}) } } return out } // 展开目录,供页面上的「浏览」按钮用 // kind=file 时连文件一起列,hidden=1 时显示隐藏项 func browseHandler(w http.ResponseWriter, r *http.Request) { resp := browseResp{ Roots: browseRoots(), Entries: []browseEntry{}, } target := strings.TrimSpace(r.URL.Query().Get("path")) if target == "" { // 没给路径时从程序所在目录开始,多半就是用户想选的地方 if wd, err := os.Getwd(); err == nil { target = wd } } if target != "" { abs, err := filepath.Abs(target) if err != nil { resp.Path = target resp.Error = "路径不合法:" + err.Error() } else { resp.Path = abs entries, parent, truncated, err := browseList(abs, r.URL.Query().Get("kind") == "file", r.URL.Query().Get("hidden") == "1") resp.Entries, resp.Parent, resp.Truncated = entries, parent, truncated if err != nil { resp.Error = "打开失败:" + err.Error() } } } w.Header().Set("Content-Type", "application/json; charset=utf-8") if err := json.NewEncoder(w).Encode(resp); err != nil { lPrintErrf("输出目录列表失败:%v", err) } }