Compare commits

..

1 Commits
v1.5.3 ... main

Author SHA1 Message Date
AI Engineer
cb30e4b320 feat(watch): 体验升级及修复目录事件 bug(by AI) 2026-06-27 15:48:17 +08:00
6 changed files with 422 additions and 76 deletions

View File

@ -1,5 +1,17 @@
# CHANGELOG # CHANGELOG
## v1.5.4 (2026-06-27)
- **修复 Bug**: 修复了配置 `Types`(文件类型白名单)时,目录本身的创建/删除等事件未能被正确过滤的问题。
- **CLI 体验大幅升级**:
- **紧凑型 UI**: 重新设计了日志输出Banner 与 Event视觉更加紧凑专业彻底分离了应用输出与 Watch 日志。
- **智能追加参数**: 当使用 `--go` 等预设并提供自定义参数(如 `watch --go --dev`CLI 会智能判断并将其追加到默认命令(如 `go run . -- --dev`)。
- **.gitignore 支持**: 默认自动读取当前目录的 `.gitignore``.ignore` 文件并将其中的规则加入排除列表。
- **.watch 任务文件**: 支持基于 `.watch` 文件的极简任务配置(键值对格式),可无参数直接执行默认任务,或通过 `watch --taskname` 执行特定任务。
- **清屏支持**: 新增 `-c, --clear` 参数,在重启前自动清理终端。
- **手动重启**: 支持在运行时键入 `rs` + 回车强行重启服务。
- **恢复参数**: 重新暴露 `-E, --exclude-ext` 选项(原 `-exclude-type`)方便灵活排除特定后缀。
- **参数标准化**: `-path` 改为 `-w/--watch``-type` 改为 `-e/--ext``-exclude-path` 改为 `-i/--ignore` 等。
## v1.5.3 (2026-06-22) ## v1.5.3 (2026-06-22)
- **新增 CLI**: 新增 `watch/watch` 子包,提供 `watch` 命令行工具,支持 `go install apigo.cc/go/watch/watch@latest` 安装。 - **新增 CLI**: 新增 `watch/watch` 子包,提供 `watch` 命令行工具,支持 `go install apigo.cc/go/watch/watch@latest` 安装。
- 支持 `-type`/`-exclude-type`/`-exclude-path` 等 watch.Start 全部配置能力。 - 支持 `-type`/`-exclude-type`/`-exclude-path` 等 watch.Start 全部配置能力。

View File

@ -26,37 +26,54 @@ go get apigo.cc/go/watch
go install apigo.cc/go/watch/watch@latest go install apigo.cc/go/watch/watch@latest
``` ```
**快速体验**
```bash ```bash
# Go 开发,变更时重新运行测试 # Go 开发:无需提供命令,默认自动执行 go run . (并支持智能追加参数)
watch -go go test ./... watch --go
watch --go --dev # 实际执行: go run . -- --dev
# Web 前端,变更时自动构建 # Go 测试:默认执行 go test -v -count=1 ./...
watch -web npm run build watch --gotest
# Python 开发,变更时重启脚本 # Web 前端:默认执行 npm run dev
watch -py python main.py --dev watch --web
# 自定义:监听 src 目录的 Go 文件,变更时运行 # 自定义执行命令(清屏并编译)
watch -path ./src -type go go run . watch -c -e go -i "vendor/**" go build -o app
# 仅打印变更事件(不执行命令)
watch -go
``` ```
**任务文件 `.watch`**
支持在项目根目录创建 `.watch` 文件(键值对格式),实现极简任务流。自动忽略文件中的空行和 `#` 注释。
```ini
dev = -c --go
test = --gotest
build = -c -e go -i vendor/** go build -o app
```
- 运行 `watch --dev`:直接加载 `dev` 的配置运行。
- 运行 `watch`(无参数):自动执行第一项任务!
**自动排除 `.gitignore`**
`watch` 默认会自动读取当前目录下的 `.gitignore``.ignore` 文件,并将其中的规则加入排除列表,**完全开箱即用**。
**交互式重启**
当程序运行在后台时,在终端内敲击 `rs` 并回车,即可强行手动重启。
**参数说明** **参数说明**
| 参数 | 说明 | | 参数 | 说明 |
|---|---| |---|---|
| `-path` | 监听路径(默认 `.`,递归) | | `-w, --watch` | 监听路径(默认 `.`,递归) |
| `-type` | 包含文件类型,逗号分隔 | | `-e, --ext` | 包含文件类型,逗号分隔 (如 `go,js`) |
| `-exclude-type` | 排除文件类型,逗号分隔 | | `-E, --exclude-ext` | 排除文件类型,逗号分隔 |
| `-exclude-path` | 排除路径模式可重复gitignore 语义) | | `-i, --ignore` | 排除路径模式可重复gitignore 语义) |
| `-go` | Go 开发预设go \| vendor/\*\*, \*\*/node_modules/\*\* | | `-c, --clear` | 重启前自动清屏 |
| `-web` | Web 前端预设html,css,js,ts,vue,jsx,tsx \| node_modules/\*\*, dist/\*\* | | `--go` | Go 预设 (自动补齐 `go run .` 与忽略路径) |
| `-js` | Node.js 预设js,ts,json \| node_modules/\*\* | | `--gotest` | Go 测试预设 (自动补齐 `go test -v -count=1 ./...`) |
| `-py` | Python 预设py,toml,yaml,yml \| \_\_pycache\_\_/\*\*, .venv/\*\* | | `--web` | Web 预设 (自动补齐 `npm run dev`) |
| `--js` | Node.js 预设 (自动补齐 `npm start`) |
| `--py` | Python 预设 (自动补齐 `python main.py`) |
预设值会与用户显式指定的参数合并追加,不会互相替换。 预设值会与用户显式指定的参数合并追加,遇到显式声明的新命令时,会自动替换预设默认命令
## API 指南 ## API 指南

