watch/watch.go

300 lines
5.8 KiB
Go
Raw Normal View History

package watch
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"apigo.cc/go/file"
"apigo.cc/go/timer"
"github.com/fsnotify/fsnotify"
"github.com/gobwas/glob"
)
// EventType 定义事件类型
type EventType string
const (
Create EventType = "create" // 创建文件或目录
Change EventType = "change" // 修改文件
Remove EventType = "remove" // 删除文件或目录
Rename EventType = "rename" // 重命名文件或目录
)
// Event 包含事件的详细信息
type Event struct {
Path string // 触发事件的路径
Type EventType // 事件类型
IsDir bool // 是否为目录
}
// Config 监听配置
type Config struct {
Paths []string // 监听的路径列表
Types []string // 包含的文件类型,例如 [".go", ".js"]
ExcludeTypes []string // 排除的文件类型
Excludes []string // 排除模式,支持 gitignore 语义 (e.g. "**/node_modules/**", "*.log")
Events []EventType // 监听的事件类型,默认全部
Debounce time.Duration // 防抖时间,相同文件的频繁事件会被合并
}
// Watcher 监听器实例
type Watcher struct {
config *Config
callback func(*Event)
fsWatcher *fsnotify.Watcher
isRunning bool
mu sync.RWMutex
stopChan chan struct{}
excludeGlobs []glob.Glob
debounceGroups sync.Map // map[string]*timer.Debouncer
}
// Start 开始监听
func Start(config Config, callback func(*Event)) (*Watcher, error) {
fsWatcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
// 规范化配置
if len(config.Events) == 0 {
config.Events = []EventType{Create, Change, Remove, Rename}
}
for i, t := range config.Types {
if !strings.HasPrefix(t, ".") {
config.Types[i] = "." + t
}
}
for i, t := range config.ExcludeTypes {
if !strings.HasPrefix(t, ".") {
config.ExcludeTypes[i] = "." + t
}
}
w := &Watcher{
config: &config,
callback: callback,
fsWatcher: fsWatcher,
isRunning: true,
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 {
if err := w.Add(p); err != nil {
_ = fsWatcher.Close()
return nil, err
}
}
go w.run()
return w, nil
}
// Stop 停止监听
func (w *Watcher) Stop() {
w.mu.Lock()
if !w.isRunning {
w.mu.Unlock()
return
}
w.isRunning = false
w.mu.Unlock()
_ = w.fsWatcher.Close()
<-w.stopChan
}
// Add 动态添加监听路径(递归)
func (w *Watcher) Add(path string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
if !file.Exists(absPath) {
_ = os.MkdirAll(absPath, 0755)
}
return filepath.Walk(absPath, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if w.isMatchExclude(p, true) {
return filepath.SkipDir
}
return w.fsWatcher.Add(p)
}
return nil
})
}
func (w *Watcher) Remove(path string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
return w.fsWatcher.Remove(absPath)
}
func (w *Watcher) run() {
defer close(w.stopChan)
for {
select {
case fsEvent, ok := <-w.fsWatcher.Events:
if !ok {
return
}
w.handleFsEvent(fsEvent)
case _, ok := <-w.fsWatcher.Errors:
if !ok {
return
}
}
}
}
func (w *Watcher) handleFsEvent(fsEvent fsnotify.Event) {
name, err := filepath.Abs(fsEvent.Name)
if err != nil {
name = fsEvent.Name
}
var eventType EventType
isDir := false
if info, err := os.Stat(name); err == nil {
isDir = info.IsDir()
}
switch {
case fsEvent.Has(fsnotify.Create):
eventType = Create
if isDir {
_ = w.Add(name)
}
case fsEvent.Has(fsnotify.Write):
eventType = Change
case fsEvent.Has(fsnotify.Remove):
eventType = Remove
case fsEvent.Has(fsnotify.Rename):
eventType = Rename
default:
return
}
if !w.isMatch(name, eventType, isDir) {
return
}
event := &Event{
Path: name,
Type: eventType,
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 {
// 1. 事件类型过滤
eventMatch := false
for _, configEt := range w.config.Events {
if et == configEt {
eventMatch = true
break
}
}
if !eventMatch {
return false
}
// 2. 排除过滤 (通用)
if w.isMatchExclude(path, isDir) {
return false
}
// 3. 类型过滤 (仅针对文件)
if !isDir {
ext := filepath.Ext(path)
if len(w.config.ExcludeTypes) > 0 {
for _, t := range w.config.ExcludeTypes {
if ext == t {
return false
}
}
}
if len(w.config.Types) > 0 {
typeMatch := false
for _, t := range w.config.Types {
if ext == t {
typeMatch = true
break
}
}
if !typeMatch {
return false
}
}
}
return true
}
func (w *Watcher) isMatchExclude(path string, isDir bool) bool {
// 统一使用 / 作为路径分隔符进行匹配,对齐 gitignore 习惯
p := filepath.ToSlash(path)
base := filepath.Base(path)
for _, g := range w.excludeGlobs {
if g.Match(p) || g.Match(base) {
return true
}
// 如果是目录,尝试加上 / 匹配 (支持 **/node_modules/** 这种语法匹配目录本身)
if isDir && g.Match(p+"/") {
return true
}
}
return false
}
func (w *Watcher) WatchList() []string {
return w.fsWatcher.WatchList()
}
func EasyStart(path string, callback func(string, EventType)) (*Watcher, error) {
return Start(Config{
Paths: []string{path},
}, func(e *Event) {
callback(e.Path, e.Type)
})
}