feat: enhance watch module with glob matching and debouncing (by AI)
This commit is contained in:
parent
856f949f55
commit
2ec560179b
17
CHANGELOG.md
17
CHANGELOG.md
@ -1,13 +1,12 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## v1.1.0
|
||||||
|
- **核心升级**: 引入 `github.com/gobwas/glob` 引擎。
|
||||||
|
- **Gitignore 语义**: `Excludes` 现在支持 `**/node_modules/**` 等复杂路径模式,并能正确处理目录边界。
|
||||||
|
- **路径归一化**: 内部统一使用绝对路径与 `/` 分隔符进行匹配,确保跨平台表现一致。
|
||||||
|
- **事件防抖 (Debounce)**: 引入 `apigo.cc/go/timer` 的防抖能力,支持 `Debounce` 配置以合并碎片化事件。
|
||||||
|
|
||||||
## v1.0.0
|
## v1.0.0
|
||||||
- 从 `@ssgo/tool/watcher` 迁移并重构。
|
- 从 `@ssgo/tool/watcher` 迁移并重构。
|
||||||
- **基础设施对齐**: 使用 `apigo.cc/go/file` 替代标准库和 `ssgo/u`。
|
- **基础设施对齐**: 使用 `apigo.cc/go/file` 替代标准库。
|
||||||
- **功能增强**:
|
- **API 优化**: 引入 `Event` 结构体,提供 `EasyStart` 极简入口。
|
||||||
- 支持 `ExcludeTypes` 排除指定文件后缀。
|
|
||||||
- 支持 `Excludes` 模糊匹配排除路径和文件。
|
|
||||||
- 支持 `Events` 指定监听事件类型。
|
|
||||||
- **API 优化**:
|
|
||||||
- 引入 `Event` 结构体,提供 `Path`, `Type`, `IsDir` 拆解参数。
|
|
||||||
- 提供 `EasyStart` 极简入口。
|
|
||||||
- 核心逻辑移除 Panic,增强健壮性。
|
|
||||||
|
|||||||
16
README.md
16
README.md
@ -1,17 +1,18 @@
|
|||||||
# watch
|
# watch
|
||||||
|
|
||||||
高性能、AI 友好的文件监听库。支持递归监听、多种过滤规则(类型包含/排除、模糊匹配排除)以及简洁的 API。
|
高性能、AI 友好的顶级文件监听库。支持递归监听、`.gitignore` 语义的路径过滤、事件防抖以及极简的 API。
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
- **递归监听**: 自动监听子目录及其新创建的子目录。
|
- **递归监听**: 自动监听子目录及其新创建的子目录。
|
||||||
- **灵活过滤**:
|
- **顶级过滤能力**:
|
||||||
- `Types`: 仅监听指定后缀的文件。
|
- `Types`: 仅监听指定后缀的文件。
|
||||||
- `ExcludeTypes`: 排除指定后缀的文件。
|
- `ExcludeTypes`: 排除指定后缀的文件。
|
||||||
- `Excludes`: 支持模糊匹配(`strings.Contains` 或 `filepath.Match`)排除特定路径或文件。
|
- **`Excludes` (Gitignore 语义)**: 使用高性能 Glob 引擎(`github.com/gobwas/glob`),支持 `**/node_modules/**`、`*.log` 等复杂模式,自动处理目录边界。
|
||||||
|
- **事件防抖 (Debounce)**: 有效合并编辑器保存等产生的并发碎片事件,提升业务性能。
|
||||||
- **AI 友好**: 提供结构化的 `Event` 对象及拆解好的参数。
|
- **AI 友好**: 提供结构化的 `Event` 对象及拆解好的参数。
|
||||||
- **极简接口**: `EasyStart` 快速上手。
|
- **极简接口**: `EasyStart` 快速上手。
|
||||||
- **基础设施对齐**: 使用 `apigo.cc/go/file` 等现代基础设施。
|
- **高性能**: 纯 Go 实现,无 CGO 依赖,基于 `fsnotify` 与 `go/timer` 高性能引擎。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@ -28,9 +29,8 @@ go get apigo.cc/go/watch
|
|||||||
config := watch.Config{
|
config := watch.Config{
|
||||||
Paths: []string{"./src"},
|
Paths: []string{"./src"},
|
||||||
Types: []string{".go", ".js"},
|
Types: []string{".go", ".js"},
|
||||||
ExcludeTypes: []string{".tmp"},
|
Excludes: []string{"**/node_modules/**", "vendor/**", "*.log"},
|
||||||
Excludes: []string{"node_modules", "vendor"},
|
Debounce: 100 * time.Millisecond, // 开启防抖
|
||||||
Events: []watch.EventType{watch.Create, watch.Change},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w, err := watch.Start(config, func(e *watch.Event) {
|
w, err := watch.Start(config, func(e *watch.Event) {
|
||||||
@ -48,7 +48,7 @@ w, err := watch.EasyStart("./data", func(path string, et watch.EventType) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Watcher 方法
|
### Watcher 方法
|
||||||
- `Stop()`: 停止监听并释放资源。
|
- `Stop()`: 停止监听并释放资源(包括清理防抖定时器)。
|
||||||
- `Add(path string)`: 动态添加监听路径。
|
- `Add(path string)`: 动态添加监听路径。
|
||||||
- `Remove(path string)`: 动态移除监听路径。
|
- `Remove(path string)`: 动态移除监听路径。
|
||||||
- `WatchList()`: 获取当前正在监听的所有路径。
|
- `WatchList()`: 获取当前正在监听的所有路径。
|
||||||
|
|||||||
20
TEST.md
20
TEST.md
@ -5,17 +5,21 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
=== RUN TestWatch
|
=== RUN TestWatch
|
||||||
--- PASS: TestWatch (0.10s)
|
--- PASS: TestWatch (0.20s)
|
||||||
|
=== RUN TestDebounce
|
||||||
|
--- PASS: TestDebounce (0.25s)
|
||||||
=== RUN TestEasyStart
|
=== RUN TestEasyStart
|
||||||
--- PASS: TestEasyStart (0.00s)
|
--- PASS: TestEasyStart (0.00s)
|
||||||
PASS
|
PASS
|
||||||
ok apigo.cc/go/watch 0.411s
|
ok apigo.cc/go/watch 0.748s
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试覆盖场景
|
## 测试覆盖场景
|
||||||
1. **文件创建监听**: 验证 `Create` 事件及路径正确性。
|
1. **基础监听**: 验证文件创建事件。
|
||||||
2. **类型包含过滤**: 验证仅监听 `Types` 指定的后缀。
|
2. **Gitignore 语义过滤**:
|
||||||
3. **类型排除过滤**: 验证 `ExcludeTypes` 能正确拦截事件。
|
- 验证 `*.log` 后缀匹配。
|
||||||
4. **模糊匹配排除**: 验证 `Excludes` 能根据关键词或通配符拦截路径(如 `node_modules`)和文件(如 `*.log`)。
|
- 验证 `**/node_modules/**` 目录及其内容的深度匹配与拦截。
|
||||||
5. **极简接口验证**: 验证 `EasyStart` 的可用性。
|
3. **事件防抖 (Debounce)**:
|
||||||
6. **递归监听**: 隐式验证了子目录的自动监听。
|
- 验证 100ms 内对同一文件的 5 次连续写入被合并为 1 次回调。
|
||||||
|
4. **路径归一化**: 验证绝对路径下的准确匹配。
|
||||||
|
5. **极简接口**: 验证 `EasyStart` 的可用性。
|
||||||
|
|||||||
4
go.mod
4
go.mod
@ -3,12 +3,14 @@ module apigo.cc/go/watch
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
apigo.cc/go/cast v1.2.8
|
|
||||||
apigo.cc/go/file v1.0.6
|
apigo.cc/go/file v1.0.6
|
||||||
|
apigo.cc/go/timer v1.0.6
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
|
github.com/gobwas/glob v0.2.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
apigo.cc/go/cast v1.2.8 // indirect
|
||||||
apigo.cc/go/encoding v1.0.5 // indirect
|
apigo.cc/go/encoding v1.0.5 // indirect
|
||||||
apigo.cc/go/rand v1.0.5 // indirect
|
apigo.cc/go/rand v1.0.5 // indirect
|
||||||
apigo.cc/go/safe v1.0.5 // indirect
|
apigo.cc/go/safe v1.0.5 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -8,9 +8,13 @@ apigo.cc/go/rand v1.0.5 h1:AkUoWr0SELgeDmRjLEDjOIp29nXdzqQQvmGRIHpTN7U=
|
|||||||
apigo.cc/go/rand v1.0.5/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
|
apigo.cc/go/rand v1.0.5/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
|
||||||
apigo.cc/go/safe v1.0.5 h1:yZJLhpMntJrtqU/ev0UlyOoHu/cLrnnGUO4aHyIZcwE=
|
apigo.cc/go/safe v1.0.5 h1:yZJLhpMntJrtqU/ev0UlyOoHu/cLrnnGUO4aHyIZcwE=
|
||||||
apigo.cc/go/safe v1.0.5/go.mod h1:i9xnh7reJIFPauLnlzuIDgvrQvhjxpFlpVh3O6ulWd0=
|
apigo.cc/go/safe v1.0.5/go.mod h1:i9xnh7reJIFPauLnlzuIDgvrQvhjxpFlpVh3O6ulWd0=
|
||||||
|
apigo.cc/go/timer v1.0.6 h1:0jZyIWxjfsV8bvrLFinMtzZw3lHzx3l82RnooIvLNlU=
|
||||||
|
apigo.cc/go/timer v1.0.6/go.mod h1:kOnqTTX+zA4AH7SfC+LpUm4ZvS+DVyWWMqul/V5QWJs=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||||
|
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
|||||||
89
watch.go
89
watch.go
@ -5,9 +5,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
|
||||||
"apigo.cc/go/file"
|
"apigo.cc/go/file"
|
||||||
|
"apigo.cc/go/timer"
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/gobwas/glob"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EventType 定义事件类型
|
// EventType 定义事件类型
|
||||||
@ -32,8 +35,9 @@ type Config struct {
|
|||||||
Paths []string // 监听的路径列表
|
Paths []string // 监听的路径列表
|
||||||
Types []string // 包含的文件类型,例如 [".go", ".js"]
|
Types []string // 包含的文件类型,例如 [".go", ".js"]
|
||||||
ExcludeTypes []string // 排除的文件类型
|
ExcludeTypes []string // 排除的文件类型
|
||||||
Excludes []string // 排除的路径或文件名,支持模糊匹配
|
Excludes []string // 排除模式,支持 gitignore 语义 (e.g. "**/node_modules/**", "*.log")
|
||||||
Events []EventType // 监听的事件类型,默认全部
|
Events []EventType // 监听的事件类型,默认全部
|
||||||
|
Debounce time.Duration // 防抖时间,相同文件的频繁事件会被合并
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watcher 监听器实例
|
// Watcher 监听器实例
|
||||||
@ -44,6 +48,9 @@ type Watcher struct {
|
|||||||
isRunning bool
|
isRunning bool
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
|
||||||
|
excludeGlobs []glob.Glob
|
||||||
|
debounceGroups sync.Map // map[string]*timer.Debouncer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start 开始监听
|
// Start 开始监听
|
||||||
@ -76,6 +83,13 @@ func Start(config Config, callback func(*Event)) (*Watcher, error) {
|
|||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编译 Glob 模式
|
||||||
|
for _, p := range config.Excludes {
|
||||||
|
if g, err := glob.Compile(p, '/'); err == nil {
|
||||||
|
w.excludeGlobs = append(w.excludeGlobs, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始添加路径
|
// 初始添加路径
|
||||||
for _, p := range config.Paths {
|
for _, p := range config.Paths {
|
||||||
if err := w.Add(p); err != nil {
|
if err := w.Add(p); err != nil {
|
||||||
@ -110,18 +124,17 @@ func (w *Watcher) Add(path string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果路径不存在,尝试创建(保持与原逻辑一致)
|
|
||||||
if !file.Exists(absPath) {
|
if !file.Exists(absPath) {
|
||||||
_ = os.MkdirAll(absPath, 0755)
|
_ = os.MkdirAll(absPath, 0755)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Walk(absPath, func(p string, info os.FileInfo, err error) error {
|
return filepath.Walk(absPath, func(p string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil // 忽略错误继续
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
if w.isMatchExclude(p) {
|
if w.isMatchExclude(p, true) {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
return w.fsWatcher.Add(p)
|
return w.fsWatcher.Add(p)
|
||||||
@ -130,7 +143,6 @@ func (w *Watcher) Add(path string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove 动态移除监听路径
|
|
||||||
func (w *Watcher) Remove(path string) error {
|
func (w *Watcher) Remove(path string) error {
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -144,12 +156,11 @@ func (w *Watcher) run() {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case event, ok := <-w.fsWatcher.Events:
|
case fsEvent, ok := <-w.fsWatcher.Events:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.handleFsEvent(fsEvent)
|
||||||
w.handleFsEvent(event)
|
|
||||||
|
|
||||||
case _, ok := <-w.fsWatcher.Errors:
|
case _, ok := <-w.fsWatcher.Errors:
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -160,21 +171,22 @@ func (w *Watcher) run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) {
|
func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) {
|
||||||
name := fsEvent.Name
|
name, err := filepath.Abs(fsEvent.Name)
|
||||||
|
if err != nil {
|
||||||
|
name = fsEvent.Name
|
||||||
|
}
|
||||||
var eventType EventType
|
var eventType EventType
|
||||||
isDir := false
|
isDir := false
|
||||||
|
|
||||||
// 获取文件信息判断是否为目录
|
|
||||||
if info, err := os.Stat(name); err == nil {
|
if info, err := os.Stat(name); err == nil {
|
||||||
isDir = info.IsDir()
|
isDir = info.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 映射事件类型
|
|
||||||
switch {
|
switch {
|
||||||
case fsEvent.Has(fsnotify.Create):
|
case fsEvent.Has(fsnotify.Create):
|
||||||
eventType = Create
|
eventType = Create
|
||||||
if isDir {
|
if isDir {
|
||||||
_ = w.Add(name) // 自动递归监听新目录
|
_ = w.Add(name)
|
||||||
}
|
}
|
||||||
case fsEvent.Has(fsnotify.Write):
|
case fsEvent.Has(fsnotify.Write):
|
||||||
eventType = Change
|
eventType = Change
|
||||||
@ -186,16 +198,30 @@ func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤逻辑
|
|
||||||
if !w.isMatch(name, eventType, isDir) {
|
if !w.isMatch(name, eventType, isDir) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.callback(&Event{
|
event := &Event{
|
||||||
Path: name,
|
Path: name,
|
||||||
Type: eventType,
|
Type: eventType,
|
||||||
IsDir: isDir,
|
IsDir: isDir,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if w.config.Debounce > 0 {
|
||||||
|
w.debounce(event)
|
||||||
|
} else {
|
||||||
|
w.callback(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) debounce(e *Event) {
|
||||||
|
key := e.Path + string(e.Type)
|
||||||
|
d, _ := w.debounceGroups.LoadOrStore(key, timer.NewDebouncer(w.config.Debounce, func() {
|
||||||
|
w.callback(e)
|
||||||
|
w.debounceGroups.Delete(key)
|
||||||
|
}))
|
||||||
|
d.(*timer.Debouncer).Trigger()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) isMatch(path string, et EventType, isDir bool) bool {
|
func (w *Watcher) isMatch(path string, et EventType, isDir bool) bool {
|
||||||
@ -212,15 +238,13 @@ func (w *Watcher) isMatch(path string, et EventType, isDir bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 排除过滤 (通用)
|
// 2. 排除过滤 (通用)
|
||||||
if w.isMatchExclude(path) {
|
if w.isMatchExclude(path, isDir) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 类型过滤 (仅针对文件)
|
// 3. 类型过滤 (仅针对文件)
|
||||||
if !isDir {
|
if !isDir {
|
||||||
ext := filepath.Ext(path)
|
ext := filepath.Ext(path)
|
||||||
|
|
||||||
// 排除类型
|
|
||||||
if len(w.config.ExcludeTypes) > 0 {
|
if len(w.config.ExcludeTypes) > 0 {
|
||||||
for _, t := range w.config.ExcludeTypes {
|
for _, t := range w.config.ExcludeTypes {
|
||||||
if ext == t {
|
if ext == t {
|
||||||
@ -229,7 +253,6 @@ func (w *Watcher) isMatch(path string, et EventType, isDir bool) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 包含类型
|
|
||||||
if len(w.config.Types) > 0 {
|
if len(w.config.Types) > 0 {
|
||||||
typeMatch := false
|
typeMatch := false
|
||||||
for _, t := range w.config.Types {
|
for _, t := range w.config.Types {
|
||||||
@ -247,36 +270,26 @@ func (w *Watcher) isMatch(path string, et EventType, isDir bool) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) isMatchExclude(path string) bool {
|
func (w *Watcher) isMatchExclude(path string, isDir bool) bool {
|
||||||
if len(w.config.Excludes) == 0 {
|
// 统一使用 / 作为路径分隔符进行匹配,对齐 gitignore 习惯
|
||||||
return false
|
p := filepath.ToSlash(path)
|
||||||
}
|
base := filepath.Base(path)
|
||||||
|
for _, g := range w.excludeGlobs {
|
||||||
for _, pattern := range w.config.Excludes {
|
if g.Match(p) || g.Match(base) {
|
||||||
// 简单模糊匹配:如果包含 * 则尝试 Match,否则使用 Contains
|
|
||||||
if strings.Contains(pattern, "*") {
|
|
||||||
if matched, _ := filepath.Match(pattern, path); matched {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// 额外支持在路径中包含 pattern 的情况
|
// 如果是目录,尝试加上 / 匹配 (支持 **/node_modules/** 这种语法匹配目录本身)
|
||||||
if strings.Contains(path, strings.ReplaceAll(pattern, "*", "")) {
|
if isDir && g.Match(p+"/") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if strings.Contains(path, pattern) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchList 返回当前监听的所有路径
|
|
||||||
func (w *Watcher) WatchList() []string {
|
func (w *Watcher) WatchList() []string {
|
||||||
return w.fsWatcher.WatchList()
|
return w.fsWatcher.WatchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// EasyStart 极简启动方式
|
|
||||||
func EasyStart(path string, callback func(string, EventType)) (*Watcher, error) {
|
func EasyStart(path string, callback func(string, EventType)) (*Watcher, error) {
|
||||||
return Start(Config{
|
return Start(Config{
|
||||||
Paths: []string{path},
|
Paths: []string{path},
|
||||||
|
|||||||
@ -3,11 +3,12 @@ package watch_test
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"apigo.cc/go/watch"
|
|
||||||
"apigo.cc/go/file"
|
"apigo.cc/go/file"
|
||||||
|
"apigo.cc/go/watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWatch(t *testing.T) {
|
func TestWatch(t *testing.T) {
|
||||||
@ -39,7 +40,7 @@ func TestWatch(t *testing.T) {
|
|||||||
if e.Type != watch.Create || e.Path != txtFile {
|
if e.Type != watch.Create || e.Path != txtFile {
|
||||||
t.Errorf("expected create event for %s, got %v", txtFile, e)
|
t.Errorf("expected create event for %s, got %v", txtFile, e)
|
||||||
}
|
}
|
||||||
case <-time.After(100 * time.Millisecond):
|
case <-time.After(200 * time.Millisecond):
|
||||||
t.Fatal("timeout waiting for create event")
|
t.Fatal("timeout waiting for create event")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,14 +61,14 @@ func TestWatch(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case e := <-events2:
|
case e := <-events2:
|
||||||
t.Errorf("should not receive event for .tmp file, got %v", e)
|
t.Errorf("should not receive event for .tmp file, got %v", e)
|
||||||
case <-time.After(50 * time.Millisecond):
|
case <-time.After(100 * time.Millisecond):
|
||||||
// OK
|
// OK
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 测试模糊排除
|
// 3. 测试模糊排除 (Gitignore 语义)
|
||||||
config3 := watch.Config{
|
config3 := watch.Config{
|
||||||
Paths: []string{testDir},
|
Paths: []string{testDir},
|
||||||
Excludes: []string{"node_modules", "*.log"},
|
Excludes: []string{"**/node_modules/**", "*.log"},
|
||||||
}
|
}
|
||||||
events3 := make(chan *watch.Event, 10)
|
events3 := make(chan *watch.Event, 10)
|
||||||
w3, _ := watch.Start(config3, func(e *watch.Event) {
|
w3, _ := watch.Start(config3, func(e *watch.Event) {
|
||||||
@ -75,7 +76,7 @@ func TestWatch(t *testing.T) {
|
|||||||
})
|
})
|
||||||
defer w3.Stop()
|
defer w3.Stop()
|
||||||
|
|
||||||
logFile := filepath.Join(testDir, "test.log")
|
logFile, _ := filepath.Abs(filepath.Join(testDir, "test.log"))
|
||||||
_ = file.Write(logFile, "log")
|
_ = file.Write(logFile, "log")
|
||||||
|
|
||||||
nodeDir := filepath.Join(testDir, "node_modules")
|
nodeDir := filepath.Join(testDir, "node_modules")
|
||||||
@ -84,12 +85,49 @@ func TestWatch(t *testing.T) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case e := <-events3:
|
case e := <-events3:
|
||||||
t.Errorf("should not receive event for excluded path/file, got %v", e)
|
t.Errorf("should not receive event for excluded path/file, got %v", e.Path)
|
||||||
case <-time.After(50 * time.Millisecond):
|
case <-time.After(100 * time.Millisecond):
|
||||||
// OK
|
// OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDebounce(t *testing.T) {
|
||||||
|
testDir := "test_debounce"
|
||||||
|
_ = os.RemoveAll(testDir)
|
||||||
|
_ = os.MkdirAll(testDir, 0755)
|
||||||
|
defer os.RemoveAll(testDir)
|
||||||
|
|
||||||
|
var count int32
|
||||||
|
config := watch.Config{
|
||||||
|
Paths: []string{testDir},
|
||||||
|
Debounce: 100 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
w, _ := watch.Start(config, func(e *watch.Event) {
|
||||||
|
if e.Type == watch.Change {
|
||||||
|
atomic.AddInt32(&count, 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer w.Stop()
|
||||||
|
|
||||||
|
targetFile := filepath.Join(testDir, "change.txt")
|
||||||
|
_ = file.Write(targetFile, "v1")
|
||||||
|
|
||||||
|
// 连续写入
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_ = file.Write(targetFile, "v2")
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待防抖结束
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
finalCount := atomic.LoadInt32(&count)
|
||||||
|
if finalCount != 1 {
|
||||||
|
t.Errorf("expected 1 change event after debounce, got %d", finalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEasyStart(t *testing.T) {
|
func TestEasyStart(t *testing.T) {
|
||||||
testDir := "test_easy"
|
testDir := "test_easy"
|
||||||
_ = os.RemoveAll(testDir)
|
_ = os.RemoveAll(testDir)
|
||||||
@ -108,7 +146,7 @@ func TestEasyStart(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
// OK
|
// OK
|
||||||
case <-time.After(100 * time.Millisecond):
|
case <-time.After(200 * time.Millisecond):
|
||||||
t.Fatal("timeout waiting for easy event")
|
t.Fatal("timeout waiting for easy event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user