View File

@ -1,17 +1,19 @@
# Test Report # Test Report
## 单元测试结果 ## 单元测试结果
执行时间: 2026-06-22 执行时间: 2026-06-27
``` ```
=== RUN TestWatch === RUN TestWatch
--- PASS: TestWatch (0.21s) --- PASS: TestWatch (0.21s)
=== RUN TestDirEventWithTypeFilter
--- PASS: TestDirEventWithTypeFilter (0.15s)
=== RUN TestDebounce === RUN TestDebounce
--- PASS: TestDebounce (0.26s) --- PASS: TestDebounce (0.26s)
=== RUN TestEasyStart === RUN TestEasyStart
--- PASS: TestEasyStart (0.00s) --- PASS: TestEasyStart (0.00s)
PASS PASS
ok apigo.cc/go/watch 0.852s ok apigo.cc/go/watch 1.018s
``` ```
## 性能测试结果 (Benchmark) ## 性能测试结果 (Benchmark)
@ -33,3 +35,4 @@ BenchmarkKeyGenerationWithFmt-16 7360394 167.6 ns/op
4. **路径归一化**: 验证绝对路径下的准确匹配。 4. **路径归一化**: 验证绝对路径下的准确匹配。
5. **极简接口**: 验证 `EasyStart` 的可用性。 5. **极简接口**: 验证 `EasyStart` 的可用性。
6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。 6. **性能优化验证**: 验证在高频匹配场景下的 $O(1)$ 查找能力。
7. **类型白名单与目录过滤**: 验证开启 Types 白名单时,正常过滤目录变更事件,不误触发回调 (`TestDirEventWithTypeFilter`)。

View File

