package main // launch_detect.go:根据项目目录识别种类,并推荐启动命令 / 默认端口。 // 优先读 package.json scripts、go.mod、pom.xml 等标记文件;再回落到静态种类建议。 import ( "encoding/json" "os" "path/filepath" "strings" ) type npmPackageJSON struct { Name string `json:"name"` Scripts map[string]string `json:"scripts"` } func fileExists(dir, name string) bool { st, e := os.Stat(filepath.Join(dir, name)) return e == nil && !st.IsDir() } func dirExists(dir, name string) bool { st, e := os.Stat(filepath.Join(dir, name)) return e == nil && st.IsDir() } func hasExtInDir(dir, ext string) bool { ents, e := os.ReadDir(dir) if e != nil { return false } ext = strings.ToLower(ext) n := 0 for _, ent := range ents { if ent.IsDir() { continue } if strings.EqualFold(filepath.Ext(ent.Name()), ext) { n++ if n >= 1 { return true } } } return false } // findLaunchExes 收集可直接启动的 .exe(相对工作目录的路径),优先浅层与常见输出目录。 func findLaunchExes(dir string) []string { dir = strings.TrimSpace(dir) if dir == "" { return nil } var out []string seen := map[string]bool{} add := func(abs string) { rel, e := filepath.Rel(dir, abs) if e != nil { rel = filepath.Base(abs) } rel = filepath.ToSlash(rel) key := strings.ToLower(rel) if seen[key] { return } // 跳过安装器 / 卸载 / 明显工具 base := strings.ToLower(filepath.Base(abs)) for _, skip := range []string{"uninstall", "setup", "installer", "update", "crashpad", "vcredist"} { if strings.Contains(base, skip) { return } } seen[key] = true // Windows 路径含空格时加引号,便于 cmd /C 执行 if strings.ContainsAny(rel, " \t") { out = append(out, `"`+filepath.FromSlash(rel)+`"`) } else { out = append(out, filepath.FromSlash(rel)) } } scan := func(root string, maxDepth int) { _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } rel, _ := filepath.Rel(root, path) depth := 0 if rel != "." { depth = strings.Count(rel, string(filepath.Separator)) } if d.IsDir() { name := strings.ToLower(d.Name()) if name == "node_modules" || name == ".git" || name == "vendor" { return filepath.SkipDir } if depth >= maxDepth { return filepath.SkipDir } return nil } if strings.EqualFold(filepath.Ext(d.Name()), ".exe") { add(path) } return nil }) } scan(dir, 1) // 根目录 for _, sub := range []string{"bin", "dist", "build", "out", "release", "Debug", "Release", "x64", "publish"} { p := filepath.Join(dir, sub) if st, e := os.Stat(p); e == nil && st.IsDir() { scan(p, 2) } } if len(out) > 8 { out = out[:8] } return out } // detectLaunchProfile 扫描目录,返回种类、名称、默认端口与推荐启停命令。 func detectLaunchProfile(dir string) LaunchProfile { dir = strings.TrimSpace(dir) out := LaunchProfile{ Dir: dir, Kind: "other", Name: filepath.Base(dir), } if dir == "" { return out } if st, e := os.Stat(dir); e != nil || !st.IsDir() { return out } base := filepath.Base(dir) out.Name = base switch { case fileExists(dir, "package.json"): out.Kind = "node" out.Port = 5173 raw, e := os.ReadFile(filepath.Join(dir, "package.json")) pkg := npmPackageJSON{} if e == nil { _ = json.Unmarshal(raw, &pkg) } if n := strings.TrimSpace(pkg.Name); n != "" { // 去掉 npm scope:@org/app → app if i := strings.LastIndex(n, "/"); i >= 0 && i+1 < len(n) { n = n[i+1:] } out.Name = n } starts := []string{} pick := []string{"dev", "start", "serve", "preview", "develop"} pm := "npm" if fileExists(dir, "pnpm-lock.yaml") { pm = "pnpm" } else if fileExists(dir, "yarn.lock") { pm = "yarn" } run := func(script string) string { switch pm { case "pnpm": return "pnpm " + script case "yarn": return "yarn " + script default: return "npm run " + script } } if pkg.Scripts != nil { for _, k := range pick { if _, ok := pkg.Scripts[k]; ok { starts = append(starts, run(k)) } } } if len(starts) == 0 { starts = append([]string{}, launchSuggestions["node"].Start...) } else { // 补全常见命令,去重 for _, c := range launchSuggestions["node"].Start { dup := false for _, s := range starts { if s == c { dup = true break } } if !dup { starts = append(starts, c) } } } if fileExists(dir, "next.config.js") || fileExists(dir, "next.config.mjs") || fileExists(dir, "next.config.ts") || dirExists(dir, ".next") { out.Port = 3000 } if fileExists(dir, "vite.config.js") || fileExists(dir, "vite.config.ts") || fileExists(dir, "vite.config.mjs") { out.Port = 5173 } out.Start = starts if len(starts) > 0 { out.StartCmd = starts[0] } case fileExists(dir, "go.mod"): out.Kind = "go" out.Port = 8080 if b, e := os.ReadFile(filepath.Join(dir, "go.mod")); e == nil { for _, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "module ") { mod := strings.TrimSpace(strings.TrimPrefix(line, "module ")) if i := strings.LastIndex(mod, "/"); i >= 0 { mod = mod[i+1:] } if mod != "" { out.Name = mod } break } } } out.Start = append([]string{}, launchSuggestions["go"].Start...) if fileExists(dir, "main.go") { out.Start = append([]string{"go run .", "go run main.go"}, out.Start...) } out.StartCmd = out.Start[0] case fileExists(dir, "manage.py"): out.Kind = "python" out.Port = 8000 out.Start = []string{"python manage.py runserver", "python manage.py runserver 0.0.0.0:8000"} out.Start = append(out.Start, launchSuggestions["python"].Start...) out.StartCmd = out.Start[0] case fileExists(dir, "pyproject.toml") || fileExists(dir, "requirements.txt") || fileExists(dir, "main.py") || fileExists(dir, "app.py"): out.Kind = "python" out.Port = 8000 starts := []string{} if fileExists(dir, "main.py") { starts = append(starts, "uvicorn main:app --reload", "python main.py") } if fileExists(dir, "app.py") { starts = append(starts, "uvicorn app:app --reload", "python app.py", "flask --app app run") } starts = append(starts, launchSuggestions["python"].Start...) out.Start = uniqStrings(starts) out.StartCmd = out.Start[0] case fileExists(dir, "pom.xml") || fileExists(dir, "build.gradle") || fileExists(dir, "build.gradle.kts"): out.Kind = "java" out.Port = 8080 out.Start = append([]string{}, launchSuggestions["java"].Start...) out.StartCmd = out.Start[0] case fileExists(dir, "artisan") || fileExists(dir, "composer.json"): out.Kind = "php" out.Port = 8000 out.Start = append([]string{}, launchSuggestions["php"].Start...) out.StartCmd = out.Start[0] case hasExtInDir(dir, ".csproj") || hasExtInDir(dir, ".sln"): out.Kind = "dotnet" out.Port = 5000 out.Start = append([]string{}, launchSuggestions["dotnet"].Start...) out.StartCmd = out.Start[0] default: // Windows 可执行文件:目录内 / bin / dist 下的 .exe 作为默认启动方式 if exes := findLaunchExes(dir); len(exes) > 0 { out.Kind = "exe" out.Start = exes out.StartCmd = exes[0] } else { out.Kind = "other" out.Start = nil } } if sug, ok := launchSuggestions[out.Kind]; ok { out.Stop = append([]string{}, sug.Stop...) } if out.Name == "" || out.Name == "." || out.Name == string(filepath.Separator) { out.Name = base } return out } func uniqStrings(in []string) []string { seen := map[string]bool{} out := make([]string, 0, len(in)) for _, s := range in { s = strings.TrimSpace(s) if s == "" || seen[s] { continue } seen[s] = true out = append(out, s) } return out } func normLaunchPath(p string) string { p = strings.TrimSpace(p) if p == "" { return "" } p = filepath.Clean(p) return strings.ToLower(filepath.ToSlash(p)) } // matchProjectByDir 用目录前缀匹配「我的项目」,取最长路径命中。 func (a *App) matchProjectByDir(dir string) (Project, bool) { nd := normLaunchPath(dir) if nd == "" || a.store == nil { return Project{}, false } ps, e := a.store.ListProjects(0) if e != nil { return Project{}, false } var best Project bestLen := 0 for _, p := range ps { np := normLaunchPath(p.Path) if np == "" { continue } if nd == np || strings.HasPrefix(nd, np+"/") { if len(np) > bestLen { best, bestLen = p, len(np) } } } return best, bestLen > 0 }