package main import ( "bufio" "fmt" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "apigo.cc/go/shell" "apigo.cc/go/watch" ) // ── ANSI 颜色常量 ────────────────────────────────────────────────────────────── const ( reset = "\033[0m" bold = "\033[1m" dim = "\033[2m" red = "\033[31m" green = "\033[32m" yellow = "\033[33m" blue = "\033[34m" cyan = "\033[36m" white = "\033[37m" gray = "\033[90m" bgBlue = "\033[44m" bgGreen = "\033[42m" ) const debounceTime = 300 * time.Millisecond // ── Preset 定义 ──────────────────────────────────────────────────────────────── type preset struct { Types []string ExcludePaths []string DefaultCommand []string } var presets = map[string]preset{ "go": { Types: []string{"go"}, ExcludePaths: []string{"vendor/**", "**/node_modules/**"}, DefaultCommand: []string{"go", "run", "."}, }, "gotest": { Types: []string{"go"}, ExcludePaths: []string{"vendor/**", "**/node_modules/**"}, DefaultCommand: []string{"go", "test", "-v", "-count=1", "./..."}, }, "web": { Types: []string{"html", "css", "js", "ts", "vue", "jsx", "tsx"}, ExcludePaths: []string{"node_modules/**", "dist/**"}, DefaultCommand: []string{"npm", "run", "dev"}, }, "js": { Types: []string{"js", "ts", "json"}, ExcludePaths: []string{"node_modules/**"}, DefaultCommand: []string{"npm", "start"}, }, "py": { Types: []string{"py", "toml", "yaml", "yml"}, ExcludePaths: []string{"__pycache__/**", ".venv/**"}, DefaultCommand: []string{"python", "main.py"}, }, } // ── 入口 ─────────────────────────────────────────────────────────────────────── func main() { args := os.Args[1:] // Built-in control options must be handled before loading .watch; otherwise // a task definition can consume -h as task arguments. if hasHelpFlag(args) { printHelp() return } if handleReload(args) { return } args = tryLoadWatchFile(args) if len(args) == 0 { printHelp() return } var paths []string var types []string var excludeTypes []string var excludePaths []string var command []string var clearScreen bool // 自动读取 .gitignore 和 .ignore excludePaths = append(excludePaths, parseIgnoreFiles()...) i := 0 for i < len(args) { arg := args[i] switch { case arg == "-h" || arg == "--help": printHelp() return case arg == "-c" || arg == "--clear": clearScreen = true case arg == "-w" || arg == "--watch": i++ if i < len(args) { paths = append(paths, strings.Split(args[i], ",")...) } case arg == "-e" || arg == "--ext": i++ if i < len(args) { types = append(types, strings.Split(args[i], ",")...) } case arg == "-E" || arg == "--exclude-ext": i++ if i < len(args) { excludeTypes = append(excludeTypes, strings.Split(args[i], ",")...) } case arg == "-i" || arg == "--ignore": i++ if i < len(args) { excludePaths = append(excludePaths, args[i]) } case strings.HasPrefix(arg, "--") && presets[strings.TrimPrefix(arg, "--")].Types != nil: name := strings.TrimPrefix(arg, "--") p := presets[name] types = append(types, p.Types...) excludePaths = append(excludePaths, p.ExcludePaths...) if len(command) == 0 { command = append([]string{}, p.DefaultCommand...) // 设定为预设默认命令 } default: // 如果走到这里,说明是遇到了位置参数(command 或 参数) if len(command) > 0 { // 已经有预设的默认命令 // 判断输入的是否像是一个新命令(例如 `go`, `npm`, `python` 等) base := args[i] if idx := strings.LastIndexAny(base, "/\\"); idx >= 0 { base = base[idx+1:] } if base == "go" || base == "go.exe" || base == "npm" || base == "npm.cmd" || base == "python" || base == "python.exe" || base == "node" || base == "node.exe" { command = args[i:] // 替换为用户显式输入的命令 } else { // 否则将其视为预设命令的参数,智能追加 // 针对 go run . 自动追加 -- 避免参数被 go run 吃掉 if command[0] == "go" && len(command) >= 3 && command[1] == "run" { command = append(command, "--") } command = append(command, args[i:]...) } } else { command = args[i:] // 没有预设,直接使用 } i = len(args) continue } i++ } if len(paths) == 0 { paths = []string{"."} } config := watch.Config{ Paths: paths, Types: types, ExcludeTypes: excludeTypes, Excludes: excludePaths, Debounce: debounceTime, } pidPath := filepath.Join(".", "watch.pid") if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0644); err != nil { logError("failed to write watch.pid: " + err.Error()) } else { defer os.Remove(pidPath) } printBanner(config, command, clearScreen) if len(command) == 0 { runMonitor(config) } else { runCommand(config, command, clearScreen) } } func hasHelpFlag(args []string) bool { for _, arg := range args { if arg == "-h" || arg == "--help" { return true } } return false } // handleReload sends SIGUSR1 to the process recorded in a PID file. func handleReload(args []string) bool { for i, arg := range args { if arg != "-r" && arg != "--reload" { continue } pidPath := "watch.pid" if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { pidPath = args[i+1] } data, err := os.ReadFile(pidPath) if err != nil { if !os.IsNotExist(err) { logError("failed to read PID file: " + err.Error()) } return true } var pid int if _, err = fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err != nil || pid <= 0 { logError("invalid PID in " + pidPath) return true } if err = syscall.Kill(pid, syscall.SIGUSR1); err != nil { logError(fmt.Sprintf("failed to reload process %d: %v", pid, err)) } else { fmt.Printf("sent reload signal to process %d (%s)\n", pid, pidPath) } return true } return false } // tryLoadWatchFile 尝试从 .watch 读取配置并处理 --task func tryLoadWatchFile(args []string) []string { contentBytes, err := os.ReadFile(".watch") if err != nil { return args } content := string(contentBytes) tasks := make(map[string]string) var firstTask string lines := strings.Split(content, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { k := strings.TrimSpace(parts[0]) v := strings.TrimSpace(parts[1]) tasks[k] = v if firstTask == "" { firstTask = k } } } if len(tasks) == 0 { return args } targetTask := firstTask // 默认第一个 if len(args) > 0 && strings.HasPrefix(args[0], "--") { taskName := strings.TrimPrefix(args[0], "--") if _, ok := tasks[taskName]; ok && presets[taskName].Types == nil { targetTask = taskName args = args[1:] // 消费掉这个参数 } } if cmdStr, ok := tasks[targetTask]; ok { fmt.Printf("%s> Using task from .watch: --%s (%s)%s\n", dim, targetTask, cmdStr, reset) // 简单按空格切割组合 taskArgs := strings.Fields(cmdStr) return append(taskArgs, args...) } return args } // parseIgnoreFiles 解析当前目录的忽略文件 func parseIgnoreFiles() []string { var ignores []string files := []string{".gitignore", ".ignore"} for _, f := range files { contentBytes, err := os.ReadFile(f) if err == nil { content := string(contentBytes) lines := strings.Split(content, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "#") { ignores = append(ignores, line) } } } } return ignores } // ── 监控模式(无命令)────────────────────────────────────────────────────────── func runMonitor(config watch.Config) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) w, err := watch.Start(config, func(e *watch.Event) { printEvent(e) }) if err != nil { logError("failed to start watcher: " + err.Error()) os.Exit(1) } defer w.Stop() <-sigCh fmt.Printf("\n%s%s Stopped.%s\n", dim, gray, reset) } // ── 命令模式 ─────────────────────────────────────────────────────────────────── func runCommand(config watch.Config, command []string, clearScreen bool) { execDir, _ := os.Getwd() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1) restartCh := make(chan *watch.Event, 1) stopCh := make(chan struct{}) doneCh := make(chan struct{}) requestRestart := func(e *watch.Event) { select { case restartCh <- e: default: // A restart is already pending; one restart will include all recent changes. } } w, err := watch.Start(config, requestRestart) if err != nil { logError("failed to start watcher: " + err.Error()) os.Exit(1) } defer w.Stop() // 监听控制台输入 `rs` 手动重启 go func() { scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { text := strings.TrimSpace(scanner.Text()) if text == "rs" { requestRestart(&watch.Event{Path: "manual restart", Type: watch.Change}) } } }() // A single worker owns proc so file events, rs, and signals cannot restart concurrently. go func() { defer close(doneCh) var proc *shell.Process start := func() { printRestart(command) var err error proc, err = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true}) if err != nil { proc = nil logError("failed to start command: " + err.Error()) } } if clearScreen { fmt.Print("\033[2J\033[H") } start() for { select { case e := <-restartCh: if clearScreen { fmt.Print("\033[2J\033[H") } printEvent(e) if proc != nil { if err := proc.Kill(); err != nil { logError("failed to stop command: " + err.Error()) continue } } start() case <-stopCh: if proc != nil { if err := proc.Kill(); err != nil { logError("failed to stop command: " + err.Error()) } } return } } }() for sig := range sigCh { if sig == syscall.SIGUSR1 { requestRestart(&watch.Event{Path: "manual reload", Type: watch.Change}) continue } break } close(stopCh) <-doneCh fmt.Printf("\n%s%s Stopped.%s\n", dim, gray, reset) } // ── 输出函数 ─────────────────────────────────────────────────────────────────── func printBanner(config watch.Config, command []string, clearScreen bool) { var parts []string parts = append(parts, fmt.Sprintf("%spaths:%s %s", dim, reset, strings.Join(config.Paths, ","))) if len(config.Types) > 0 { types := make([]string, len(config.Types)) for i, t := range config.Types { if !strings.HasPrefix(t, ".") { t = "." + t } types[i] = t } parts = append(parts, fmt.Sprintf("%sexts:%s %s", dim, reset, strings.Join(types, ","))) } if len(config.ExcludeTypes) > 0 { exTypes := make([]string, len(config.ExcludeTypes)) for i, t := range config.ExcludeTypes { if !strings.HasPrefix(t, ".") { t = "." + t } exTypes[i] = t } parts = append(parts, fmt.Sprintf("%s-exts:%s %s", dim, reset, strings.Join(exTypes, ","))) } if len(config.Excludes) > 0 { showCount := 2 if len(config.Excludes) > showCount { parts = append(parts, fmt.Sprintf("%signore:%s %s...", dim, reset, strings.Join(config.Excludes[:showCount], ","))) } else { parts = append(parts, fmt.Sprintf("%signore:%s %s", dim, reset, strings.Join(config.Excludes, ","))) } } if len(command) > 0 { parts = append(parts, fmt.Sprintf("%scmd:%s %s%s%s", dim, reset, bold, strings.Join(command, " "), reset)) } fmt.Printf("\n%s%swatch%s ❖ %s\n", bold, cyan, reset, strings.Join(parts, fmt.Sprintf(" %s|%s ", dim, reset))) fmt.Printf("%s(Type `rs` then Enter to restart manually)%s\n\n", gray, reset) } func printEvent(e *watch.Event) { ts := time.Now().Format("15:04:05") typeStr, typeColor := eventStyle(e.Type) dirMark := "" if e.IsDir { dirMark = gray + " [dir]" + reset } fmt.Printf("%s%s%s %s%s%s%s %s%s%s\n", gray, ts, reset, bold, typeColor, typeStr, reset, white, e.Path, reset+dirMark) } func printRestart(command []string) { ts := time.Now().Format("15:04:05") cmdStr := strings.Join(command, " ") line := strings.Repeat("─", 48) fmt.Printf("\n%s%s%s %s%s$ %s%s%s\n", gray, line, reset, gray, ts, reset, bold+green, cmdStr+reset) } func logError(msg string) { fmt.Fprintf(os.Stderr, "%s%s✗ %s%s\n", bold, red, msg, reset) } func eventStyle(et watch.EventType) (string, string) { switch et { case watch.Create: return "create", green case watch.Change: return "change", blue case watch.Remove: return "remove", red case watch.Rename: return "rename", yellow default: return string(et), white } } // ── 帮助信息 ─────────────────────────────────────────────────────────────────── func printHelp() { fmt.Print(` ` + bold + bgBlue + ` watch ` + reset + ` File watcher & command runner ` + dim + ` Recursive by default. Respects .gitignore automatically.` + reset + ` ` + bold + `Usage` + reset + ` watch [options] [command [args...]] watch # execute default task in .watch or print help ` + bold + `Options` + reset + ` ` + cyan + `-w, --watch ` + reset + ` Watch paths (comma-separated), default "." ` + cyan + `-e, --ext ` + reset + ` Include extensions (comma-separated), e.g., ` + yellow + `go,ts` + reset + ` ` + cyan + `-E, --exclude-ext ` + reset + ` Exclude extensions (comma-separated) ` + cyan + `-i, --ignore ` + reset + ` Ignore glob patterns (repeatable), e.g., ` + yellow + `node_modules/**` + reset + ` ` + cyan + `-c, --clear` + reset + ` Clear screen before restarting command ` + cyan + `-r, --reload [pidfile]` + reset + ` Send SIGUSR1 to the PID in watch.pid (or a specified file) ` + cyan + `-h, --help` + reset + ` Show this help ` + bold + `Presets` + reset + ` (Auto-injects default commands if no command provided) ` + green + `--go` + reset + ` Go dev — ext: ` + dim + `go` + reset + ` | cmd: ` + dim + `go run .` + reset + ` ` + green + `--gotest` + reset + ` Go test — ext: ` + dim + `go` + reset + ` | cmd: ` + dim + `go test -v -count=1 ./...` + reset + ` ` + green + `--web` + reset + ` Web frontend — ext: ` + dim + `html,css,js,ts,vue,jsx...` + reset + ` | cmd: ` + dim + `npm run dev` + reset + ` ` + green + `--js` + reset + ` Node.js dev — ext: ` + dim + `js,ts,json` + reset + ` | cmd: ` + dim + `npm start` + reset + ` ` + green + `--py` + reset + ` Python dev — ext: ` + dim + `py,toml,yaml...` + reset + ` | cmd: ` + dim + `python main.py` + reset + ` ` + bold + `Tasks File (.watch)` + reset + ` You can create a .watch file with key=value format: ` + dim + `dev = --clear -w src -e go go run .` + reset + ` ` + dim + `test = --gotest` + reset + ` Run ` + yellow + `watch --dev` + reset + ` to execute a task, or just ` + yellow + `watch` + reset + ` for the first task. ` + bold + `Interactive` + reset + ` Type ` + yellow + `rs` + reset + ` and press Enter while running to force a manual restart. `) }