@ -251,8 +251,13 @@ func (watcher *Watcher) isMatch(path string, et EventType, isDir bool) bool {
return false return false
} }
// 3. 类型过滤 (仅针对文件) // 3. 类型过滤
if !isDir { if isDir {
// 目录没有后缀,若用户配置了文件类型白名单,则目录事件不匹配
if len(watcher.typeMap) > 0 {
return false
}
} else {
ext := filepath.Ext(path) ext := filepath.Ext(path)
if len(watcher.exTypeMap) > 0 { if len(watcher.exTypeMap) > 0 {
if _, ok := watcher.exTypeMap[ext]; ok { if _, ok := watcher.exTypeMap[ext]; ok {

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"bufio"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
@ -12,78 +13,142 @@ import (
"apigo.cc/go/watch" "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 const debounceTime = 300 * time.Millisecond
// ── Preset 定义 ────────────────────────────────────────────────────────────────
type preset struct { type preset struct {
Types []string Types []string
ExcludePaths []string ExcludePaths []string
DefaultCommand []string
} }
var presets = map[string]preset{ var presets = map[string]preset{
"go": { "go": {
Types: []string{"go"}, Types: []string{"go"},
ExcludePaths: []string{"vendor/**", "**/node_modules/**"}, 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": { "web": {
Types: []string{"html", "css", "js", "ts", "vue", "jsx", "tsx"}, Types: []string{"html", "css", "js", "ts", "vue", "jsx", "tsx"},
ExcludePaths: []string{"node_modules/**", "dist/**"}, ExcludePaths: []string{"node_modules/**", "dist/**"},
DefaultCommand: []string{"npm", "run", "dev"},
}, },
"js": { "js": {
Types: []string{"js", "ts", "json"}, Types: []string{"js", "ts", "json"},
ExcludePaths: []string{"node_modules/**"}, ExcludePaths: []string{"node_modules/**"},
DefaultCommand: []string{"npm", "start"},
}, },
"py": { "py": {
Types: []string{"py", "toml", "yaml", "yml"}, Types: []string{"py", "toml", "yaml", "yml"},
ExcludePaths: []string{"__pycache__/**", ".venv/**"}, ExcludePaths: []string{"__pycache__/**", ".venv/**"},
DefaultCommand: []string{"python", "main.py"},
}, },
} }
// ── 入口 ───────────────────────────────────────────────────────────────────────
func main() { func main() {
args := os.Args[1:] args := os.Args[1:]
args = tryLoadWatchFile(args)
if len(args) == 0 {
printHelp()
return
}
var paths []string var paths []string
var types []string var types []string
var excludeTypes []string var excludeTypes []string
var excludePaths []string var excludePaths []string
var command []string var command []string
var clearScreen bool
// 自动读取 .gitignore 和 .ignore
excludePaths = append(excludePaths, parseIgnoreFiles()...)
i := 0 i := 0
for i < len(args) { for i < len(args) {
arg := args[i] arg := args[i]
switch arg { switch {
case "-h", "--help": case arg == "-h" || arg == "--help":
printHelp() printHelp()
return return
case "-path": case arg == "-c" || arg == "--clear":
clearScreen = true
case arg == "-w" || arg == "--watch":
i++ i++
if i < len(args) { if i < len(args) {
paths = append(paths, strings.Split(args[i], ",")...) paths = append(paths, strings.Split(args[i], ",")...)
} }
case "-type": case arg == "-e" || arg == "--ext":
i++ i++
if i < len(args) { if i < len(args) {
types = append(types, strings.Split(args[i], ",")...) types = append(types, strings.Split(args[i], ",")...)
} }
case "-exclude-type": case arg == "-E" || arg == "--exclude-ext":
i++ i++
if i < len(args) { if i < len(args) {
excludeTypes = append(excludeTypes, strings.Split(args[i], ",")...) excludeTypes = append(excludeTypes, strings.Split(args[i], ",")...)
} }
case "-exclude-path": case arg == "-i" || arg == "--ignore":
i++ i++
if i < len(args) { if i < len(args) {
excludePaths = append(excludePaths, args[i]) excludePaths = append(excludePaths, args[i])
} }
case "-go", "-web", "-js", "-py": case strings.HasPrefix(arg, "--") && presets[strings.TrimPrefix(arg, "--")].Types != nil:
name := strings.TrimPrefix(arg, "-") name := strings.TrimPrefix(arg, "--")
if p, ok := presets[name]; ok { p := presets[name]
types = append(types, p.Types...) types = append(types, p.Types...)
excludePaths = append(excludePaths, p.ExcludePaths...) excludePaths = append(excludePaths, p.ExcludePaths...)
if len(command) == 0 {
command = append([]string{}, p.DefaultCommand...) // 设定为预设默认命令
} }
default: default:
// 第一个非内置参数,它及其后所有内容作为命令 // 如果走到这里说明是遇到了位置参数command 或 参数)
command = args[i:] 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) i = len(args)
continue continue
} }
@ -102,84 +167,282 @@ func main() {
Debounce: debounceTime, Debounce: debounceTime,
} }
printBanner(config, command, clearScreen)
if len(command) == 0 { if len(command) == 0 {
runMonitor(config) runMonitor(config)
} else { } else {
runCommand(config, command) runCommand(config, command, clearScreen)
} }
} }
// 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) { func runMonitor(config watch.Config) {
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
w, err := watch.Start(config, func(e *watch.Event) { w, err := watch.Start(config, func(e *watch.Event) {
fmt.Printf("%s: %s\n", e.Type, e.Path) printEvent(e)
}) })
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "watch error:", err) logError("failed to start watcher: " + err.Error())
os.Exit(1) os.Exit(1)
} }
defer w.Stop() defer w.Stop()
<-sigCh <-sigCh
fmt.Printf("\n%s%s Stopped.%s\n", dim, gray, reset)
} }
func runCommand(config watch.Config, command []string) { // ── 命令模式 ───────────────────────────────────────────────────────────────────
func runCommand(config watch.Config, command []string, clearScreen bool) {
execDir, _ := os.Getwd() execDir, _ := os.Getwd()
var proc *shell.Process var proc *shell.Process
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
w, err := watch.Start(config, func(e *watch.Event) { restart := func(e *watch.Event) {
fmt.Printf("\n>> %s: %s\n", e.Type, e.Path) if clearScreen {
fmt.Print("\033[2J\033[H")
}
printEvent(e)
printRestart(command)
if proc != nil { if proc != nil {
proc.Kill() proc.Kill() // shell.Kill() 已内置完整的进程树(PGID)清理逻辑
} }
proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true}) proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true})
}) }
w, err := watch.Start(config, restart)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "watch error:", err) logError("failed to start watcher: " + err.Error())
os.Exit(1) os.Exit(1)
} }
defer w.Stop() defer w.Stop()
// 监听控制台输入 `rs` 手动重启
go func() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text == "rs" {
restart(&watch.Event{Path: "manual restart", Type: watch.Change})
}
}
}()
// 启动时先执行一次 // 启动时先执行一次
if clearScreen {
fmt.Print("\033[2J\033[H")
}
printRestart(command)
proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true}) proc, _ = shell.Start(command[0], command[1:], &shell.Options{Dir: execDir, CatchSignal: true})
<-sigCh <-sigCh
if proc != nil { if proc != nil {
proc.Kill() proc.Kill()
} }
fmt.Printf("\n%s%s Stopped.%s\n", dim, gray, reset)
os.Exit(0) os.Exit(0)
} }
// ── 输出函数 ───────────────────────────────────────────────────────────────────
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() { func printHelp() {
fmt.Print(`Usage: watch [options] [command [args...]] fmt.Print(`
` + bold + bgBlue + ` watch ` + reset + ` File watcher & command runner
` + dim + ` Recursive by default. Respects .gitignore automatically.` + reset + `
Watch file changes and execute command on change. ` + bold + `Usage` + reset + `
Without a command, just print change events to stdout. watch [options] [command [args...]]
watch # execute default task in .watch or print help
Options: ` + bold + `Options` + reset + `
-path <paths> Watch paths (comma-separated), default "." ` + cyan + `-w, --watch <path>` + reset + ` Watch paths (comma-separated), default "."
-type <types> Include file types (comma-separated), e.g. -type go,js ` + cyan + `-e, --ext <exts>` + reset + ` Include extensions (comma-separated), e.g., ` + yellow + `go,ts` + reset + `
-exclude-type <types> Exclude file types (comma-separated) ` + cyan + `-E, --exclude-ext <exts>` + reset + ` Exclude extensions (comma-separated)
-exclude-path <pattern> Exclude path pattern (repeatable, gitignore semantics) ` + cyan + `-i, --ignore <pattern>` + reset + ` Ignore glob patterns (repeatable), e.g., ` + yellow + `node_modules/**` + reset + `
` + cyan + `-c, --clear` + reset + ` Clear screen before restarting command
` + cyan + `-h, --help` + reset + ` Show this help
Presets: ` + bold + `Presets` + reset + ` (Auto-injects default commands if no command provided)
-go Go development (go | vendor/**, **/node_modules/**) ` + green + `--go` + reset + ` Go dev ext: ` + dim + `go` + reset + ` | cmd: ` + dim + `go run .` + reset + `
-web Web frontend (html,css,js,ts,vue,jsx,tsx | node_modules/**, dist/**) ` + green + `--gotest` + reset + ` Go test ext: ` + dim + `go` + reset + ` | cmd: ` + dim + `go test -v -count=1 ./...` + reset + `
-js Node.js development (js,ts,json | node_modules/**) ` + green + `--web` + reset + ` Web frontend ext: ` + dim + `html,css,js,ts,vue,jsx...` + reset + ` | cmd: ` + dim + `npm run dev` + reset + `
-py Python development (py,toml,yaml,yml | __pycache__/**, .venv/**) ` + 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 + `
Preset values merge with explicit options; they don't replace them. ` + 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.
Examples: ` + bold + `Interactive` + reset + `
watch -go go test ./... Type ` + yellow + `rs` + reset + ` and press Enter while running to force a manual restart.
watch -web npm run build
watch -py python main.py --dev
watch -path ./src -type go go run .
watch # print change events only
`) `)
} }

View File

@ -91,6 +91,52 @@ func TestWatch(t *testing.T) {
} }
} }
// TestDirEventWithTypeFilter 验证:配置了 Types 白名单时,文件夹的变化不应触发回调
func TestDirEventWithTypeFilter(t *testing.T) {
testDir := "test_dir_filter"
_ = os.RemoveAll(testDir)
_ = os.MkdirAll(testDir, 0755)
defer os.RemoveAll(testDir)
events := make(chan *watch.Event, 5)
config := watch.Config{
Paths: []string{testDir},
Types: []string{".go"}, // 只监听 .go 文件
}
w, err := watch.Start(config, func(e *watch.Event) {
events <- e
})
if err != nil {
t.Fatal(err)
}
defer w.Stop()
// 创建一个子目录,不应触发回调
subDir := filepath.Join(testDir, "subpkg")
_ = os.MkdirAll(subDir, 0755)
select {
case e := <-events:
t.Errorf("should not receive event for dir creation when Types filter is set, got %v (isDir=%v)", e.Path, e.IsDir)
case <-time.After(150 * time.Millisecond):
// OK: 文件夹创建事件被正确过滤
}
// 创建一个 .go 文件,应该触发回调
goFile, _ := filepath.Abs(filepath.Join(testDir, "main.go"))
_ = os.WriteFile(goFile, []byte("package main"), 0644)
select {
case e := <-events:
if e.Path != goFile {
t.Errorf("expected event for %s, got %s", goFile, e.Path)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("timeout waiting for .go file create event")
}
}
func TestDebounce(t *testing.T) { func TestDebounce(t *testing.T) {
testDir := "test_debounce" testDir := "test_debounce"
_ = os.RemoveAll(testDir) _ = os.RemoveAll(testDir)