339 lines
8.5 KiB
Go
339 lines
8.5 KiB
Go
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
|
||
}
|